feat(tests): add unit tests for page renderer lifecycle, layout, and permissions

- Created new test files for `page-renderer.layout.test.ts`, `page-renderer.lifecycle.test.ts`, and `page-renderer.permissions.test.ts` to cover various functionalities of the PageRenderer class.
- Implemented tests for registering pages, loading pages from the database, filtering pages by level, and checking permissions based on user roles.
- Removed the old `page-renderer.test.ts` file to streamline test organization and improve maintainability.

refactor(schema): reorganize schema utility functions and add tests

- Introduced a new structure for schema utility functions, grouping them into directories based on their functionality (e.g., `field`, `model`, `record`).
- Added tests for schema utilities, including validation, serialization, and migration functions.
- Created mock data for testing schema-related functionalities, ensuring comprehensive coverage of edge cases and expected behaviors.
- Added backward compatibility for schema utilities through a new entry point.

chore: clean up unused code and improve code organization

- Removed redundant code and improved the organization of schema utility functions for better readability and maintainability.
- Ensured all functions are properly imported and exported from their respective directories.
This commit is contained in:
2025-12-27 19:17:32 +00:00
parent 93a93b995d
commit 0dcc613843
27 changed files with 1117 additions and 1031 deletions

View File

@@ -1 +1 @@
export * from '../builder-types'
export * from '@/lib/types/builder-types'

View File

@@ -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: [],
},
],
})

View File

@@ -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'
}
]
}
}
],
},
}
}

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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()

View File

@@ -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'

View File

@@ -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

View File

@@ -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> = {}
): 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(),
}
}

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
}
})
})
})

View File

@@ -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> = {}
): 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)
})
})
})

View File

@@ -0,0 +1,3 @@
// Backward compatibility entry point for schema utilities
// Prefer importing from '@/lib/schema' but keep legacy path working
export * from './schema'

View File

@@ -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

View File

@@ -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()],
},
],
})

View File

@@ -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')
})
})
})

View File

@@ -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)
})

View File

@@ -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()
})
})
})

View File

@@ -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'

View File

@@ -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
}

View File

@@ -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<string, string> => {
const errors: Record<string, string> = {}
for (const field of model.fields) {
if (field.editable === false) continue
const error = validateField(field, record[field.name])