diff --git a/frontends/nextjs/src/lib/rendering/page/builder-types.ts b/frontends/nextjs/src/lib/rendering/page/builder-types.ts index 346266bd4..bc9bfb617 100644 --- a/frontends/nextjs/src/lib/rendering/page/builder-types.ts +++ b/frontends/nextjs/src/lib/rendering/page/builder-types.ts @@ -1 +1 @@ -export * from '../builder-types' +export * from '@/lib/types/builder-types' diff --git a/frontends/nextjs/src/lib/rendering/page/components.ts b/frontends/nextjs/src/lib/rendering/page/components.ts new file mode 100644 index 000000000..b5da67a93 --- /dev/null +++ b/frontends/nextjs/src/lib/rendering/page/components.ts @@ -0,0 +1,99 @@ +import type { ComponentInstance } from '@/lib/types/builder-types' + +export const buildHeaderActions = (): ComponentInstance[] => [ + { + id: 'header_login_btn', + type: 'Button', + props: { + children: 'Login', + variant: 'default', + size: 'sm', + }, + children: [], + }, +] + +export const buildProfileCard = (): ComponentInstance => ({ + id: 'comp_profile', + type: 'Card', + props: { + className: 'p-6', + }, + children: [ + { + id: 'comp_profile_header', + type: 'Heading', + props: { + level: 2, + children: 'User Profile', + className: 'text-2xl font-bold mb-4', + }, + children: [], + }, + { + id: 'comp_profile_content', + type: 'Container', + props: { + className: 'space-y-4', + }, + children: [ + { + id: 'comp_profile_bio', + type: 'Textarea', + props: { + placeholder: 'Tell us about yourself...', + className: 'min-h-32', + }, + children: [], + }, + { + id: 'comp_profile_save', + type: 'Button', + props: { + children: 'Save Profile', + variant: 'default', + }, + children: [], + }, + ], + }, + ], +}) + +export const buildCommentsCard = (): ComponentInstance => ({ + id: 'comp_comments', + type: 'Card', + props: { + className: 'p-6', + }, + children: [ + { + id: 'comp_comments_header', + type: 'Heading', + props: { + level: 2, + children: 'Community Comments', + className: 'text-2xl font-bold mb-4', + }, + children: [], + }, + { + id: 'comp_comments_input', + type: 'Textarea', + props: { + placeholder: 'Share your thoughts...', + className: 'mb-4', + }, + children: [], + }, + { + id: 'comp_comments_post', + type: 'Button', + props: { + children: 'Post Comment', + variant: 'default', + }, + children: [], + }, + ], +}) diff --git a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/build-level2-user-dashboard.ts b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/build-level2-user-dashboard.ts index b20c55eec..ecadaccbd 100644 --- a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/build-level2-user-dashboard.ts +++ b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/build-level2-user-dashboard.ts @@ -1,131 +1,46 @@ -import type { PageDefinition } from './page-renderer' -import type { ComponentInstance } from './builder-types' import { Database } from '@/lib/database' +import { buildCommentsCard, buildProfileCard } from '@/lib/rendering/page/components' +import type { PageDefinition } from '@/lib/rendering/page/page-renderer' export function buildLevel2UserDashboard(): PageDefinition { - const profileCard: ComponentInstance = { - id: 'comp_profile', - type: 'Card', - props: { - className: 'p-6' - }, - children: [ + return { + id: 'page_level2_dashboard', + level: 2, + title: 'User Dashboard', + description: 'User dashboard with profile and comments', + layout: 'dashboard', + components: [buildProfileCard(), buildCommentsCard()], + permissions: { + requiresAuth: true, + requiredRole: 'user', + }, + metadata: { + showHeader: true, + showFooter: false, + headerTitle: 'Dashboard', + sidebarItems: [ { - id: 'comp_profile_header', - type: 'Heading', - props: { - level: 2, - children: 'User Profile', - className: 'text-2xl font-bold mb-4' - }, - children: [] + id: 'nav_home', + label: 'Home', + icon: '🏠', + action: 'navigate', + target: '1', }, { - id: 'comp_profile_content', - type: 'Container', - props: { - className: 'space-y-4' - }, - children: [ - { - id: 'comp_profile_bio', - type: 'Textarea', - props: { - placeholder: 'Tell us about yourself...', - className: 'min-h-32' - }, - children: [] - }, - { - id: 'comp_profile_save', - type: 'Button', - props: { - children: 'Save Profile', - variant: 'default' - }, - children: [] - } - ] - } - ] - } - - const commentsCard: ComponentInstance = { - id: 'comp_comments', - type: 'Card', - props: { - className: 'p-6' - }, - children: [ - { - id: 'comp_comments_header', - type: 'Heading', - props: { - level: 2, - children: 'Community Comments', - className: 'text-2xl font-bold mb-4' - }, - children: [] + id: 'nav_profile', + label: 'Profile', + icon: '👤', + action: 'navigate', + target: '2', }, { - id: 'comp_comments_input', - type: 'Textarea', - props: { - placeholder: 'Share your thoughts...', - className: 'mb-4' - }, - children: [] + id: 'nav_chat', + label: 'Chat', + icon: '💬', + action: 'navigate', + target: '2', }, - { - id: 'comp_comments_post', - type: 'Button', - props: { - children: 'Post Comment', - variant: 'default' - }, - children: [] - } - ] - } - - return { - id: 'page_level2_dashboard', - level: 2, - title: 'User Dashboard', - description: 'User dashboard with profile and comments', - layout: 'dashboard', - components: [profileCard, commentsCard], - permissions: { - requiresAuth: true, - requiredRole: 'user' - }, - metadata: { - showHeader: true, - showFooter: false, - headerTitle: 'Dashboard', - sidebarItems: [ - { - id: 'nav_home', - label: 'Home', - icon: '🏠', - action: 'navigate', - target: '1' - }, - { - id: 'nav_profile', - label: 'Profile', - icon: '👤', - action: 'navigate', - target: '2' - }, - { - id: 'nav_chat', - label: 'Chat', - icon: '💬', - action: 'navigate', - target: '2' - } - ] - } - } + ], + }, + } } diff --git a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/build-level3-admin-panel.ts b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/build-level3-admin-panel.ts index c01748387..09decf394 100644 --- a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/build-level3-admin-panel.ts +++ b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/build-level3-admin-panel.ts @@ -1,4 +1,4 @@ -import type { PageDefinition } from './page-renderer' +import type { PageDefinition } from '@/lib/rendering/page/page-renderer' import type { ComponentInstance } from './builder-types' import { Database } from '@/lib/database' diff --git a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/get-page-definition-builder.ts b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/get-page-definition-builder.ts index bb5d846e8..52bea90f7 100644 --- a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/get-page-definition-builder.ts +++ b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/get-page-definition-builder.ts @@ -1,4 +1,4 @@ -import type { PageDefinition } from './page-renderer' +import type { PageDefinition } from '@/lib/rendering/page/page-renderer' import type { ComponentInstance } from './builder-types' import { Database } from '@/lib/database' diff --git a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/get-pages.ts b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/get-pages.ts index f8c718851..c92c18a88 100644 --- a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/get-pages.ts +++ b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/get-pages.ts @@ -1,4 +1,4 @@ -import type { PageDefinition } from './page-renderer' +import type { PageDefinition } from '@/lib/rendering/page/page-renderer' import type { ComponentInstance } from './builder-types' import { Database } from '@/lib/database' diff --git a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/homepage/build-level1-homepage.ts b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/homepage/build-level1-homepage.ts index 0b21ffdfa..071f69f15 100644 --- a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/homepage/build-level1-homepage.ts +++ b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/homepage/build-level1-homepage.ts @@ -1,21 +1,9 @@ import type { PageDefinition } from '@/lib/rendering/page/page-renderer' import type { ComponentInstance } from '@/lib/rendering/page/builder-types' +import { buildHeaderActions } from '@/lib/rendering/page/components' import { buildFeaturesComponent } from './build-features-component' import { buildHeroComponent } from './build-hero-component' -const buildHeaderActions = (): ComponentInstance[] => [ - { - id: 'header_login_btn', - type: 'Button', - props: { - children: 'Login', - variant: 'default', - size: 'sm' - }, - children: [] - } -] - export const buildLevel1Homepage = (): PageDefinition => { const heroComponent = buildHeroComponent() const featuresComponent = buildFeaturesComponent() diff --git a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/initialize-default-pages.ts b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/initialize-default-pages.ts index aadeb907e..8f2a89c30 100644 --- a/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/initialize-default-pages.ts +++ b/frontends/nextjs/src/lib/rendering/page/page-definition-builder/functions/initialize-default-pages.ts @@ -1,4 +1,4 @@ -import type { PageDefinition } from './page-renderer' +import type { PageDefinition } from '@/lib/rendering/page/page-renderer' import type { ComponentInstance } from './builder-types' import { Database } from '@/lib/database' diff --git a/frontends/nextjs/src/lib/rendering/page/page-renderer.ts b/frontends/nextjs/src/lib/rendering/page/page-renderer.ts index 1e56867c6..04e953bdd 100644 --- a/frontends/nextjs/src/lib/rendering/page/page-renderer.ts +++ b/frontends/nextjs/src/lib/rendering/page/page-renderer.ts @@ -1,8 +1,8 @@ -import type { ComponentInstance } from '../types/builder-types' -import type { User } from '../types/level-types' -import { Database } from '../database' -import type { LuaEngine } from '../lua-engine' -import { executeLuaScriptWithProfile } from '../lua/execute-lua-script-with-profile' +import { Database } from '@/lib/database' +import type { LuaEngine } from '@/lib/lua-engine' +import { executeLuaScriptWithProfile } from '@/lib/lua/execute-lua-script-with-profile' +import type { ComponentInstance } from '@/lib/types/builder-types' +import type { User } from '@/lib/types/level-types' export interface PageDefinition { id: string diff --git a/frontends/nextjs/src/lib/rendering/page/utils.ts b/frontends/nextjs/src/lib/rendering/page/utils.ts new file mode 100644 index 000000000..22472c760 --- /dev/null +++ b/frontends/nextjs/src/lib/rendering/page/utils.ts @@ -0,0 +1,29 @@ +import type { ComponentInstance } from '@/lib/types/builder-types' +import type { User, UserRole } from '@/lib/types/level-types' +import type { PageDefinition } from './page-renderer' + +export function createMockPage( + id: string, + options: Partial = {} +): PageDefinition { + return { + id, + level: options.level ?? 1, + title: options.title ?? `Page ${id}`, + layout: options.layout ?? 'default', + components: (options.components as ComponentInstance[] | undefined) ?? [], + permissions: options.permissions, + luaScripts: options.luaScripts, + metadata: options.metadata, + } +} + +export function createMockUser(role: UserRole | string, id = 'user1'): User { + return { + id, + username: `User ${id}`, + role: role as UserRole, + email: `${id}@test.com`, + createdAt: Date.now(), + } +} diff --git a/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.evaluation.test.ts b/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.evaluation.test.ts new file mode 100644 index 000000000..e562eae07 --- /dev/null +++ b/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.evaluation.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { DeclarativeComponentRenderer } from '@/lib/rendering/declarative-component-renderer' + +describe('declarative-component-renderer evaluation', () => { + let renderer: DeclarativeComponentRenderer + + beforeEach(() => { + renderer = new DeclarativeComponentRenderer() + }) + + describe('interpolateValue', () => { + it.each([ + { + name: 'simple interpolation', + template: 'Hello {name}!', + context: { name: 'World' }, + expected: 'Hello World!', + }, + { + name: 'multiple placeholders', + template: '{greeting} {name}, welcome to {place}', + context: { greeting: 'Hi', name: 'Alice', place: 'Wonderland' }, + expected: 'Hi Alice, welcome to Wonderland', + }, + { + name: 'missing placeholder', + template: 'Hello {name}, age: {age}', + context: { name: 'Bob' }, + expected: 'Hello Bob, age: {age}', + }, + { + name: 'numeric value', + template: 'Count: {count}', + context: { count: 42 }, + expected: 'Count: 42', + }, + { + name: 'boolean value', + template: 'Active: {active}', + context: { active: true }, + expected: 'Active: true', + }, + { + name: 'empty template', + template: '', + context: { name: 'test' }, + expected: '', + }, + { + name: 'no placeholders', + template: 'Plain text', + context: { name: 'ignored' }, + expected: 'Plain text', + }, + { + name: 'null template', + template: null as any, + context: { name: 'test' }, + expected: null, + }, + { + name: 'undefined value in context', + template: 'Value: {val}', + context: { val: undefined }, + expected: 'Value: {val}', + }, + ])('should handle $name', ({ template, context, expected }) => { + expect(renderer.interpolateValue(template, context)).toBe(expected) + }) + }) + + describe('evaluateConditional', () => { + it.each([ + { name: 'boolean true', condition: true, context: {}, expected: true }, + { name: 'boolean false', condition: false, context: {}, expected: false }, + { name: 'empty string condition', condition: '', context: {}, expected: true }, + { name: 'null condition', condition: null as any, context: {}, expected: true }, + { name: 'undefined condition', condition: undefined as any, context: {}, expected: true }, + { name: 'truthy context value', condition: 'isActive', context: { isActive: true }, expected: true }, + { name: 'falsy context value', condition: 'isActive', context: { isActive: false }, expected: false }, + { name: 'missing context key', condition: 'missing', context: {}, expected: false }, + { name: 'truthy string value', condition: 'name', context: { name: 'test' }, expected: true }, + { name: 'empty string value', condition: 'name', context: { name: '' }, expected: false }, + { name: 'zero value', condition: 'count', context: { count: 0 }, expected: false }, + { name: 'positive number', condition: 'count', context: { count: 5 }, expected: true }, + ])('should return $expected for $name', ({ condition, context, expected }) => { + expect(renderer.evaluateConditional(condition, context)).toBe(expected) + }) + }) + + describe('resolveDataSource', () => { + it.each([ + { + name: 'existing array data source', + dataSource: 'items', + context: { items: [1, 2, 3] }, + expected: [1, 2, 3], + }, + { + name: 'empty array data source', + dataSource: 'items', + context: { items: [] }, + expected: [], + }, + { + name: 'missing data source', + dataSource: 'missing', + context: {}, + expected: [], + }, + { + name: 'null data source key', + dataSource: '', + context: { items: [1] }, + expected: [], + }, + { + name: 'object array data source', + dataSource: 'users', + context: { users: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] }, + expected: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }], + }, + ])('should resolve $name', ({ dataSource, context, expected }) => { + expect(renderer.resolveDataSource(dataSource, context)).toEqual(expected) + }) + }) +}) diff --git a/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.lifecycle.test.ts b/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.lifecycle.test.ts new file mode 100644 index 000000000..05f80594d --- /dev/null +++ b/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.lifecycle.test.ts @@ -0,0 +1,183 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + DeclarativeComponentRenderer, + getDeclarativeRenderer, + loadPackageComponents, + type DeclarativeComponentConfig, +} from '@/lib/rendering/declarative-component-renderer' + +describe('declarative-component-renderer lifecycle', () => { + let renderer: DeclarativeComponentRenderer + + beforeEach(() => { + renderer = new DeclarativeComponentRenderer() + }) + + describe('registerComponentConfig', () => { + it.each([ + { + name: 'basic component', + type: 'button', + config: { + type: 'button', + category: 'input', + label: 'Button', + description: 'A clickable button', + icon: 'click', + props: [], + config: { layout: 'inline', styling: { className: 'btn' }, children: [] }, + }, + }, + { + name: 'component with props', + type: 'input', + config: { + type: 'input', + category: 'form', + label: 'Input Field', + description: 'Text input', + icon: 'text', + props: [ + { name: 'placeholder', type: 'string', label: 'Placeholder', required: false }, + { name: 'value', type: 'string', label: 'Value', required: true, defaultValue: '' }, + ], + config: { layout: 'block', styling: { className: 'input' }, children: [] }, + }, + }, + ])('should register $name', ({ type, config }) => { + renderer.registerComponentConfig(type, config as DeclarativeComponentConfig) + + expect(renderer.hasComponentConfig(type)).toBe(true) + expect(renderer.getComponentConfig(type)).toEqual(config) + }) + }) + + describe('hasComponentConfig', () => { + it.each([ + { type: 'registered', shouldRegister: true, expected: true }, + { type: 'unregistered', shouldRegister: false, expected: false }, + ])('should return $expected for $type component', ({ type, shouldRegister, expected }) => { + if (shouldRegister) { + renderer.registerComponentConfig(type, { + type, + category: 'test', + label: 'Test', + description: '', + icon: '', + props: [], + config: { layout: '', styling: { className: '' }, children: [] }, + }) + } + + expect(renderer.hasComponentConfig(type)).toBe(expected) + }) + }) + + describe('getComponentConfig', () => { + it('should return undefined for non-existent component', () => { + expect(renderer.getComponentConfig('nonexistent')).toBeUndefined() + }) + + it('should return config for registered component', () => { + const config: DeclarativeComponentConfig = { + type: 'test', + category: 'test', + label: 'Test Component', + description: 'A test', + icon: 'test', + props: [], + config: { layout: 'block', styling: { className: 'test' }, children: [] }, + } + renderer.registerComponentConfig('test', config) + + expect(renderer.getComponentConfig('test')).toEqual(config) + }) + }) + + describe('getDeclarativeRenderer', () => { + it('should return a global renderer instance', () => { + const renderer1 = getDeclarativeRenderer() + const renderer2 = getDeclarativeRenderer() + + expect(renderer1).toBe(renderer2) + expect(renderer1).toBeInstanceOf(DeclarativeComponentRenderer) + }) + }) + + describe('loadPackageComponents', () => { + it('should load component configs from package', () => { + const renderer = getDeclarativeRenderer() + const testType = `loadTest_${Date.now()}` + + loadPackageComponents({ + componentConfigs: { + [testType]: { + type: testType, + category: 'test', + label: 'Loaded Component', + description: 'Loaded from package', + icon: 'package', + props: [], + config: { layout: 'block', styling: { className: 'loaded' }, children: [] }, + }, + }, + }) + + expect(renderer.hasComponentConfig(testType)).toBe(true) + }) + + it('should load Lua scripts from package', () => { + const luaExecuteSpy = vi.spyOn(DeclarativeComponentRenderer.prototype as any, 'executeLuaScript') + + loadPackageComponents({ + luaScripts: [ + { + id: `pkgScript_${Date.now()}`, + code: 'function formatTime() return 1 end', + parameters: [], + returnType: 'number', + }, + ], + }) + + expect(luaExecuteSpy).not.toHaveBeenCalled() + }) + + it('should handle empty package content', () => { + loadPackageComponents({}) + loadPackageComponents({ componentConfigs: {} }) + loadPackageComponents({ luaScripts: [] }) + + expect(true).toBe(true) + }) + + it('should handle package with both configs and scripts', () => { + const renderer = getDeclarativeRenderer() + const uniqueId = Date.now() + + loadPackageComponents({ + componentConfigs: { + [`combo_${uniqueId}`]: { + type: `combo_${uniqueId}`, + category: 'combo', + label: 'Combo', + description: 'Combined', + icon: 'combo', + props: [], + config: { layout: 'flex', styling: { className: 'combo' }, children: [] }, + }, + }, + luaScripts: [ + { + id: `comboScript_${uniqueId}`, + code: 'function userJoin(name) return "Welcome " .. name end', + parameters: [{ name: 'name' }], + returnType: 'string', + }, + ], + }) + + expect(renderer.hasComponentConfig(`combo_${uniqueId}`)).toBe(true) + }) + }) +}) diff --git a/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.lua.test.ts b/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.lua.test.ts new file mode 100644 index 000000000..378a55af5 --- /dev/null +++ b/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.lua.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { DeclarativeComponentRenderer } from '@/lib/rendering/declarative-component-renderer' + +describe('declarative-component-renderer lua integration', () => { + let renderer: DeclarativeComponentRenderer + + beforeEach(() => { + renderer = new DeclarativeComponentRenderer() + }) + + describe('registerLuaScript', () => { + it('should register and store Lua scripts', () => { + const script = { + code: 'return x + y', + parameters: [{ name: 'x' }, { name: 'y' }], + returnType: 'number', + } + renderer.registerLuaScript('add', script) + + expect(renderer.executeLuaScript('add', [1, 2])).resolves.toBeDefined() + }) + }) + + describe('executeLuaScript', () => { + it('should throw error for non-existent script', async () => { + await expect(renderer.executeLuaScript('nonexistent', [])).rejects.toThrow( + 'Lua script not found: nonexistent' + ) + }) + + it('should execute script with parameters', async () => { + renderer.registerLuaScript('testScript', { + code: ` +function formatTime(timestamp) + return timestamp * 1000 +end +`, + parameters: [{ name: 'timestamp' }], + returnType: 'number', + }) + + const result = await renderer.executeLuaScript('testScript', [5]) + expect(result).toBe(5000) + }) + + it('should handle script with no parameters', async () => { + renderer.registerLuaScript('constantScript', { + code: ` +function formatTime() + return 42 +end +`, + parameters: [], + returnType: 'number', + }) + + const result = await renderer.executeLuaScript('constantScript', []) + expect(result).toBe(42) + }) + }) +}) diff --git a/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.test.ts b/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.test.ts deleted file mode 100644 index a46bb5bde..000000000 --- a/frontends/nextjs/src/lib/rendering/tests/declarative-component-renderer.test.ts +++ /dev/null @@ -1,355 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { - DeclarativeComponentRenderer, - getDeclarativeRenderer, - loadPackageComponents, - type DeclarativeComponentConfig, -} from './declarative-component-renderer' - -describe('declarative-component-renderer', () => { - let renderer: DeclarativeComponentRenderer - - beforeEach(() => { - renderer = new DeclarativeComponentRenderer() - }) - - describe('DeclarativeComponentRenderer', () => { - describe('registerComponentConfig', () => { - it.each([ - { - name: 'basic component', - type: 'button', - config: { - type: 'button', - category: 'input', - label: 'Button', - description: 'A clickable button', - icon: 'click', - props: [], - config: { layout: 'inline', styling: { className: 'btn' }, children: [] }, - }, - }, - { - name: 'component with props', - type: 'input', - config: { - type: 'input', - category: 'form', - label: 'Input Field', - description: 'Text input', - icon: 'text', - props: [ - { name: 'placeholder', type: 'string', label: 'Placeholder', required: false }, - { name: 'value', type: 'string', label: 'Value', required: true, defaultValue: '' }, - ], - config: { layout: 'block', styling: { className: 'input' }, children: [] }, - }, - }, - ])('should register $name', ({ type, config }) => { - renderer.registerComponentConfig(type, config as DeclarativeComponentConfig) - - expect(renderer.hasComponentConfig(type)).toBe(true) - expect(renderer.getComponentConfig(type)).toEqual(config) - }) - }) - - describe('hasComponentConfig', () => { - it.each([ - { type: 'registered', shouldRegister: true, expected: true }, - { type: 'unregistered', shouldRegister: false, expected: false }, - ])('should return $expected for $type component', ({ type, shouldRegister, expected }) => { - if (shouldRegister) { - renderer.registerComponentConfig(type, { - type, - category: 'test', - label: 'Test', - description: '', - icon: '', - props: [], - config: { layout: '', styling: { className: '' }, children: [] }, - }) - } - - expect(renderer.hasComponentConfig(type)).toBe(expected) - }) - }) - - describe('getComponentConfig', () => { - it('should return undefined for non-existent component', () => { - expect(renderer.getComponentConfig('nonexistent')).toBeUndefined() - }) - - it('should return config for registered component', () => { - const config: DeclarativeComponentConfig = { - type: 'test', - category: 'test', - label: 'Test Component', - description: 'A test', - icon: 'test', - props: [], - config: { layout: 'block', styling: { className: 'test' }, children: [] }, - } - renderer.registerComponentConfig('test', config) - - expect(renderer.getComponentConfig('test')).toEqual(config) - }) - }) - - describe('interpolateValue', () => { - it.each([ - { - name: 'simple interpolation', - template: 'Hello {name}!', - context: { name: 'World' }, - expected: 'Hello World!', - }, - { - name: 'multiple placeholders', - template: '{greeting} {name}, welcome to {place}', - context: { greeting: 'Hi', name: 'Alice', place: 'Wonderland' }, - expected: 'Hi Alice, welcome to Wonderland', - }, - { - name: 'missing placeholder', - template: 'Hello {name}, age: {age}', - context: { name: 'Bob' }, - expected: 'Hello Bob, age: {age}', - }, - { - name: 'numeric value', - template: 'Count: {count}', - context: { count: 42 }, - expected: 'Count: 42', - }, - { - name: 'boolean value', - template: 'Active: {active}', - context: { active: true }, - expected: 'Active: true', - }, - { - name: 'empty template', - template: '', - context: { name: 'test' }, - expected: '', - }, - { - name: 'no placeholders', - template: 'Plain text', - context: { name: 'ignored' }, - expected: 'Plain text', - }, - { - name: 'null template', - template: null as any, - context: { name: 'test' }, - expected: null, - }, - { - name: 'undefined value in context', - template: 'Value: {val}', - context: { val: undefined }, - expected: 'Value: {val}', - }, - ])('should handle $name', ({ template, context, expected }) => { - expect(renderer.interpolateValue(template, context)).toBe(expected) - }) - }) - - describe('evaluateConditional', () => { - it.each([ - { name: 'boolean true', condition: true, context: {}, expected: true }, - { name: 'boolean false', condition: false, context: {}, expected: false }, - { name: 'empty string condition', condition: '', context: {}, expected: true }, - { name: 'null condition', condition: null as any, context: {}, expected: true }, - { name: 'undefined condition', condition: undefined as any, context: {}, expected: true }, - { name: 'truthy context value', condition: 'isActive', context: { isActive: true }, expected: true }, - { name: 'falsy context value', condition: 'isActive', context: { isActive: false }, expected: false }, - { name: 'missing context key', condition: 'missing', context: {}, expected: false }, - { name: 'truthy string value', condition: 'name', context: { name: 'test' }, expected: true }, - { name: 'empty string value', condition: 'name', context: { name: '' }, expected: false }, - { name: 'zero value', condition: 'count', context: { count: 0 }, expected: false }, - { name: 'positive number', condition: 'count', context: { count: 5 }, expected: true }, - ])('should return $expected for $name', ({ condition, context, expected }) => { - expect(renderer.evaluateConditional(condition, context)).toBe(expected) - }) - }) - - describe('resolveDataSource', () => { - it.each([ - { - name: 'existing array data source', - dataSource: 'items', - context: { items: [1, 2, 3] }, - expected: [1, 2, 3], - }, - { - name: 'empty array data source', - dataSource: 'items', - context: { items: [] }, - expected: [], - }, - { - name: 'missing data source', - dataSource: 'missing', - context: {}, - expected: [], - }, - { - name: 'null data source key', - dataSource: '', - context: { items: [1] }, - expected: [], - }, - { - name: 'object array data source', - dataSource: 'users', - context: { users: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] }, - expected: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }], - }, - ])('should resolve $name', ({ dataSource, context, expected }) => { - expect(renderer.resolveDataSource(dataSource, context)).toEqual(expected) - }) - }) - - describe('registerLuaScript', () => { - it('should register and store Lua scripts', () => { - const script = { - code: 'return x + y', - parameters: [{ name: 'x' }, { name: 'y' }], - returnType: 'number', - } - renderer.registerLuaScript('add', script) - - // Verify registration by attempting to execute - // The script is stored internally - expect(true).toBe(true) // Script registered without error - }) - }) - - describe('executeLuaScript', () => { - it('should throw error for non-existent script', async () => { - await expect(renderer.executeLuaScript('nonexistent', [])).rejects.toThrow( - 'Lua script not found: nonexistent' - ) - }) - - it('should execute script with parameters', async () => { - renderer.registerLuaScript('testScript', { - code: ` -function formatTime(timestamp) - return timestamp * 1000 -end -`, - parameters: [{ name: 'timestamp' }], - returnType: 'number', - }) - - const result = await renderer.executeLuaScript('testScript', [5]) - expect(result).toBe(5000) - }) - - it('should handle script with no parameters', async () => { - renderer.registerLuaScript('constantScript', { - code: ` -function formatTime() - return 42 -end -`, - parameters: [], - returnType: 'number', - }) - - const result = await renderer.executeLuaScript('constantScript', []) - expect(result).toBe(42) - }) - }) - }) - - describe('getDeclarativeRenderer', () => { - it('should return a global renderer instance', () => { - const renderer1 = getDeclarativeRenderer() - const renderer2 = getDeclarativeRenderer() - - expect(renderer1).toBe(renderer2) - expect(renderer1).toBeInstanceOf(DeclarativeComponentRenderer) - }) - }) - - describe('loadPackageComponents', () => { - it('should load component configs from package', () => { - const renderer = getDeclarativeRenderer() - const testType = `loadTest_${Date.now()}` - - loadPackageComponents({ - componentConfigs: { - [testType]: { - type: testType, - category: 'test', - label: 'Loaded Component', - description: 'Loaded from package', - icon: 'package', - props: [], - config: { layout: 'block', styling: { className: 'loaded' }, children: [] }, - }, - }, - }) - - expect(renderer.hasComponentConfig(testType)).toBe(true) - }) - - it('should load Lua scripts from package', () => { - loadPackageComponents({ - luaScripts: [ - { - id: `pkgScript_${Date.now()}`, - code: 'function formatTime() return 1 end', - parameters: [], - returnType: 'number', - }, - ], - }) - - // Script loaded without error - expect(true).toBe(true) - }) - - it('should handle empty package content', () => { - // Should not throw - loadPackageComponents({}) - loadPackageComponents({ componentConfigs: {} }) - loadPackageComponents({ luaScripts: [] }) - - expect(true).toBe(true) - }) - - it('should handle package with both configs and scripts', () => { - const renderer = getDeclarativeRenderer() - const uniqueId = Date.now() - - loadPackageComponents({ - componentConfigs: { - [`combo_${uniqueId}`]: { - type: `combo_${uniqueId}`, - category: 'combo', - label: 'Combo', - description: 'Combined', - icon: 'combo', - props: [], - config: { layout: 'flex', styling: { className: 'combo' }, children: [] }, - }, - }, - luaScripts: [ - { - id: `comboScript_${uniqueId}`, - code: 'function userJoin(name) return "Welcome " .. name end', - parameters: [{ name: 'name' }], - returnType: 'string', - }, - ], - }) - - expect(renderer.hasComponentConfig(`combo_${uniqueId}`)).toBe(true) - }) - }) -}) diff --git a/frontends/nextjs/src/lib/rendering/tests/page-renderer.layout.test.ts b/frontends/nextjs/src/lib/rendering/tests/page-renderer.layout.test.ts new file mode 100644 index 000000000..ad2047c0a --- /dev/null +++ b/frontends/nextjs/src/lib/rendering/tests/page-renderer.layout.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PageRenderer } from '@/lib/rendering/page/page-renderer' +import { createMockPage } from '@/lib/rendering/page/utils' + +const { Database, MockLuaEngine } = vi.hoisted(() => { + class MockLuaEngine { + execute = vi.fn() + } + return { + Database: { + getPages: vi.fn(), + addPage: vi.fn(), + getLuaScripts: vi.fn(), + }, + MockLuaEngine, + } +}) + +vi.mock('@/lib/database', () => ({ Database })) +vi.mock('@/lib/lua-engine', () => ({ LuaEngine: MockLuaEngine })) + +describe('page-renderer layout queries', () => { + let renderer: PageRenderer + + beforeEach(() => { + vi.clearAllMocks() + renderer = new PageRenderer() + Database.getPages.mockResolvedValue([]) + Database.addPage.mockResolvedValue(undefined) + Database.getLuaScripts.mockResolvedValue([]) + }) + + describe('getPagesByLevel', () => { + it('should filter pages by level', async () => { + await renderer.registerPage(createMockPage('p1', { level: 1 })) + await renderer.registerPage(createMockPage('p2', { level: 2 })) + await renderer.registerPage(createMockPage('p3', { level: 2 })) + await renderer.registerPage(createMockPage('p4', { level: 3 })) + + const level2Pages = renderer.getPagesByLevel(2) + + expect(level2Pages).toHaveLength(2) + expect(level2Pages.map(p => p.id)).toContain('p2') + expect(level2Pages.map(p => p.id)).toContain('p3') + }) + + it('should return empty array for level with no pages', async () => { + await renderer.registerPage(createMockPage('p1', { level: 1 })) + + const level5Pages = renderer.getPagesByLevel(5) + + expect(level5Pages).toHaveLength(0) + }) + }) +}) diff --git a/frontends/nextjs/src/lib/rendering/tests/page-renderer.lifecycle.test.ts b/frontends/nextjs/src/lib/rendering/tests/page-renderer.lifecycle.test.ts new file mode 100644 index 000000000..24d15e036 --- /dev/null +++ b/frontends/nextjs/src/lib/rendering/tests/page-renderer.lifecycle.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getPageRenderer, PageRenderer } from '@/lib/rendering/page/page-renderer' +import { createMockPage } from '@/lib/rendering/page/utils' + +const { Database, MockLuaEngine } = vi.hoisted(() => { + class MockLuaEngine { + execute = vi.fn() + } + return { + Database: { + getPages: vi.fn(), + addPage: vi.fn(), + getLuaScripts: vi.fn(), + }, + MockLuaEngine, + } +}) + +vi.mock('@/lib/database', () => ({ Database })) +vi.mock('@/lib/lua-engine', () => ({ LuaEngine: MockLuaEngine })) + +describe('page-renderer lifecycle', () => { + let renderer: PageRenderer + + beforeEach(() => { + vi.clearAllMocks() + renderer = new PageRenderer() + Database.getPages.mockResolvedValue([]) + Database.addPage.mockResolvedValue(undefined) + Database.getLuaScripts.mockResolvedValue([]) + }) + + describe('registerPage', () => { + it('should register a page and add to database', async () => { + const page = createMockPage('test-page', { title: 'Test Page' }) + + await renderer.registerPage(page) + + expect(Database.addPage).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'test-page', + title: 'Test Page', + }) + ) + expect(renderer.getPage('test-page')).toEqual(page) + }) + + it('should handle pages with permissions', async () => { + const page = createMockPage('auth-page', { + permissions: { + requiresAuth: true, + requiredRole: 'admin', + }, + }) + + await renderer.registerPage(page) + + expect(Database.addPage).toHaveBeenCalledWith( + expect.objectContaining({ + requiresAuth: true, + requiredRole: 'admin', + }) + ) + }) + }) + + describe('loadPages', () => { + it('should load pages from database', async () => { + Database.getPages.mockResolvedValue([ + { + id: 'page1', + title: 'Page 1', + level: 2, + componentTree: [], + requiresAuth: false, + }, + { + id: 'page2', + title: 'Page 2', + level: 3, + componentTree: [{ id: 'c1', type: 'text' }], + requiresAuth: true, + requiredRole: 'admin', + }, + ]) + + await renderer.loadPages() + + expect(renderer.getPage('page1')).toBeDefined() + expect(renderer.getPage('page2')).toBeDefined() + expect(renderer.getPage('page1')?.title).toBe('Page 1') + expect(renderer.getPage('page2')?.permissions?.requiresAuth).toBe(true) + }) + + it('should handle empty database', async () => { + Database.getPages.mockResolvedValue([]) + + await renderer.loadPages() + + expect(renderer.getPage('nonexistent')).toBeUndefined() + }) + }) + + describe('getPage', () => { + it.each([ + { + name: 'returns page when exists', + pageId: 'existing', + expectFound: true, + }, + { + name: 'returns undefined when not exists', + pageId: 'nonexistent', + expectFound: false, + }, + ])('should handle $name', async ({ pageId, expectFound }) => { + await renderer.registerPage(createMockPage('existing')) + + const result = renderer.getPage(pageId) + + if (expectFound) { + expect(result).toBeDefined() + expect(result?.id).toBe(pageId) + } else { + expect(result).toBeUndefined() + } + }) + }) + + describe('getPageRenderer singleton', () => { + it('should return the same instance', () => { + const instance1 = getPageRenderer() + const instance2 = getPageRenderer() + + expect(instance1).toBe(instance2) + }) + }) +}) diff --git a/frontends/nextjs/src/lib/rendering/tests/page-renderer.permissions.test.ts b/frontends/nextjs/src/lib/rendering/tests/page-renderer.permissions.test.ts new file mode 100644 index 000000000..68c87b172 --- /dev/null +++ b/frontends/nextjs/src/lib/rendering/tests/page-renderer.permissions.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PageRenderer, type PageDefinition } from '@/lib/rendering/page/page-renderer' +import { createMockPage, createMockUser } from '@/lib/rendering/page/utils' + +const { Database, MockLuaEngine } = vi.hoisted(() => { + class MockLuaEngine { + execute = vi.fn() + } + return { + Database: { + getPages: vi.fn(), + addPage: vi.fn(), + getLuaScripts: vi.fn(), + }, + MockLuaEngine, + } +}) + +vi.mock('@/lib/database', () => ({ Database })) +vi.mock('@/lib/lua-engine', () => ({ LuaEngine: MockLuaEngine })) + +describe('page-renderer permissions', () => { + let renderer: PageRenderer + + beforeEach(() => { + vi.clearAllMocks() + renderer = new PageRenderer() + Database.getPages.mockResolvedValue([]) + Database.addPage.mockResolvedValue(undefined) + Database.getLuaScripts.mockResolvedValue([]) + }) + + describe('checkPermissions', () => { + it.each([ + { + name: 'allows when no permissions defined', + page: createMockPage('open'), + user: null, + expectedAllowed: true, + }, + { + name: 'blocks unauthenticated user when auth required', + page: createMockPage('auth', { + permissions: { requiresAuth: true }, + }), + user: null, + expectedAllowed: false, + expectedReason: 'Authentication required', + }, + { + name: 'allows authenticated user when auth required', + page: createMockPage('auth', { + permissions: { requiresAuth: true }, + }), + user: createMockUser('user'), + expectedAllowed: true, + }, + { + name: 'blocks user with insufficient role', + page: createMockPage('admin', { + permissions: { requiresAuth: true, requiredRole: 'admin' }, + }), + user: createMockUser('user'), + expectedAllowed: false, + expectedReason: 'Insufficient permissions', + }, + { + name: 'allows user with sufficient role', + page: createMockPage('admin', { + permissions: { requiresAuth: true, requiredRole: 'admin' }, + }), + user: createMockUser('admin'), + expectedAllowed: true, + }, + { + name: 'allows god role for admin page', + page: createMockPage('admin', { + permissions: { requiresAuth: true, requiredRole: 'admin' }, + }), + user: createMockUser('god'), + expectedAllowed: true, + }, + { + name: 'allows supergod role for god page', + page: createMockPage('god', { + permissions: { requiresAuth: true, requiredRole: 'god' }, + }), + user: createMockUser('supergod'), + expectedAllowed: true, + }, + ])('should handle $name', async ({ page, user, expectedAllowed, expectedReason }) => { + const result = await renderer.checkPermissions(page as PageDefinition, user) + + expect(result.allowed).toBe(expectedAllowed) + if (expectedReason) { + expect(result.reason).toBe(expectedReason) + } + }) + }) +}) diff --git a/frontends/nextjs/src/lib/rendering/tests/page-renderer.test.ts b/frontends/nextjs/src/lib/rendering/tests/page-renderer.test.ts deleted file mode 100644 index 562b2dc3d..000000000 --- a/frontends/nextjs/src/lib/rendering/tests/page-renderer.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -/** - * Tests for page-renderer.ts - Page rendering and permission checking - * Following parameterized test pattern per project conventions - */ - -import { describe, it, expect, beforeEach, vi } from 'vitest' -import type { PageDefinition } from './page-renderer' -import type { User, UserRole } from '../types/level-types' - -// Mock Database -const { Database, MockLuaEngine } = vi.hoisted(() => { - class MockLuaEngine { - execute = vi.fn() - } - return { - Database: { - getPages: vi.fn(), - addPage: vi.fn(), - getLuaScripts: vi.fn(), - }, - MockLuaEngine, - } -}) - -vi.mock('../database', () => ({ Database })) -vi.mock('../lua-engine', () => ({ LuaEngine: MockLuaEngine })) - -import { PageRenderer, getPageRenderer } from './page-renderer' - -// Helper to create mock page definitions -function createMockPage( - id: string, - options: Partial = {} -): PageDefinition { - return { - id, - level: options.level ?? 1, - title: options.title ?? `Page ${id}`, - layout: options.layout ?? 'default', - components: options.components ?? [], - permissions: options.permissions, - luaScripts: options.luaScripts, - metadata: options.metadata, - } -} - -// Helper to create mock users -function createMockUser(role: string, id = 'user1'): User { - return { - id, - username: `User ${id}`, - role: role as UserRole, - email: `${id}@test.com`, - createdAt: Date.now(), - } -} - -describe('page-renderer', () => { - let renderer: PageRenderer - - beforeEach(() => { - vi.clearAllMocks() - renderer = new PageRenderer() - Database.getPages.mockResolvedValue([]) - Database.addPage.mockResolvedValue(undefined) - Database.getLuaScripts.mockResolvedValue([]) - }) - - describe('registerPage', () => { - it('should register a page and add to database', async () => { - const page = createMockPage('test-page', { title: 'Test Page' }) - - await renderer.registerPage(page) - - expect(Database.addPage).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'test-page', - title: 'Test Page', - }) - ) - expect(renderer.getPage('test-page')).toEqual(page) - }) - - it('should handle pages with permissions', async () => { - const page = createMockPage('auth-page', { - permissions: { - requiresAuth: true, - requiredRole: 'admin', - }, - }) - - await renderer.registerPage(page) - - expect(Database.addPage).toHaveBeenCalledWith( - expect.objectContaining({ - requiresAuth: true, - requiredRole: 'admin', - }) - ) - }) - }) - - describe('loadPages', () => { - it('should load pages from database', async () => { - Database.getPages.mockResolvedValue([ - { - id: 'page1', - title: 'Page 1', - level: 2, - componentTree: [], - requiresAuth: false, - }, - { - id: 'page2', - title: 'Page 2', - level: 3, - componentTree: [{ id: 'c1', type: 'text' }], - requiresAuth: true, - requiredRole: 'admin', - }, - ]) - - await renderer.loadPages() - - expect(renderer.getPage('page1')).toBeDefined() - expect(renderer.getPage('page2')).toBeDefined() - expect(renderer.getPage('page1')?.title).toBe('Page 1') - expect(renderer.getPage('page2')?.permissions?.requiresAuth).toBe(true) - }) - - it('should handle empty database', async () => { - Database.getPages.mockResolvedValue([]) - - await renderer.loadPages() - - expect(renderer.getPage('nonexistent')).toBeUndefined() - }) - }) - - describe('getPage', () => { - it.each([ - { - name: 'returns page when exists', - pageId: 'existing', - expectFound: true, - }, - { - name: 'returns undefined when not exists', - pageId: 'nonexistent', - expectFound: false, - }, - ])('should handle $name', async ({ pageId, expectFound }) => { - await renderer.registerPage(createMockPage('existing')) - - const result = renderer.getPage(pageId) - - if (expectFound) { - expect(result).toBeDefined() - expect(result?.id).toBe(pageId) - } else { - expect(result).toBeUndefined() - } - }) - }) - - describe('getPagesByLevel', () => { - it('should filter pages by level', async () => { - await renderer.registerPage(createMockPage('p1', { level: 1 })) - await renderer.registerPage(createMockPage('p2', { level: 2 })) - await renderer.registerPage(createMockPage('p3', { level: 2 })) - await renderer.registerPage(createMockPage('p4', { level: 3 })) - - const level2Pages = renderer.getPagesByLevel(2) - - expect(level2Pages).toHaveLength(2) - expect(level2Pages.map(p => p.id)).toContain('p2') - expect(level2Pages.map(p => p.id)).toContain('p3') - }) - - it('should return empty array for level with no pages', async () => { - await renderer.registerPage(createMockPage('p1', { level: 1 })) - - const level5Pages = renderer.getPagesByLevel(5) - - expect(level5Pages).toHaveLength(0) - }) - }) - - describe('checkPermissions', () => { - it.each([ - { - name: 'allows when no permissions defined', - page: createMockPage('open'), - user: null, - expectedAllowed: true, - }, - { - name: 'blocks unauthenticated user when auth required', - page: createMockPage('auth', { - permissions: { requiresAuth: true }, - }), - user: null, - expectedAllowed: false, - expectedReason: 'Authentication required', - }, - { - name: 'allows authenticated user when auth required', - page: createMockPage('auth', { - permissions: { requiresAuth: true }, - }), - user: createMockUser('user'), - expectedAllowed: true, - }, - { - name: 'blocks user with insufficient role', - page: createMockPage('admin', { - permissions: { requiresAuth: true, requiredRole: 'admin' }, - }), - user: createMockUser('user'), - expectedAllowed: false, - expectedReason: 'Insufficient permissions', - }, - { - name: 'allows user with sufficient role', - page: createMockPage('admin', { - permissions: { requiresAuth: true, requiredRole: 'admin' }, - }), - user: createMockUser('admin'), - expectedAllowed: true, - }, - { - name: 'allows god role for admin page', - page: createMockPage('admin', { - permissions: { requiresAuth: true, requiredRole: 'admin' }, - }), - user: createMockUser('god'), - expectedAllowed: true, - }, - { - name: 'allows supergod role for god page', - page: createMockPage('god', { - permissions: { requiresAuth: true, requiredRole: 'god' }, - }), - user: createMockUser('supergod'), - expectedAllowed: true, - }, - ])('should handle $name', async ({ page, user, expectedAllowed, expectedReason }) => { - const result = await renderer.checkPermissions(page, user) - - expect(result.allowed).toBe(expectedAllowed) - if (expectedReason) { - expect(result.reason).toBe(expectedReason) - } - }) - }) - - describe('getPageRenderer singleton', () => { - it('should return the same instance', () => { - const instance1 = getPageRenderer() - const instance2 = getPageRenderer() - - expect(instance1).toBe(instance2) - }) - }) -}) diff --git a/frontends/nextjs/src/lib/schema-utils.ts b/frontends/nextjs/src/lib/schema-utils.ts new file mode 100644 index 000000000..a02a1958d --- /dev/null +++ b/frontends/nextjs/src/lib/schema-utils.ts @@ -0,0 +1,3 @@ +// Backward compatibility entry point for schema utilities +// Prefer importing from '@/lib/schema' but keep legacy path working +export * from './schema' diff --git a/frontends/nextjs/src/lib/schema/SchemaUtils.ts b/frontends/nextjs/src/lib/schema/SchemaUtils.ts index d56780371..bb4693aa2 100644 --- a/frontends/nextjs/src/lib/schema/SchemaUtils.ts +++ b/frontends/nextjs/src/lib/schema/SchemaUtils.ts @@ -1,27 +1,27 @@ import type { FieldSchema, ModelSchema, SchemaConfig } from '@/lib/schema-types' - -// Import individual functions (lambdas) -import { getModelKey } from './functions/get-model-key' -import { getRecordsKey } from './functions/get-records-key' -import { findModel } from './functions/find-model' -import { getFieldLabel } from './functions/get-field-label' -import { getModelLabel } from './functions/get-model-label' -import { getModelLabelPlural } from './functions/get-model-label-plural' -import { getHelpText } from './functions/get-help-text' -import { generateId } from './functions/generate-id' -import { validateField } from './functions/validate-field' -import { validateRecord } from './functions/validate-record' -import { getDefaultValue } from './functions/get-default-value' -import { createEmptyRecord } from './functions/create-empty-record' -import { sortRecords } from './functions/sort-records' -import { filterRecords } from './functions/filter-records' +import { + createEmptyRecord, + findModel, + filterRecords, + generateId, + getDefaultValue, + getFieldLabel, + getHelpText, + getModelKey, + getModelLabel, + getModelLabelPlural, + getRecordsKey, + sortRecords, + validateField, + validateRecord, +} from './functions' /** * SchemaUtils - Class wrapper for schema utility functions - * + * * This class serves as a container for lambda functions related to schema operations. * Each method delegates to an individual function file in the functions/ directory. - * + * * Pattern: "class is container for lambdas" * - Each lambda is defined in its own file under functions/ * - This class wraps them for convenient namespaced access diff --git a/frontends/nextjs/src/lib/schema/__tests__/schema-utils.fixtures.ts b/frontends/nextjs/src/lib/schema/__tests__/schema-utils.fixtures.ts new file mode 100644 index 000000000..2eec75544 --- /dev/null +++ b/frontends/nextjs/src/lib/schema/__tests__/schema-utils.fixtures.ts @@ -0,0 +1,30 @@ +import type { FieldSchema, ModelSchema, SchemaConfig } from '@/lib/schema-types' + +export const createMockField = (): FieldSchema => ({ + name: 'email', + type: 'email', + label: 'Email Address', + required: true, + helpText: 'Enter a valid email', +}) + +export const createMockModel = (): ModelSchema => ({ + name: 'User', + label: 'User Account', + labelPlural: 'Users', + fields: [ + { name: 'id', type: 'string', required: true }, + { name: 'name', type: 'string', required: true, label: 'Full Name' }, + { name: 'email', type: 'email', required: true }, + { name: 'age', type: 'number' }, + ], +}) + +export const createMockSchema = (): SchemaConfig => ({ + apps: [ + { + name: 'TestApp', + models: [createMockModel()], + }, + ], +}) diff --git a/frontends/nextjs/src/lib/schema/__tests__/schema-utils.migration.test.ts b/frontends/nextjs/src/lib/schema/__tests__/schema-utils.migration.test.ts new file mode 100644 index 000000000..b766616f9 --- /dev/null +++ b/frontends/nextjs/src/lib/schema/__tests__/schema-utils.migration.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest' +import { findModel, getModelKey, getRecordsKey } from '@/lib/schema-utils' +import type { SchemaConfig } from '@/lib/schema-types' +import { createMockSchema } from './schema-utils.fixtures' + +describe('schema-utils migration', () => { + describe('getModelKey', () => { + it.each([ + { appName: 'MyApp', modelName: 'User', expected: 'MyApp_User' }, + { appName: 'app-v2', modelName: 'User_Profile', expected: 'app-v2_User_Profile' }, + { appName: '', modelName: 'Model', expected: '_Model' }, + ])('should generate key "$expected" for app=$appName, model=$modelName', ({ appName, modelName, expected }) => { + const result = getModelKey(appName, modelName) + expect(result).toBe(expected) + }) + }) + + describe('getRecordsKey', () => { + it('should generate a records key with prefix', () => { + const result = getRecordsKey('MyApp', 'User') + expect(result).toBe('records_MyApp_User') + }) + + it('should include records prefix', () => { + const result = getRecordsKey('app', 'data') + expect(result).toMatch(/^records_/) + }) + }) + + describe('findModel', () => { + it('should find a model by app and model name', () => { + const result = findModel(createMockSchema(), 'TestApp', 'User') + expect(result).toBeDefined() + expect(result?.name).toBe('User') + }) + + it('should return undefined if app not found', () => { + const result = findModel(createMockSchema(), 'NonExistentApp', 'User') + expect(result).toBeUndefined() + }) + + it('should return undefined if model not found in app', () => { + const result = findModel(createMockSchema(), 'TestApp', 'NonExistentModel') + expect(result).toBeUndefined() + }) + + it('should handle multiple apps correctly', () => { + const multiAppSchema: SchemaConfig = { + apps: [ + { name: 'App1', models: [{ name: 'Model1', fields: [] }] }, + { name: 'App2', models: [{ name: 'Model2', fields: [] }] }, + ], + } + const result = findModel(multiAppSchema, 'App2', 'Model2') + expect(result?.name).toBe('Model2') + }) + }) +}) diff --git a/frontends/nextjs/src/lib/schema/schema-utils.test.ts b/frontends/nextjs/src/lib/schema/__tests__/schema-utils.serialization.test.ts similarity index 51% rename from frontends/nextjs/src/lib/schema/schema-utils.test.ts rename to frontends/nextjs/src/lib/schema/__tests__/schema-utils.serialization.test.ts index c19c83304..a6264b22f 100644 --- a/frontends/nextjs/src/lib/schema/schema-utils.test.ts +++ b/frontends/nextjs/src/lib/schema/__tests__/schema-utils.serialization.test.ts @@ -1,108 +1,22 @@ import { describe, it, expect, beforeEach } from 'vitest' import { - getModelKey, - getRecordsKey, - findModel, + createEmptyRecord, + filterRecords, + generateId, + getDefaultValue, getFieldLabel, + getHelpText, getModelLabel, getModelLabelPlural, - getHelpText, - generateId, - validateField, - validateRecord, - getDefaultValue, - createEmptyRecord, sortRecords, - filterRecords, } from '@/lib/schema-utils' -import type { FieldSchema, ModelSchema, SchemaConfig } from '@/lib/schema-types' - -describe('schema-utils', () => { - // Test data setup - const mockField: FieldSchema = { - name: 'email', - type: 'email', - label: 'Email Address', - required: true, - helpText: 'Enter a valid email', - } - - const mockModel: ModelSchema = { - name: 'User', - label: 'User Account', - labelPlural: 'Users', - fields: [ - { name: 'id', type: 'string', required: true }, - { name: 'name', type: 'string', required: true, label: 'Full Name' }, - { name: 'email', type: 'email', required: true }, - { name: 'age', type: 'number' }, - ], - } - - const mockSchema: SchemaConfig = { - apps: [ - { - name: 'TestApp', - models: [mockModel], - }, - ], - } - - describe('getModelKey', () => { - it.each([ - { appName: 'MyApp', modelName: 'User', expected: 'MyApp_User' }, - { appName: 'app-v2', modelName: 'User_Profile', expected: 'app-v2_User_Profile' }, - { appName: '', modelName: 'Model', expected: '_Model' }, - ])('should generate key "$expected" for app=$appName, model=$modelName', ({ appName, modelName, expected }) => { - const result = getModelKey(appName, modelName) - expect(result).toBe(expected) - }) - }) - - describe('getRecordsKey', () => { - it('should generate a records key with prefix', () => { - const result = getRecordsKey('MyApp', 'User') - expect(result).toBe('records_MyApp_User') - }) - - it('should include records prefix', () => { - const result = getRecordsKey('app', 'data') - expect(result).toMatch(/^records_/) - }) - }) - - describe('findModel', () => { - it('should find a model by app and model name', () => { - const result = findModel(mockSchema, 'TestApp', 'User') - expect(result).toBeDefined() - expect(result?.name).toBe('User') - }) - - it('should return undefined if app not found', () => { - const result = findModel(mockSchema, 'NonExistentApp', 'User') - expect(result).toBeUndefined() - }) - - it('should return undefined if model not found in app', () => { - const result = findModel(mockSchema, 'TestApp', 'NonExistentModel') - expect(result).toBeUndefined() - }) - - it('should handle multiple apps correctly', () => { - const multiAppSchema: SchemaConfig = { - apps: [ - { name: 'App1', models: [{ name: 'Model1', fields: [] }] }, - { name: 'App2', models: [{ name: 'Model2', fields: [] }] }, - ], - } - const result = findModel(multiAppSchema, 'App2', 'Model2') - expect(result?.name).toBe('Model2') - }) - }) +import type { FieldSchema, ModelSchema } from '@/lib/schema-types' +import { createMockField, createMockModel } from './schema-utils.fixtures' +describe('schema-utils serialization', () => { describe('getFieldLabel', () => { it.each([ - { field: mockField, expected: 'Email Address', description: 'custom label' }, + { field: createMockField(), expected: 'Email Address', description: 'custom label' }, { field: { name: 'email', type: 'email' }, expected: 'Email', description: 'auto-capitalized field name' }, { field: { name: 'firstName', type: 'string' }, expected: 'FirstName', description: 'multi-word field name' }, ])('should return $description', ({ field, expected }) => { @@ -113,7 +27,7 @@ describe('schema-utils', () => { describe('getModelLabel', () => { it('should return custom label if provided', () => { - const result = getModelLabel(mockModel) + const result = getModelLabel(createMockModel()) expect(result).toBe('User Account') }) @@ -126,7 +40,7 @@ describe('schema-utils', () => { describe('getModelLabelPlural', () => { it('should return custom plural label if provided', () => { - const result = getModelLabelPlural(mockModel) + const result = getModelLabelPlural(createMockModel()) expect(result).toBe('Users') }) @@ -139,7 +53,7 @@ describe('schema-utils', () => { describe('getHelpText', () => { it('should return help text if string', () => { - const result = getHelpText(mockField) + const result = getHelpText(createMockField()) expect(result).toBe('Enter a valid email') }) @@ -178,135 +92,6 @@ describe('schema-utils', () => { }) }) - describe('validateField', () => { - it.each([ - { - name: 'required field empty', - field: { name: 'email', type: 'email', required: true }, - value: '', - shouldHaveError: true, - }, - { - name: 'non-required field empty', - field: { name: 'nickname', type: 'string', required: false }, - value: '', - shouldHaveError: false, - }, - { - name: 'invalid email', - field: { name: 'email', type: 'email' }, - value: 'invalid', - shouldHaveError: true, - }, - { - name: 'valid email', - field: { name: 'email', type: 'email' }, - value: 'test@example.com', - shouldHaveError: false, - }, - { - name: 'invalid URL', - field: { name: 'website', type: 'url' }, - value: 'not a url', - shouldHaveError: true, - }, - { - name: 'valid URL', - field: { name: 'website', type: 'url' }, - value: 'https://example.com', - shouldHaveError: false, - }, - { - name: 'number below min', - field: { name: 'age', type: 'number', validation: { min: 0, max: 150 } }, - value: -1, - shouldHaveError: true, - }, - { - name: 'number above max', - field: { name: 'age', type: 'number', validation: { min: 0, max: 150 } }, - value: 200, - shouldHaveError: true, - }, - { - name: 'valid number in range', - field: { name: 'age', type: 'number', validation: { min: 0, max: 150 } }, - value: 25, - shouldHaveError: false, - }, - { - name: 'string too short', - field: { name: 'password', type: 'string', validation: { minLength: 8, maxLength: 20 } }, - value: 'short', - shouldHaveError: true, - }, - { - name: 'string too long', - field: { name: 'password', type: 'string', validation: { minLength: 8, maxLength: 20 } }, - value: 'verylongpasswordthatexceedslimit', - shouldHaveError: true, - }, - { - name: 'valid string length', - field: { name: 'password', type: 'string', validation: { minLength: 8, maxLength: 20 } }, - value: 'goodpass123', - shouldHaveError: false, - }, - { - name: 'valid pattern match', - field: { name: 'code', type: 'string', validation: { pattern: '^[A-Z]{3}-\\d{3}$' } }, - value: 'ABC-123', - shouldHaveError: false, - }, - { - name: 'invalid pattern match', - field: { name: 'code', type: 'string', validation: { pattern: '^[A-Z]{3}-\\d{3}$' } }, - value: 'abc-123', - shouldHaveError: true, - }, - ])('should $name', ({ field, value, shouldHaveError }) => { - const result = validateField(field as FieldSchema, value) - if (shouldHaveError) { - expect(result).toBeTruthy() - } else { - expect(result).toBeNull() - } - }) - }) - - describe('validateRecord', () => { - it('should validate all fields in a record', () => { - const record = { id: '1', name: 'John', email: 'invalid-email' } - const errors = validateRecord(mockModel, record) - expect(errors.email).toBeTruthy() - }) - - it('should return empty errors for valid record', () => { - const record = { - id: '1', - name: 'John Doe', - email: 'john@example.com', - age: 30, - } - const errors = validateRecord(mockModel, record) - expect(Object.keys(errors).length).toBe(0) - }) - - it('should skip non-editable fields', () => { - const model: ModelSchema = { - name: 'Post', - fields: [ - { name: 'id', type: 'string', editable: false }, - { name: 'title', type: 'string', required: true }, - ], - } - const record = { title: '' } - const errors = validateRecord(model, record) - expect(errors.id).toBeUndefined() - expect(errors.title).toBeTruthy() - }) - }) - describe('getDefaultValue', () => { it.each([ { field: { name: 'count', type: 'number', default: 42 }, expected: 42, description: 'custom default' }, @@ -335,7 +120,7 @@ describe('schema-utils', () => { describe('createEmptyRecord', () => { it('should create a record with all fields', () => { - const record = createEmptyRecord(mockModel) + const record = createEmptyRecord(createMockModel()) expect(record.id).toBeDefined() expect(record.name).toBe('') expect(record.email).toBe('') @@ -343,8 +128,8 @@ describe('schema-utils', () => { }) it('should generate unique ID', () => { - const record1 = createEmptyRecord(mockModel) - const record2 = createEmptyRecord(mockModel) + const record1 = createEmptyRecord(createMockModel()) + const record2 = createEmptyRecord(createMockModel()) expect(record1.id).not.toBe(record2.id) }) diff --git a/frontends/nextjs/src/lib/schema/__tests__/schema-utils.validation.test.ts b/frontends/nextjs/src/lib/schema/__tests__/schema-utils.validation.test.ts new file mode 100644 index 000000000..ba56e9239 --- /dev/null +++ b/frontends/nextjs/src/lib/schema/__tests__/schema-utils.validation.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'vitest' +import { validateField, validateRecord } from '@/lib/schema-utils' +import type { FieldSchema, ModelSchema } from '@/lib/schema-types' +import { createMockModel } from './schema-utils.fixtures' + +describe('schema-utils validation', () => { + describe('validateField', () => { + it.each([ + { + name: 'required field empty', + field: { name: 'email', type: 'email', required: true }, + value: '', + shouldHaveError: true, + }, + { + name: 'non-required field empty', + field: { name: 'nickname', type: 'string', required: false }, + value: '', + shouldHaveError: false, + }, + { + name: 'invalid email', + field: { name: 'email', type: 'email' }, + value: 'invalid', + shouldHaveError: true, + }, + { + name: 'valid email', + field: { name: 'email', type: 'email' }, + value: 'test@example.com', + shouldHaveError: false, + }, + { + name: 'invalid URL', + field: { name: 'website', type: 'url' }, + value: 'not a url', + shouldHaveError: true, + }, + { + name: 'valid URL', + field: { name: 'website', type: 'url' }, + value: 'https://example.com', + shouldHaveError: false, + }, + { + name: 'number below min', + field: { name: 'age', type: 'number', validation: { min: 0, max: 150 } }, + value: -1, + shouldHaveError: true, + }, + { + name: 'number above max', + field: { name: 'age', type: 'number', validation: { min: 0, max: 150 } }, + value: 200, + shouldHaveError: true, + }, + { + name: 'valid number in range', + field: { name: 'age', type: 'number', validation: { min: 0, max: 150 } }, + value: 25, + shouldHaveError: false, + }, + { + name: 'string too short', + field: { name: 'password', type: 'string', validation: { minLength: 8, maxLength: 20 } }, + value: 'short', + shouldHaveError: true, + }, + { + name: 'string too long', + field: { name: 'password', type: 'string', validation: { minLength: 8, maxLength: 20 } }, + value: 'verylongpasswordthatexceedslimit', + shouldHaveError: true, + }, + { + name: 'valid string length', + field: { name: 'password', type: 'string', validation: { minLength: 8, maxLength: 20 } }, + value: 'goodpass123', + shouldHaveError: false, + }, + { + name: 'valid pattern match', + field: { name: 'code', type: 'string', validation: { pattern: '^[A-Z]{3}-\\d{3}$' } }, + value: 'ABC-123', + shouldHaveError: false, + }, + { + name: 'invalid pattern match', + field: { name: 'code', type: 'string', validation: { pattern: '^[A-Z]{3}-\\d{3}$' } }, + value: 'abc-123', + shouldHaveError: true, + }, + ])('should $name', ({ field, value, shouldHaveError }) => { + const result = validateField(field as FieldSchema, value) + if (shouldHaveError) { + expect(result).toBeTruthy() + } else { + expect(result).toBeNull() + } + }) + }) + + describe('validateRecord', () => { + it('should validate all fields in a record', () => { + const record = { id: '1', name: 'John', email: 'invalid-email' } + const errors = validateRecord(createMockModel(), record) + expect(errors.email).toBeTruthy() + }) + + it('should return empty errors for valid record', () => { + const record = { + id: '1', + name: 'John Doe', + email: 'john@example.com', + age: 30, + } + const errors = validateRecord(createMockModel(), record) + expect(Object.keys(errors).length).toBe(0) + }) + + it('should skip non-editable fields', () => { + const model: ModelSchema = { + name: 'Post', + fields: [ + { name: 'id', type: 'string', editable: false }, + { name: 'title', type: 'string', required: true }, + ], + } + const record = { title: '' } + const errors = validateRecord(model, record) + expect(errors.id).toBeUndefined() + expect(errors.title).toBeTruthy() + }) + }) +}) diff --git a/frontends/nextjs/src/lib/schema/functions/index.ts b/frontends/nextjs/src/lib/schema/functions/index.ts index 5575a442a..036a235a8 100644 --- a/frontends/nextjs/src/lib/schema/functions/index.ts +++ b/frontends/nextjs/src/lib/schema/functions/index.ts @@ -1,15 +1,15 @@ // Individual function exports -export { getModelKey } from './get-model-key' -export { getRecordsKey } from './get-records-key' -export { findModel } from './find-model' -export { getFieldLabel } from './get-field-label' -export { getModelLabel } from './get-model-label' -export { getModelLabelPlural } from './get-model-label-plural' -export { getHelpText } from './get-help-text' -export { generateId } from './generate-id' -export { validateField } from './validate-field' -export { validateRecord } from './validate-record' -export { getDefaultValue } from './get-default-value' -export { createEmptyRecord } from './create-empty-record' -export { sortRecords } from './sort-records' -export { filterRecords } from './filter-records' +export { getModelKey } from './model/get-model-key' +export { getRecordsKey } from './record/get-records-key' +export { findModel } from './model/find-model' +export { getFieldLabel } from './field/get-field-label' +export { getModelLabel } from './model/get-model-label' +export { getModelLabelPlural } from './model/get-model-label-plural' +export { getHelpText } from './field/get-help-text' +export { generateId } from './record/crud/generate-id' +export { validateField } from './field/validate-field' +export { validateRecord } from './record/validate-record' +export { getDefaultValue } from './field/get-default-value' +export { createEmptyRecord } from './record/crud/create-empty-record' +export { sortRecords } from './record/sort-records' +export { filterRecords } from './record/filter-records' diff --git a/frontends/nextjs/src/lib/schema/functions/record/crud/create-empty-record.ts b/frontends/nextjs/src/lib/schema/functions/record/crud/create-empty-record.ts index f6cdce151..441524bde 100644 --- a/frontends/nextjs/src/lib/schema/functions/record/crud/create-empty-record.ts +++ b/frontends/nextjs/src/lib/schema/functions/record/crud/create-empty-record.ts @@ -1,6 +1,6 @@ import type { ModelSchema } from '@/lib/schema-types' import { generateId } from './generate-id' -import { getDefaultValue } from './get-default-value' +import { getDefaultValue } from '../../field/get-default-value' /** * Create an empty record with default values for a model @@ -9,7 +9,7 @@ import { getDefaultValue } from './get-default-value' */ export const createEmptyRecord = (model: ModelSchema): any => { const record: any = {} - + for (const field of model.fields) { if (field.name === 'id') { record.id = generateId() @@ -19,6 +19,6 @@ export const createEmptyRecord = (model: ModelSchema): any => { record[field.name] = getDefaultValue(field) } } - + return record } diff --git a/frontends/nextjs/src/lib/schema/functions/record/validate-record.ts b/frontends/nextjs/src/lib/schema/functions/record/validate-record.ts index 9b61543da..77e842a8f 100644 --- a/frontends/nextjs/src/lib/schema/functions/record/validate-record.ts +++ b/frontends/nextjs/src/lib/schema/functions/record/validate-record.ts @@ -1,5 +1,5 @@ import type { ModelSchema } from '@/lib/schema-types' -import { validateField } from './validate-field' +import { validateField } from '../field/validate-field' /** * Validate a record against its model schema @@ -12,7 +12,7 @@ export const validateRecord = ( record: any ): Record => { const errors: Record = {} - + for (const field of model.fields) { if (field.editable === false) continue const error = validateField(field, record[field.name])