mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-27 07:14:56 +00:00
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:
@@ -1 +1 @@
|
||||
export * from '../builder-types'
|
||||
export * from '@/lib/types/builder-types'
|
||||
|
||||
99
frontends/nextjs/src/lib/rendering/page/components.ts
Normal file
99
frontends/nextjs/src/lib/rendering/page/components.ts
Normal 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: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
29
frontends/nextjs/src/lib/rendering/page/utils.ts
Normal file
29
frontends/nextjs/src/lib/rendering/page/utils.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
3
frontends/nextjs/src/lib/schema-utils.ts
Normal file
3
frontends/nextjs/src/lib/schema-utils.ts
Normal 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'
|
||||
@@ -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
|
||||
|
||||
@@ -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()],
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user