Skip to content

Commit 311aa22

Browse files
committed
add form node unit test
1 parent 0f0ccb2 commit 311aa22

1 file changed

Lines changed: 375 additions & 0 deletions

File tree

Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
import type { FormComponents, InternalSchemaNode, Validator } from '~/types';
2+
import { mount } from '@vue/test-utils';
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { computed, defineComponent, h, inject, nextTick, provide, ref, type Ref } from 'vue';
5+
import FormNode from './FormNode.vue';
6+
7+
const mockUseNode = vi.fn();
8+
const mockIsNodeValid = vi.fn();
9+
const mockSetDragEventData = vi.fn();
10+
11+
vi.mock('~/compositions/useNode', () => ({
12+
default: (...args: unknown[]) => mockUseNode(...args),
13+
}));
14+
15+
vi.mock('~/compositions/useSchema', () => ({
16+
isNodeValid: (...args: unknown[]) => mockIsNodeValid(...args),
17+
}));
18+
19+
vi.mock('~/utils', () => ({
20+
setDragEventData: (...args: unknown[]) => mockSetDragEventData(...args),
21+
}));
22+
23+
const DynamicComponent = defineComponent({
24+
name: 'DynamicComponent',
25+
props: {
26+
id: { type: String, required: true },
27+
mode: { type: String, required: true },
28+
error: { type: String, default: undefined },
29+
modelValue: { type: [String, Number, Boolean, Object, Array], default: undefined },
30+
custom: { type: String, default: undefined },
31+
},
32+
emits: ['update:modelValue'],
33+
template: `
34+
<section data-dynamic>
35+
<span data-id>{{ id }}</span>
36+
<span data-mode>{{ mode }}</span>
37+
<span data-error>{{ error }}</span>
38+
<span data-model>{{ modelValue }}</span>
39+
<span data-custom>{{ custom }}</span>
40+
<button data-update @click="$emit('update:modelValue', 'from-child')">update</button>
41+
<slot />
42+
</section>
43+
`,
44+
});
45+
46+
const SlotProbe = defineComponent({
47+
name: 'SlotProbe',
48+
setup() {
49+
const node = inject<Ref<InternalSchemaNode>>('node');
50+
return () => h('span', { 'data-slot-node-id': node?.value?._id });
51+
},
52+
});
53+
54+
function createNode(overrides: Partial<InternalSchemaNode> = {}): InternalSchemaNode {
55+
return {
56+
_id: 'node-1',
57+
type: 'text',
58+
name: 'field',
59+
props: { custom: 'from-node-props' },
60+
...overrides,
61+
};
62+
}
63+
64+
function createComponentsFixture(): FormComponents {
65+
return {
66+
text: {
67+
label: 'Text',
68+
propsSchema: [{ type: 'text', name: '$name' }],
69+
component: DynamicComponent,
70+
},
71+
};
72+
}
73+
74+
function createUseNodeState(overrides?: {
75+
component?: unknown;
76+
error?: string | undefined;
77+
isShown?: boolean;
78+
modelValue?: unknown;
79+
}) {
80+
return {
81+
component: computed(() =>
82+
Object.prototype.hasOwnProperty.call(overrides ?? {}, 'component')
83+
? overrides?.component
84+
: DynamicComponent,
85+
),
86+
error: computed(() => overrides?.error),
87+
isShown: computed(() => overrides?.isShown ?? true),
88+
modelValue: ref(overrides?.modelValue ?? 'initial-value'),
89+
};
90+
}
91+
92+
function mountFormNode(options?: {
93+
node?: InternalSchemaNode;
94+
mode?: 'build' | 'read' | 'edit';
95+
selectedNode?: InternalSchemaNode | undefined;
96+
formId?: string;
97+
validator?: Validator;
98+
components?: FormComponents;
99+
provideValidator?: boolean;
100+
provideComponents?: boolean;
101+
provideMode?: boolean;
102+
provideSelectedNode?: boolean;
103+
provideFormId?: boolean;
104+
useNodeState?: ReturnType<typeof createUseNodeState>;
105+
isNodeValidFlag?: boolean;
106+
withParentClickSpy?: boolean;
107+
withParentDragSpy?: boolean;
108+
}) {
109+
const node = options?.node ?? createNode();
110+
const mode = ref(options?.mode ?? 'build');
111+
const selectedNode = ref<InternalSchemaNode | undefined>(options?.selectedNode);
112+
const formId = ref(options?.formId ?? 'form-123');
113+
const validator: Validator = options?.validator ?? (() => true);
114+
const components = options?.components ?? createComponentsFixture();
115+
const useNodeState = options?.useNodeState ?? createUseNodeState();
116+
117+
mockUseNode.mockReturnValue(useNodeState);
118+
mockIsNodeValid.mockReturnValue(options?.isNodeValidFlag ?? true);
119+
120+
const parentClickSpy = vi.fn();
121+
const parentDragSpy = vi.fn();
122+
123+
const Parent = defineComponent({
124+
components: { FormNode, SlotProbe },
125+
setup() {
126+
if (options?.provideValidator !== false) {
127+
provide('validator', validator);
128+
}
129+
if (options?.provideComponents !== false) {
130+
provide('components', components);
131+
}
132+
if (options?.provideMode !== false) {
133+
provide('mode', mode);
134+
}
135+
if (options?.provideSelectedNode !== false) {
136+
provide('selectedNode', selectedNode);
137+
}
138+
if (options?.provideFormId !== false) {
139+
provide('formId', formId);
140+
}
141+
return { node, mode, selectedNode, parentClickSpy, parentDragSpy };
142+
},
143+
template: `
144+
<div data-parent @click="parentClickSpy" @dragstart="parentDragSpy">
145+
<FormNode :node="node">
146+
<SlotProbe />
147+
</FormNode>
148+
</div>
149+
`,
150+
});
151+
152+
const wrapper = mount(Parent);
153+
return { wrapper, node, mode, selectedNode, formId, parentClickSpy, parentDragSpy, useNodeState };
154+
}
155+
156+
describe('component FormNode', () => {
157+
beforeEach(() => {
158+
vi.clearAllMocks();
159+
vi.restoreAllMocks();
160+
});
161+
162+
afterEach(() => {
163+
vi.clearAllMocks();
164+
});
165+
166+
describe('visibility and root rendering', () => {
167+
it('renders root wrapper when isShown is true', () => {
168+
const { wrapper } = mountFormNode({ useNodeState: createUseNodeState({ isShown: true }) });
169+
expect(wrapper.find('[data-node="node-1"]').exists()).toBe(true);
170+
});
171+
172+
it('renders root wrapper in build mode even when isShown is false', () => {
173+
const { wrapper } = mountFormNode({ mode: 'build', useNodeState: createUseNodeState({ isShown: false }) });
174+
expect(wrapper.find('[data-node="node-1"]').exists()).toBe(true);
175+
});
176+
177+
it('does not render root wrapper when isShown is false and mode is not build', () => {
178+
const { wrapper } = mountFormNode({ mode: 'read', useNodeState: createUseNodeState({ isShown: false }) });
179+
expect(wrapper.find('[data-node="node-1"]').exists()).toBe(false);
180+
});
181+
});
182+
183+
describe('component resolution', () => {
184+
it('renders the resolved dynamic component when component is provided', () => {
185+
const { wrapper } = mountFormNode({ useNodeState: createUseNodeState({ component: DynamicComponent }) });
186+
expect(wrapper.find('[data-dynamic]').exists()).toBe(true);
187+
});
188+
189+
it('renders fallback text when component is missing', () => {
190+
const { wrapper } = mountFormNode({ useNodeState: createUseNodeState({ component: null }) });
191+
expect(wrapper.text()).toContain('Component type not found!');
192+
});
193+
});
194+
195+
describe('dynamic component bindings', () => {
196+
it('passes node props to the rendered component', () => {
197+
const { wrapper } = mountFormNode({ node: createNode({ props: { custom: 'abc' } }) });
198+
expect(wrapper.find('[data-custom]').text()).toBe('abc');
199+
});
200+
201+
it('passes id as node._id to the rendered component', () => {
202+
const { wrapper } = mountFormNode({ node: createNode({ _id: 'node-42' }) });
203+
expect(wrapper.find('[data-id]').text()).toBe('node-42');
204+
});
205+
206+
it('passes mode prop to the rendered component', () => {
207+
const { wrapper } = mountFormNode({ mode: 'read' });
208+
expect(wrapper.find('[data-mode]').text()).toBe('read');
209+
});
210+
211+
it('passes error prop from useNode to the rendered component', () => {
212+
const { wrapper } = mountFormNode({ useNodeState: createUseNodeState({ error: 'bad input' }) });
213+
expect(wrapper.find('[data-error]').text()).toBe('bad input');
214+
});
215+
216+
it('binds v-model to useNode modelValue', async () => {
217+
const state = createUseNodeState({ modelValue: 'before' });
218+
const { wrapper } = mountFormNode({ useNodeState: state });
219+
expect(wrapper.find('[data-model]').text()).toBe('before');
220+
await wrapper.find('[data-update]').trigger('click');
221+
await nextTick();
222+
expect((state.modelValue as Ref<unknown>).value).toBe('from-child');
223+
});
224+
225+
it('renders slot content inside the dynamic component', () => {
226+
const { wrapper } = mountFormNode();
227+
expect(wrapper.find('[data-slot-node-id]').exists()).toBe(true);
228+
});
229+
});
230+
231+
describe('build mode visuals', () => {
232+
it('sets draggable=true only in build mode', async () => {
233+
const { wrapper, mode } = mountFormNode({ mode: 'build' });
234+
const root = wrapper.find('[data-node="node-1"]');
235+
expect(root.attributes('draggable')).toBe('true');
236+
mode.value = 'read';
237+
await nextTick();
238+
expect(root.attributes('draggable')).toBe('false');
239+
});
240+
241+
it('shows drag handle only in build mode', async () => {
242+
const { wrapper, mode } = mountFormNode({ mode: 'build' });
243+
expect(wrapper.find('.drag-handle').exists()).toBe(true);
244+
mode.value = 'read';
245+
await nextTick();
246+
expect(wrapper.find('.drag-handle').exists()).toBe(false);
247+
});
248+
249+
it('adds former-draggable and padding classes in build mode', () => {
250+
const { wrapper } = mountFormNode({ mode: 'build' });
251+
const classes = wrapper.find('[data-node="node-1"]').classes();
252+
expect(classes).toContain('former-draggable');
253+
expect(classes).toContain('p-2');
254+
});
255+
256+
it('applies selected-node border class when selectedNode._id equals node._id in build mode', () => {
257+
const node = createNode({ _id: 'same-id' });
258+
const { wrapper } = mountFormNode({ mode: 'build', node, selectedNode: node });
259+
const classes = wrapper.find('[data-node="same-id"]').classes();
260+
expect(classes).toContain('border-2');
261+
expect(classes).toContain('!border-blue-600');
262+
});
263+
264+
it('applies hidden-in-build background class when build mode and isShown is false', () => {
265+
const { wrapper } = mountFormNode({
266+
mode: 'build',
267+
useNodeState: createUseNodeState({ isShown: false }),
268+
});
269+
const classes = wrapper.find('[data-node="node-1"]').classes();
270+
expect(classes).toContain('bg-zinc-300');
271+
expect(classes).toContain('dark:bg-zinc-700');
272+
});
273+
274+
it('applies invalid-node border class when isNodeValid returns false in build mode', () => {
275+
const { wrapper } = mountFormNode({ mode: 'build', isNodeValidFlag: false });
276+
const classes = wrapper.find('[data-node="node-1"]').classes();
277+
expect(classes).toContain('border-2');
278+
expect(classes).toContain('border-red-500');
279+
});
280+
281+
it('does not apply build-only classes outside build mode', () => {
282+
const { wrapper } = mountFormNode({ mode: 'read', isNodeValidFlag: false });
283+
const classes = wrapper.find('[data-node="node-1"]').classes();
284+
expect(classes).not.toContain('former-draggable');
285+
expect(classes).not.toContain('p-2');
286+
expect(classes).not.toContain('!border-blue-600');
287+
expect(classes).not.toContain('border-red-500');
288+
});
289+
});
290+
291+
describe('click behavior', () => {
292+
it('sets selectedNode to current node on root click', async () => {
293+
const node = createNode({ _id: 'clicked-id' });
294+
const { wrapper, selectedNode } = mountFormNode({ node, selectedNode: undefined });
295+
await wrapper.find('[data-node="clicked-id"]').trigger('click');
296+
expect(selectedNode.value?._id).toBe('clicked-id');
297+
});
298+
299+
it('stops click propagation on root click', async () => {
300+
const { wrapper, parentClickSpy } = mountFormNode();
301+
await wrapper.find('[data-node="node-1"]').trigger('click');
302+
expect(parentClickSpy).not.toHaveBeenCalled();
303+
});
304+
});
305+
306+
describe('drag behavior', () => {
307+
it('calls setDragEventData on dragstart with formId, discriminator node_id and node._id', async () => {
308+
const { wrapper } = mountFormNode({ formId: 'form-xyz', node: createNode({ _id: 'drag-id' }) });
309+
await wrapper.find('[data-node="drag-id"]').trigger('dragstart');
310+
expect(mockSetDragEventData).toHaveBeenCalledWith(
311+
expect.anything(),
312+
'form-xyz',
313+
'node_id',
314+
'drag-id',
315+
);
316+
});
317+
318+
it('stops dragstart propagation on root dragstart', async () => {
319+
const { wrapper, parentDragSpy } = mountFormNode();
320+
await wrapper.find('[data-node="node-1"]').trigger('dragstart');
321+
expect(parentDragSpy).not.toHaveBeenCalled();
322+
});
323+
324+
it('does not call setDragEventData when dragstart is not triggered', () => {
325+
mountFormNode();
326+
expect(mockSetDragEventData).not.toHaveBeenCalled();
327+
});
328+
});
329+
330+
describe('injection and provide contract', () => {
331+
it('provides node ref for descendants via provide("node", nodeRef)', () => {
332+
const { wrapper } = mountFormNode({ node: createNode({ _id: 'provided-id' }) });
333+
expect(wrapper.find('[data-slot-node-id]').attributes('data-slot-node-id')).toBe('provided-id');
334+
});
335+
336+
it('uses injected mode ref to drive build/read behavior reactively', async () => {
337+
const { wrapper, mode } = mountFormNode({ mode: 'build' });
338+
expect(wrapper.find('.drag-handle').exists()).toBe(true);
339+
mode.value = 'read';
340+
await nextTick();
341+
expect(wrapper.find('.drag-handle').exists()).toBe(false);
342+
});
343+
344+
it('uses injected selectedNode ref reactively for class updates', async () => {
345+
const node = createNode({ _id: 'sel-id' });
346+
const { wrapper, selectedNode } = mountFormNode({ mode: 'build', node, selectedNode: undefined });
347+
expect(wrapper.find('[data-node="sel-id"]').classes()).not.toContain('!border-blue-600');
348+
selectedNode.value = node;
349+
await nextTick();
350+
expect(wrapper.find('[data-node="sel-id"]').classes()).toContain('!border-blue-600');
351+
});
352+
});
353+
354+
describe('error paths (required injections)', () => {
355+
it('throws when validator injection is missing', () => {
356+
expect(() => mountFormNode({ provideValidator: false })).toThrow('Please provide a value for validator');
357+
});
358+
359+
it('throws when components injection is missing', () => {
360+
expect(() => mountFormNode({ provideComponents: false })).toThrow('Please provide a value for components');
361+
});
362+
363+
it('throws when mode injection is missing', () => {
364+
expect(() => mountFormNode({ provideMode: false })).toThrow('Please provide a value for mode');
365+
});
366+
367+
it('throws when selectedNode injection is missing', () => {
368+
expect(() => mountFormNode({ provideSelectedNode: false })).toThrow('Please provide a value for selectedNode');
369+
});
370+
371+
it('throws when formId injection is missing', () => {
372+
expect(() => mountFormNode({ provideFormId: false })).toThrow('Please provide a value for formId');
373+
});
374+
});
375+
});

0 commit comments

Comments
 (0)