Skip to content

Commit 78d3c3b

Browse files
authored
Merge pull request #2 from devitools/test/react-unit-tests
test: add @ybyra/react unit tests, fix playground tests, and fix docs MIME error
2 parents 361759d + cbe1765 commit 78d3c3b

12 files changed

Lines changed: 805 additions & 10 deletions

File tree

docs/.vitepress/config.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@ export default defineConfig({
3333
{
3434
text: 'Demos',
3535
items: [
36-
{ text: 'React Web', link: '/demo/react-web/', target: '_blank' },
37-
{ text: 'React Native', link: '/demo/react-native/', target: '_blank' },
38-
{ text: 'Vue + Quasar', link: '/demo/vue-quasar/', target: '_blank' },
39-
{ text: 'SvelteKit', link: '/demo/sveltekit/', target: '_blank' },
36+
{ text: 'React Web', link: 'https://devitools.github.io/ybyra/demo/react-web/', target: '_blank' },
37+
{ text: 'React Native', link: 'https://devitools.github.io/ybyra/demo/react-native/', target: '_blank' },
38+
{ text: 'Vue + Quasar', link: 'https://devitools.github.io/ybyra/demo/vue-quasar/', target: '_blank' },
39+
{ text: 'SvelteKit', link: 'https://devitools.github.io/ybyra/demo/sveltekit/', target: '_blank' },
4040
],
4141
},
4242
],

packages/react-native/testing/setup.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,14 @@ vi.mock('@ybyra/persistence', () => ({
3232
search: vi.fn(),
3333
})),
3434
}))
35+
36+
vi.mock('@ybyra/persistence/web', () => ({
37+
createWebDriver: vi.fn(() => ({
38+
initialize: vi.fn(),
39+
create: vi.fn(),
40+
read: vi.fn(),
41+
update: vi.fn(),
42+
destroy: vi.fn(),
43+
search: vi.fn(),
44+
})),
45+
}))

packages/react/src/icons.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, it, expect, beforeEach } from 'vitest'
2+
import { configureIcons, resolveActionIcon, resolveGroupIcon } from './icons'
3+
4+
beforeEach(() => {
5+
// Reset global icon map before each test to avoid state bleed
6+
configureIcons({})
7+
})
8+
9+
describe('configureIcons', () => {
10+
it('sets the icon map', () => {
11+
configureIcons({
12+
person: {
13+
actions: { save: 'save-icon' },
14+
},
15+
})
16+
expect(resolveActionIcon('person', 'save')).toBe('save-icon')
17+
})
18+
19+
it('replaces the entire map on subsequent calls', () => {
20+
configureIcons({ person: { actions: { save: 'old-icon' } } })
21+
configureIcons({ order: { actions: { submit: 'new-icon' } } })
22+
expect(resolveActionIcon('person', 'save')).toBeUndefined()
23+
expect(resolveActionIcon('order', 'submit')).toBe('new-icon')
24+
})
25+
})
26+
27+
describe('resolveActionIcon', () => {
28+
it('returns domain-specific action icon', () => {
29+
configureIcons({
30+
person: {
31+
actions: { save: 'person-save-icon' },
32+
},
33+
common: {
34+
actions: { save: 'common-save-icon' },
35+
},
36+
})
37+
expect(resolveActionIcon('person', 'save')).toBe('person-save-icon')
38+
})
39+
40+
it('falls back to common action icon', () => {
41+
configureIcons({
42+
common: {
43+
actions: { save: 'common-save-icon' },
44+
},
45+
})
46+
expect(resolveActionIcon('person', 'save')).toBe('common-save-icon')
47+
})
48+
49+
it('returns undefined when no icon exists', () => {
50+
configureIcons({})
51+
expect(resolveActionIcon('person', 'save')).toBeUndefined()
52+
})
53+
})
54+
55+
describe('resolveGroupIcon', () => {
56+
it('returns domain-specific group icon', () => {
57+
configureIcons({
58+
person: {
59+
groups: { personal: 'personal-icon' },
60+
},
61+
common: {
62+
groups: { personal: 'common-personal-icon' },
63+
},
64+
})
65+
expect(resolveGroupIcon('person', 'personal')).toBe('personal-icon')
66+
})
67+
68+
it('falls back to common group icon', () => {
69+
configureIcons({
70+
common: {
71+
groups: { personal: 'common-personal-icon' },
72+
},
73+
})
74+
expect(resolveGroupIcon('person', 'personal')).toBe('common-personal-icon')
75+
})
76+
77+
it('returns undefined when no icon exists', () => {
78+
configureIcons({})
79+
expect(resolveGroupIcon('person', 'personal')).toBeUndefined()
80+
})
81+
})
82+
83+
describe('icon resolution with multiple domains', () => {
84+
it('resolves icons from the correct domain', () => {
85+
configureIcons({
86+
person: {
87+
actions: { save: 'person-save' },
88+
groups: { info: 'person-info' },
89+
},
90+
order: {
91+
actions: { save: 'order-save' },
92+
groups: { info: 'order-info' },
93+
},
94+
})
95+
expect(resolveActionIcon('person', 'save')).toBe('person-save')
96+
expect(resolveActionIcon('order', 'save')).toBe('order-save')
97+
expect(resolveGroupIcon('person', 'info')).toBe('person-info')
98+
expect(resolveGroupIcon('order', 'info')).toBe('order-info')
99+
})
100+
})

packages/react/src/proxy.test.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { createStateProxy, createSchemaProxy } from './proxy'
3+
import type { FieldConfig } from '@ybyra/core'
4+
5+
function makeFieldConfig(overrides: Partial<FieldConfig> = {}): FieldConfig {
6+
return {
7+
component: 'text',
8+
dataType: 'string',
9+
attrs: {},
10+
form: { width: 100, height: 1, hidden: false, disabled: false, order: 0 },
11+
table: { show: false, width: 'auto', sortable: false, filterable: false, order: 0 },
12+
validations: [],
13+
scopes: null,
14+
states: [],
15+
defaultValue: undefined,
16+
...overrides,
17+
}
18+
}
19+
20+
describe('createStateProxy', () => {
21+
it('reads existing values from snapshot', () => {
22+
const snapshot = { name: 'Alice', age: 30 }
23+
const { proxy } = createStateProxy(snapshot)
24+
expect(proxy.name).toBe('Alice')
25+
expect(proxy.age).toBe(30)
26+
})
27+
28+
it('tracks mutations via set', () => {
29+
const { proxy, getChanges } = createStateProxy({ name: 'Alice' })
30+
proxy.name = 'Bob'
31+
expect(proxy.name).toBe('Bob')
32+
expect(getChanges()).toEqual({ name: 'Bob' })
33+
})
34+
35+
it('getChanges returns only modified fields', () => {
36+
const { proxy, getChanges } = createStateProxy({ name: 'Alice', age: 30 })
37+
proxy.name = 'Bob'
38+
const changes = getChanges()
39+
expect(changes).toEqual({ name: 'Bob' })
40+
expect(changes).not.toHaveProperty('age')
41+
})
42+
43+
it('getChanges returns empty object when nothing changed', () => {
44+
const { getChanges } = createStateProxy({ name: 'Alice' })
45+
expect(getChanges()).toEqual({})
46+
})
47+
48+
it('does not mutate the original snapshot object', () => {
49+
const snapshot = { name: 'Alice', age: 30 }
50+
const { proxy } = createStateProxy(snapshot)
51+
proxy.name = 'Bob'
52+
proxy.age = 99
53+
expect(snapshot.name).toBe('Alice')
54+
expect(snapshot.age).toBe(30)
55+
})
56+
57+
it('multiple sets to same key keeps latest value', () => {
58+
const { proxy, getChanges } = createStateProxy({ name: 'Alice' })
59+
proxy.name = 'Bob'
60+
proxy.name = 'Charlie'
61+
expect(proxy.name).toBe('Charlie')
62+
expect(getChanges()).toEqual({ name: 'Charlie' })
63+
})
64+
65+
it('handles setting value to undefined', () => {
66+
const { proxy, getChanges } = createStateProxy({ name: 'Alice' })
67+
proxy.name = undefined
68+
expect(proxy.name).toBeUndefined()
69+
expect(getChanges()).toEqual({ name: undefined })
70+
})
71+
72+
it('handles setting value to null', () => {
73+
const { proxy, getChanges } = createStateProxy({ name: 'Alice' })
74+
proxy.name = null
75+
expect(proxy.name).toBeNull()
76+
expect(getChanges()).toEqual({ name: null })
77+
})
78+
})
79+
80+
// Known issue: structuredClone does not reliably clone RegExp objects inside
81+
// ValidationRule params. If createSchemaProxy is used with structuredClone
82+
// upstream (e.g., via toConfig()), RegExp-based pattern validations may throw.
83+
// This is an upstream concern in @ybyra/core, not a proxy behavior issue — see
84+
// TextFieldDefinition.pattern() which stores a raw RegExp in validation params.
85+
86+
describe('createSchemaProxy', () => {
87+
it('provides field proxies with defaults from config', () => {
88+
const fields = {
89+
name: makeFieldConfig({
90+
form: { width: 50, height: 2, hidden: false, disabled: false, order: 0 },
91+
}),
92+
}
93+
const { proxy } = createSchemaProxy(fields, {})
94+
expect(proxy.name.width).toBe(50)
95+
expect(proxy.name.height).toBe(2)
96+
expect(proxy.name.hidden).toBe(false)
97+
expect(proxy.name.disabled).toBe(false)
98+
expect(proxy.name.state).toBe('')
99+
})
100+
101+
it('merges currentOverrides into field defaults', () => {
102+
const fields = {
103+
name: makeFieldConfig({
104+
form: { width: 50, height: 2, hidden: false, disabled: false, order: 0 },
105+
}),
106+
}
107+
const overrides = { name: { hidden: true, width: 75 } }
108+
const { proxy } = createSchemaProxy(fields, overrides)
109+
expect(proxy.name.hidden).toBe(true)
110+
expect(proxy.name.width).toBe(75)
111+
// Non-overridden properties keep config defaults
112+
expect(proxy.name.height).toBe(2)
113+
expect(proxy.name.disabled).toBe(false)
114+
})
115+
116+
it('tracks field property mutations', () => {
117+
const fields = { name: makeFieldConfig() }
118+
const { proxy, getOverrides } = createSchemaProxy(fields, {})
119+
proxy.name.hidden = true
120+
expect(getOverrides()).toEqual({ name: { hidden: true } })
121+
})
122+
123+
it('getOverrides returns only modified fields', () => {
124+
const fields = {
125+
name: makeFieldConfig(),
126+
age: makeFieldConfig(),
127+
}
128+
const { proxy, getOverrides } = createSchemaProxy(fields, {})
129+
proxy.name.hidden = true
130+
const overrides = getOverrides()
131+
expect(overrides).toHaveProperty('name')
132+
expect(overrides).not.toHaveProperty('age')
133+
})
134+
135+
it('getOverrides returns empty object when nothing changed', () => {
136+
const fields = { name: makeFieldConfig() }
137+
const { getOverrides } = createSchemaProxy(fields, {})
138+
expect(getOverrides()).toEqual({})
139+
})
140+
141+
it('setting hidden on a field appears in overrides', () => {
142+
const fields = { name: makeFieldConfig() }
143+
const { proxy, getOverrides } = createSchemaProxy(fields, {})
144+
proxy.name.hidden = true
145+
expect(getOverrides().name?.hidden).toBe(true)
146+
})
147+
148+
it('setting width on a field appears in overrides', () => {
149+
const fields = { name: makeFieldConfig() }
150+
const { proxy, getOverrides } = createSchemaProxy(fields, {})
151+
proxy.name.width = 200
152+
expect(getOverrides().name?.width).toBe(200)
153+
})
154+
155+
it('setting disabled on a field appears in overrides', () => {
156+
const fields = { name: makeFieldConfig() }
157+
const { proxy, getOverrides } = createSchemaProxy(fields, {})
158+
proxy.name.disabled = true
159+
expect(getOverrides().name?.disabled).toBe(true)
160+
})
161+
162+
it('setting state on a field appears in overrides', () => {
163+
const fields = { name: makeFieldConfig() }
164+
const { proxy, getOverrides } = createSchemaProxy(fields, {})
165+
proxy.name.state = 'editing'
166+
expect(getOverrides().name?.state).toBe('editing')
167+
})
168+
169+
it('multiple fields can be overridden independently', () => {
170+
const fields = {
171+
name: makeFieldConfig(),
172+
age: makeFieldConfig(),
173+
}
174+
const { proxy, getOverrides } = createSchemaProxy(fields, {})
175+
proxy.name.hidden = true
176+
proxy.age.disabled = true
177+
const overrides = getOverrides()
178+
expect(overrides.name).toEqual({ hidden: true })
179+
expect(overrides.age).toEqual({ disabled: true })
180+
})
181+
})
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { registerRenderers, getRenderer, createRegistry } from './registry'
3+
import type { FieldRenderer } from './types'
4+
5+
// Simple mock renderers — FieldRenderer is React.ComponentType<FieldRendererProps>,
6+
// so we cast arrow functions returning null.
7+
const FakeTextRenderer = (() => null) as unknown as FieldRenderer
8+
const FakeNumberRenderer = (() => null) as unknown as FieldRenderer
9+
const FakeSelectRenderer = (() => null) as unknown as FieldRenderer
10+
11+
describe('global registry', () => {
12+
describe('registerRenderers', () => {
13+
it('registers renderer by component name', () => {
14+
// Using unique names to avoid polluting other tests (global mutable state,
15+
// no unregister API available)
16+
registerRenderers({ 'test-text-global': FakeTextRenderer })
17+
expect(getRenderer('test-text-global')).toBe(FakeTextRenderer)
18+
})
19+
})
20+
21+
describe('getRenderer', () => {
22+
it('returns registered renderer', () => {
23+
registerRenderers({ 'test-number-global': FakeNumberRenderer })
24+
expect(getRenderer('test-number-global')).toBe(FakeNumberRenderer)
25+
})
26+
27+
it('returns undefined for unregistered component', () => {
28+
expect(getRenderer('nonexistent-component')).toBeUndefined()
29+
})
30+
31+
it('overwrites existing renderer when re-registered', () => {
32+
registerRenderers({ 'test-overwrite': FakeTextRenderer })
33+
registerRenderers({ 'test-overwrite': FakeNumberRenderer })
34+
expect(getRenderer('test-overwrite')).toBe(FakeNumberRenderer)
35+
})
36+
})
37+
})
38+
39+
describe('createRegistry', () => {
40+
it('creates an independent registry', () => {
41+
const registry = createRegistry()
42+
expect(registry).toHaveProperty('register')
43+
expect(registry).toHaveProperty('get')
44+
})
45+
46+
it('register adds renderers to scoped registry', () => {
47+
const registry = createRegistry()
48+
registry.register({ 'scoped-text': FakeTextRenderer })
49+
expect(registry.get('scoped-text')).toBe(FakeTextRenderer)
50+
})
51+
52+
it('get returns registered renderer from scoped registry', () => {
53+
const registry = createRegistry()
54+
registry.register({ 'scoped-number': FakeNumberRenderer })
55+
expect(registry.get('scoped-number')).toBe(FakeNumberRenderer)
56+
})
57+
58+
it('get returns undefined for unregistered component', () => {
59+
const registry = createRegistry()
60+
expect(registry.get('nonexistent')).toBeUndefined()
61+
})
62+
63+
it('scoped registry is independent from global registry', () => {
64+
// Register in global
65+
registerRenderers({ 'global-only-renderer': FakeSelectRenderer })
66+
67+
// Create scoped — should not see global renderers
68+
const registry = createRegistry()
69+
expect(registry.get('global-only-renderer')).toBeUndefined()
70+
71+
// Register in scoped — should not appear in global
72+
registry.register({ 'scoped-only-renderer': FakeTextRenderer })
73+
expect(getRenderer('scoped-only-renderer')).toBeUndefined()
74+
expect(registry.get('scoped-only-renderer')).toBe(FakeTextRenderer)
75+
})
76+
})

0 commit comments

Comments
 (0)