Skip to content

Commit 4618b7b

Browse files
Merge pull request #2371 from gjs-opsflo/fix/calm-studio-test-coverage
test(calm-core): improve validation branch coverage from 77% to 98%
2 parents b4b6f27 + 9ad6a57 commit 4618b7b

1 file changed

Lines changed: 289 additions & 0 deletions

File tree

calm-suite/calm-studio/packages/calm-core/src/validation.test.ts

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,295 @@ describe('validateCalmArchitecture', () => {
130130
expect(errors.length).toBeGreaterThan(0);
131131
});
132132

133+
it('skips semantic rules when nodes is not an array', () => {
134+
const arch = { nodes: 'bad', relationships: [] } as unknown as CalmArchitecture;
135+
const issues = validateCalmArchitecture(arch);
136+
// Should have schema error but no semantic warnings (orphan, etc.)
137+
const warnings = issues.filter((i) => i.severity === 'warning');
138+
expect(warnings).toHaveLength(0);
139+
});
140+
141+
it('skips semantic rules when relationships is not an array', () => {
142+
const arch = { nodes: [], relationships: 'bad' } as unknown as CalmArchitecture;
143+
const issues = validateCalmArchitecture(arch);
144+
const warnings = issues.filter((i) => i.severity === 'warning');
145+
expect(warnings).toHaveLength(0);
146+
});
147+
148+
it('schema error on relationship extracts relationshipId', () => {
149+
const arch: CalmArchitecture = {
150+
nodes: [makeNode('node-a', 'Node A')],
151+
relationships: [
152+
{
153+
'unique-id': 'rel-bad',
154+
'relationship-type': '' as never, // minLength violation
155+
source: 'node-a',
156+
destination: 'node-a'
157+
} as never
158+
]
159+
};
160+
const issues = validateCalmArchitecture(arch);
161+
const schemaErrors = issues.filter(
162+
(i) => i.severity === 'error' && i.path?.startsWith('/relationships/')
163+
);
164+
expect(schemaErrors.length).toBeGreaterThan(0);
165+
expect(schemaErrors[0]!.relationshipId).toBe('rel-bad');
166+
});
167+
168+
it('schema error on node without unique-id does not set nodeId', () => {
169+
const arch = {
170+
nodes: [{ 'node-type': '', name: 'X' }], // missing unique-id, empty node-type
171+
relationships: []
172+
} as unknown as CalmArchitecture;
173+
const issues = validateCalmArchitecture(arch);
174+
const nodeSchemaErrors = issues.filter(
175+
(i) => i.severity === 'error' && i.path?.startsWith('/nodes/')
176+
);
177+
expect(nodeSchemaErrors.length).toBeGreaterThan(0);
178+
// nodeId should be undefined since the node has no unique-id
179+
expect(nodeSchemaErrors[0]!.nodeId).toBeUndefined();
180+
});
181+
182+
it('schema error with empty instancePath omits path prefix in message', () => {
183+
// Architecture missing required 'nodes' property entirely
184+
const arch = { relationships: [] } as unknown as CalmArchitecture;
185+
const issues = validateCalmArchitecture(arch);
186+
const errors = issues.filter((i) => i.severity === 'error');
187+
expect(errors.length).toBeGreaterThan(0);
188+
// At least one error should have empty path (root-level schema error)
189+
const rootError = errors.find((i) => i.path === '');
190+
expect(rootError).toBeDefined();
191+
// Message should not start with ':'
192+
expect(rootError!.message).not.toMatch(/^:/);
193+
});
194+
195+
it('relationship missing source returns error', () => {
196+
const arch = {
197+
nodes: [makeNode('node-a', 'Node A')],
198+
relationships: [
199+
{ 'unique-id': 'rel-1', 'relationship-type': 'connects', destination: 'node-a' }
200+
]
201+
} as unknown as CalmArchitecture;
202+
const issues = validateCalmArchitecture(arch);
203+
const errors = issues.filter(
204+
(i) => i.severity === 'error' && i.message.includes('missing a source')
205+
);
206+
expect(errors.length).toBeGreaterThan(0);
207+
expect(errors[0]!.relationshipId).toBe('rel-1');
208+
});
209+
210+
it('relationship missing destination returns error', () => {
211+
const arch = {
212+
nodes: [makeNode('node-a', 'Node A')],
213+
relationships: [
214+
{ 'unique-id': 'rel-1', 'relationship-type': 'connects', source: 'node-a' }
215+
]
216+
} as unknown as CalmArchitecture;
217+
const issues = validateCalmArchitecture(arch);
218+
const errors = issues.filter(
219+
(i) => i.severity === 'error' && i.message.includes('missing a destination')
220+
);
221+
expect(errors.length).toBeGreaterThan(0);
222+
expect(errors[0]!.relationshipId).toBe('rel-1');
223+
});
224+
225+
it('dangling destination reference returns error', () => {
226+
const arch: CalmArchitecture = {
227+
nodes: [makeNode('node-a', 'Node A')],
228+
relationships: [makeRel('rel-1', 'node-a', 'ghost-node')]
229+
};
230+
const issues = validateCalmArchitecture(arch);
231+
const errors = issues.filter(
232+
(i) => i.severity === 'error' && i.message.includes('destination') && i.message.includes('ghost-node')
233+
);
234+
expect(errors.length).toBeGreaterThan(0);
235+
expect(errors[0]!.relationshipId).toBe('rel-1');
236+
});
237+
238+
it('duplicate relationship unique-ids returns error', () => {
239+
const arch: CalmArchitecture = {
240+
nodes: [makeNode('node-a', 'Node A'), makeNode('node-b', 'Node B')],
241+
relationships: [makeRel('rel-dup', 'node-a', 'node-b'), makeRel('rel-dup', 'node-b', 'node-a')]
242+
};
243+
const issues = validateCalmArchitecture(arch);
244+
const errors = issues.filter(
245+
(i) => i.severity === 'error' && i.message.includes('Duplicate relationship')
246+
);
247+
expect(errors.length).toBeGreaterThan(0);
248+
expect(errors[0]!.relationshipId).toBe('rel-dup');
249+
});
250+
251+
it('relationship without unique-id skips duplicate check and uses ? in messages', () => {
252+
const arch = {
253+
nodes: [makeNode('node-a', 'Node A')],
254+
relationships: [
255+
{ 'relationship-type': 'connects', source: 'node-a', destination: 'ghost' }
256+
]
257+
} as unknown as CalmArchitecture;
258+
const issues = validateCalmArchitecture(arch);
259+
// Should have dangling ref error with '?' as relId
260+
const danglingErrors = issues.filter(
261+
(i) => i.severity === 'error' && i.message.includes('"?"')
262+
);
263+
expect(danglingErrors.length).toBeGreaterThan(0);
264+
// Should NOT have relationshipId set
265+
expect(danglingErrors[0]!.relationshipId).toBeUndefined();
266+
});
267+
268+
it('node without unique-id is skipped in duplicate/semantic checks', () => {
269+
const arch = {
270+
nodes: [
271+
{ 'node-type': 'service', name: 'No ID', description: 'desc' },
272+
makeNode('node-b', 'Node B')
273+
],
274+
relationships: []
275+
} as unknown as CalmArchitecture;
276+
const issues = validateCalmArchitecture(arch);
277+
// Should not crash; no duplicate errors for undefined ids
278+
const dupErrors = issues.filter((i) => i.message.includes('Duplicate node'));
279+
expect(dupErrors).toHaveLength(0);
280+
});
281+
282+
it('node with empty string description returns info', () => {
283+
const arch: CalmArchitecture = {
284+
nodes: [
285+
{ 'unique-id': 'node-1', 'node-type': 'service' as const, name: 'Test', description: ' ' }
286+
],
287+
relationships: []
288+
};
289+
const issues = validateCalmArchitecture(arch);
290+
const infos = issues.filter(
291+
(i) => i.severity === 'info' && i.nodeId === 'node-1' && i.message.includes('no description')
292+
);
293+
expect(infos.length).toBeGreaterThan(0);
294+
});
295+
296+
it('dangling source with zero nodes does not report dangling ref', () => {
297+
// nodeIds.size === 0 branch: skip dangling check
298+
const arch = {
299+
nodes: [],
300+
relationships: [
301+
{ 'unique-id': 'rel-1', 'relationship-type': 'connects', source: 'x', destination: 'y' }
302+
]
303+
} as unknown as CalmArchitecture;
304+
const issues = validateCalmArchitecture(arch);
305+
const danglingErrors = issues.filter(
306+
(i) => i.severity === 'error' && i.message.includes('does not reference a known node')
307+
);
308+
expect(danglingErrors).toHaveLength(0);
309+
});
310+
311+
it('relationship with missing source AND no relId omits relationshipId', () => {
312+
const arch = {
313+
nodes: [makeNode('node-a', 'Node A')],
314+
relationships: [
315+
{ 'relationship-type': 'connects', destination: 'node-a' }
316+
]
317+
} as unknown as CalmArchitecture;
318+
const issues = validateCalmArchitecture(arch);
319+
const missingSource = issues.filter(
320+
(i) => i.severity === 'error' && i.message.includes('missing a source')
321+
);
322+
expect(missingSource.length).toBeGreaterThan(0);
323+
expect(missingSource[0]!.relationshipId).toBeUndefined();
324+
});
325+
326+
it('relationship with missing destination AND no relId omits relationshipId', () => {
327+
const arch = {
328+
nodes: [makeNode('node-a', 'Node A')],
329+
relationships: [
330+
{ 'relationship-type': 'connects', source: 'node-a' }
331+
]
332+
} as unknown as CalmArchitecture;
333+
const issues = validateCalmArchitecture(arch);
334+
const missingDest = issues.filter(
335+
(i) => i.severity === 'error' && i.message.includes('missing a destination')
336+
);
337+
expect(missingDest.length).toBeGreaterThan(0);
338+
expect(missingDest[0]!.relationshipId).toBeUndefined();
339+
});
340+
341+
it('self-loop with no relId uses ? and omits relationshipId', () => {
342+
const arch = {
343+
nodes: [makeNode('node-a', 'Node A')],
344+
relationships: [
345+
{ 'relationship-type': 'connects', source: 'node-a', destination: 'node-a' }
346+
]
347+
} as unknown as CalmArchitecture;
348+
const issues = validateCalmArchitecture(arch);
349+
const selfLoop = issues.filter(
350+
(i) => i.severity === 'warning' && i.message.includes('connects a node to itself')
351+
);
352+
expect(selfLoop.length).toBeGreaterThan(0);
353+
expect(selfLoop[0]!.message).toContain('"?"');
354+
expect(selfLoop[0]!.relationshipId).toBeUndefined();
355+
});
356+
357+
it('issues are sorted by severity: errors first, then warnings, then info', () => {
358+
const arch: CalmArchitecture = {
359+
nodes: [
360+
{ 'unique-id': 'node-a', 'node-type': 'service' as const, name: 'A' }, // info: no desc
361+
{ 'unique-id': 'node-b', 'node-type': 'service' as const, name: 'B' }, // warning: orphan, info: no desc
362+
],
363+
relationships: [makeRel('rel-1', 'node-a', 'ghost')] // error: dangling dest
364+
};
365+
const issues = validateCalmArchitecture(arch);
366+
expect(issues.length).toBeGreaterThanOrEqual(3);
367+
const severities = issues.map((i) => i.severity);
368+
const errorIdx = severities.indexOf('error');
369+
const warnIdx = severities.indexOf('warning');
370+
const infoIdx = severities.indexOf('info');
371+
if (errorIdx >= 0 && warnIdx >= 0) expect(errorIdx).toBeLessThan(warnIdx);
372+
if (warnIdx >= 0 && infoIdx >= 0) expect(warnIdx).toBeLessThan(infoIdx);
373+
});
374+
375+
it('schema error on relationship without unique-id does not set relationshipId', () => {
376+
const arch = {
377+
nodes: [],
378+
relationships: [
379+
{ 'relationship-type': '', source: 'a', destination: 'b' } // minLength violation, no unique-id
380+
]
381+
} as unknown as CalmArchitecture;
382+
const issues = validateCalmArchitecture(arch);
383+
const relSchemaErrors = issues.filter(
384+
(i) => i.severity === 'error' && i.path?.startsWith('/relationships/')
385+
);
386+
expect(relSchemaErrors.length).toBeGreaterThan(0);
387+
expect(relSchemaErrors[0]!.relationshipId).toBeUndefined();
388+
});
389+
390+
it('dangling source ref without relId uses ? and omits relationshipId', () => {
391+
const arch = {
392+
nodes: [makeNode('node-a', 'Node A')],
393+
relationships: [
394+
{ 'relationship-type': 'connects', source: 'ghost', destination: 'node-a' }
395+
]
396+
} as unknown as CalmArchitecture;
397+
const issues = validateCalmArchitecture(arch);
398+
const dangling = issues.filter(
399+
(i) => i.severity === 'error' && i.message.includes('source') && i.message.includes('ghost')
400+
);
401+
expect(dangling.length).toBeGreaterThan(0);
402+
expect(dangling[0]!.message).toContain('"?"');
403+
expect(dangling[0]!.relationshipId).toBeUndefined();
404+
});
405+
406+
it('dangling destination ref without relId uses ? and omits relationshipId', () => {
407+
const arch = {
408+
nodes: [makeNode('node-a', 'Node A')],
409+
relationships: [
410+
{ 'relationship-type': 'connects', source: 'node-a', destination: 'ghost' }
411+
]
412+
} as unknown as CalmArchitecture;
413+
const issues = validateCalmArchitecture(arch);
414+
const dangling = issues.filter(
415+
(i) => i.severity === 'error' && i.message.includes('destination') && i.message.includes('ghost')
416+
);
417+
expect(dangling.length).toBeGreaterThan(0);
418+
expect(dangling[0]!.message).toContain('"?"');
419+
expect(dangling[0]!.relationshipId).toBeUndefined();
420+
});
421+
133422
it('ValidationIssue includes optional path, nodeId, relationshipId fields', () => {
134423
// Ensure the type has the right shape — compile-time check via TypeScript
135424
const issue: ValidationIssue = {

0 commit comments

Comments
 (0)