Skip to content

Commit a5ba0d8

Browse files
authored
test(mcp): a 627-line tool surface with no test file, via harness not extraction (#3144)
* test(mcp): add mutation-targeted coverage for query.ts shaping/formatting/sort query_entities' shapeEntities and formatQueryResult, and count_entities' group_by sort, had no dedicated test file (627 lines). Fixtures use distinct-per-field values, a missing-optionals row, a >25-item page, and an out-of-order/tied group_by fixture so a field swap, dropped field, reversed sort, or off-by-one boundary changes the result rather than matching it by symmetry. * test(mcp): the pluralization-boundary test could not detect the boundary From @louistrue's adversarial review of this PR. The test named "singular 'entity' really does trigger at count 1" asserted /^Found \d+ matching entit(y|ies)/ which matches "entity" AND "entities", for any count. The one test in the file written for the singular/plural boundary was the one test that could not tell which side of it the output landed on. Its neighbours already do it properly -- the 0-match case pins /^Found 0 matching entities\./ and the 26-match case pins /^Found 26 matching entities\./ -- so this was the odd one out, not the convention. Now pins the exact singular string. The fixture does yield exactly one match, traced rather than assumed: with no `type` filter only product types are candidates; SHAPE_MODEL declares no IfcRelContainedInSpatialStructure or IfcRelAggregates, so `storey()` walks up and finds nothing for all three walls; only the storey itself (#41, the guid('STOR') being queried) short-circuits to itself. total === 1, so formatQueryResult renders the singular. Cannot run vitest here (this worktree has no node_modules); CI is the check.
1 parent 989da89 commit a5ba0d8

1 file changed

Lines changed: 291 additions & 0 deletions

File tree

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4+
5+
/**
6+
* Mutation-targeted coverage for `query_entities`' shaping/formatting and
7+
* `count_entities`' `group_by` sort — the largest untested file in this
8+
* package before this file existed.
9+
*
10+
* Fixtures are built so a mutant cannot hide behind a symmetry:
11+
* - `shapeEntities` fixtures give every entity a *distinct* value per field
12+
* (name/description/objectType/globalId), so a field swap or drop changes
13+
* the output rather than agreeing with it by accident, and include one
14+
* entity that omits the optional attributes so a "always fill in X"
15+
* mutant is observable.
16+
* - `formatQueryResult` fixtures include an exact single-entity result (the
17+
* "entity" vs "entities" pluralization boundary) and a >25-item page (the
18+
* "+N more" boundary), rather than only in-range sizes.
19+
* - `count_entities` group_by fixtures are inserted in an order that is
20+
* NOT already sorted by count, with an explicit tie, so a reversed or
21+
* no-op sort produces a different, wrong, order rather than an
22+
* indistinguishable one.
23+
*/
24+
25+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
26+
import { tmpdir } from 'node:os';
27+
import { join } from 'node:path';
28+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
29+
import type { CallToolResult } from '../protocol/index.js';
30+
import type { ToolContext } from '../context.js';
31+
import { DEFAULT_CONFIG, InMemoryModelRegistry, NOOP_PROGRESS, SILENT_LOGGER } from '../context.js';
32+
import { fullScope } from '../auth/scope.js';
33+
import { loadIfcModel } from '../loader.js';
34+
import { queryTools } from './query.js';
35+
36+
/** A 22-character IFC GlobalId from a short mnemonic. */
37+
function guid(mnemonic: string): string {
38+
return (mnemonic + '0'.repeat(22)).slice(0, 22);
39+
}
40+
41+
function step(body: string): string {
42+
return `ISO-10303-21;
43+
HEADER;
44+
FILE_DESCRIPTION((''),'2;1');
45+
FILE_NAME('m','2026',(''),(''),'','','');
46+
FILE_SCHEMA(('IFC4'));
47+
ENDSEC;
48+
DATA;
49+
#1= IFCPROJECT('${guid('PROJ')}',$,'Proj',$,$,$,$,(#20),#30);
50+
#20= IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-5,#21,$);
51+
#21= IFCAXIS2PLACEMENT3D(#22,$,$);
52+
#22= IFCCARTESIANPOINT((0.,0.,0.));
53+
#30= IFCUNITASSIGNMENT((#31));
54+
#31= IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);
55+
#40= IFCLOCALPLACEMENT($,#21);
56+
${body}
57+
ENDSEC;
58+
END-ISO-10303-21;
59+
`;
60+
}
61+
62+
let tmp: string;
63+
const ctx: ToolContext = {
64+
registry: new InMemoryModelRegistry(),
65+
scope: fullScope(),
66+
progress: NOOP_PROGRESS,
67+
log: SILENT_LOGGER,
68+
signal: new AbortController().signal,
69+
config: { ...DEFAULT_CONFIG },
70+
};
71+
72+
async function load(id: string, content: string): Promise<void> {
73+
const path = join(tmp, `${id}.ifc`);
74+
await writeFile(path, content, 'utf-8');
75+
ctx.registry.add(await loadIfcModel(path, { modelId: id }));
76+
}
77+
78+
function tool(name: string) {
79+
const t = queryTools.find((x) => x.name === name);
80+
if (!t) throw new Error(`${name} not registered`);
81+
return t;
82+
}
83+
84+
async function call(name: string, input: Record<string, unknown>): Promise<CallToolResult> {
85+
const result = await tool(name).handler(input, ctx);
86+
expect(result.isError).toBeUndefined();
87+
return result;
88+
}
89+
90+
function text(result: CallToolResult): string {
91+
const block = result.content.find((c) => c.type === 'text') as { text: string } | undefined;
92+
if (!block) throw new Error('no text block');
93+
return block.text;
94+
}
95+
96+
// -- Fixture 1: shapeEntities / formatQueryResult -----------------------
97+
98+
// Every field distinct per wall, so a swap (e.g. type<->objectType, or
99+
// name<->description) changes the observed output instead of matching it.
100+
// WALC omits every optional attribute, so a "fill in a default" mutant on
101+
// the projection is observable too.
102+
const SHAPE_MODEL = step(`
103+
#41= IFCBUILDINGSTOREY('${guid('STOR')}',$,'L01',$,$,#40,$,$,.ELEMENT.,0.);
104+
#72= IFCWALL('${guid('WALA')}',$,'Name A','Desc A','Type A',#40,$,'tagA',$);
105+
#73= IFCWALL('${guid('WALB')}',$,'Name B','Desc B','Type B',#40,$,'tagB',$);
106+
#74= IFCWALL('${guid('WALC')}',$,$,$,$,#40,$,'tagC',$);
107+
`);
108+
109+
// -- Fixture 2: a >25-row page, to observe the "+N more" line -----------
110+
111+
function manyWallsBody(n: number): string {
112+
const lines: string[] = [`#41= IFCBUILDINGSTOREY('${guid('STOR')}',$,'L01',$,$,#40,$,$,.ELEMENT.,0.);`];
113+
for (let i = 0; i < n; i++) {
114+
const ref = 100 + i;
115+
const g = `W${i}`.padEnd(4, '0');
116+
lines.push(`#${ref}= IFCWALL('${guid(g)}',$,'Wall ${i}',$,$,#40,$,'tag${i}',$);`);
117+
}
118+
return lines.join('\n');
119+
}
120+
const MANY_MODEL = step(manyWallsBody(26));
121+
122+
// -- Fixture 3: count_entities group_by storey ---------------------------
123+
//
124+
// Insertion order A(1), B(3), C(2), D(2) is deliberately NOT the descending
125+
// order the sort must produce (B,3)(C or D,2)(C or D,2)(A,1). A reversed or
126+
// dropped comparator leaves the insertion order (or the exact opposite),
127+
// either of which fails the assertion below. C and D tie at count 2, so the
128+
// comparator's tie-breaking is exercised too (only ordering *between* the
129+
// count-3 and count-1 groups is asserted, since a `(a,b)=>b-a` comparator
130+
// is not required to be stable across the tie).
131+
const GROUP_MODEL = step(`
132+
#41= IFCBUILDINGSTOREY('${guid('STOA')}',$,'Storey A',$,$,#40,$,$,.ELEMENT.,0.);
133+
#42= IFCBUILDINGSTOREY('${guid('STOB')}',$,'Storey B',$,$,#40,$,$,.ELEMENT.,0.);
134+
#43= IFCBUILDINGSTOREY('${guid('STOC')}',$,'Storey C',$,$,#40,$,$,.ELEMENT.,0.);
135+
#44= IFCBUILDINGSTOREY('${guid('STOD')}',$,'Storey D',$,$,#40,$,$,.ELEMENT.,0.);
136+
#50= IFCBUILDING('${guid('BLDG')}',$,'B',$,$,#40,$,$,.ELEMENT.,$,$,$);
137+
#51= IFCRELAGGREGATES('${guid('AGG1')}',$,$,$,#1,(#50));
138+
#52= IFCRELAGGREGATES('${guid('AGG2')}',$,$,$,#50,(#41,#42,#43,#44));
139+
#60= IFCRELCONTAINEDINSPATIALSTRUCTURE('${guid('RC1')}',$,$,$,(#70),#41);
140+
#61= IFCRELCONTAINEDINSPATIALSTRUCTURE('${guid('RC2')}',$,$,$,(#71,#72,#73),#42);
141+
#62= IFCRELCONTAINEDINSPATIALSTRUCTURE('${guid('RC3')}',$,$,$,(#74,#75),#43);
142+
#63= IFCRELCONTAINEDINSPATIALSTRUCTURE('${guid('RC4')}',$,$,$,(#76,#77),#44);
143+
#70= IFCWALL('${guid('WA')}',$,'A',$,$,#40,$,'tA',$);
144+
#71= IFCWALL('${guid('WB1')}',$,'B1',$,$,#40,$,'tB1',$);
145+
#72= IFCWALL('${guid('WB2')}',$,'B2',$,$,#40,$,'tB2',$);
146+
#73= IFCWALL('${guid('WB3')}',$,'B3',$,$,#40,$,'tB3',$);
147+
#74= IFCWALL('${guid('WC1')}',$,'C1',$,$,#40,$,'tC1',$);
148+
#75= IFCWALL('${guid('WC2')}',$,'C2',$,$,#40,$,'tC2',$);
149+
#76= IFCWALL('${guid('WD1')}',$,'D1',$,$,#40,$,'tD1',$);
150+
#77= IFCWALL('${guid('WD2')}',$,'D2',$,$,#40,$,'tD2',$);
151+
`);
152+
153+
beforeAll(async () => {
154+
tmp = await mkdtemp(join(tmpdir(), 'ifc-lite-mcp-query-'));
155+
await load('shape', SHAPE_MODEL);
156+
await load('many', MANY_MODEL);
157+
await load('group', GROUP_MODEL);
158+
}, 60_000);
159+
160+
afterAll(async () => {
161+
await rm(tmp, { recursive: true, force: true });
162+
});
163+
164+
describe('query_entities → shapeEntities (default fields)', () => {
165+
it('carries every default field distinctly, not swapped or defaulted', async () => {
166+
const out = await call('query_entities', { model_id: 'shape', type: 'IfcWall', fields: [] });
167+
const entities = (out.structuredContent as { entities: Array<Record<string, unknown>> }).entities;
168+
const a = entities.find((e) => e.globalId === guid('WALA'));
169+
expect(a).toBeDefined();
170+
expect(a?.name).toBe('Name A');
171+
expect(a?.description).toBe('Desc A');
172+
expect(a?.objectType).toBe('Type A');
173+
expect(a?.type).toBe('IfcWall');
174+
expect(a?.expressId).toBe(72);
175+
expect(a?.modelId).toBe('shape');
176+
});
177+
178+
it('an entity with no optional attributes gets them as null/undefined, not filled in from another row', async () => {
179+
const out = await call('query_entities', { model_id: 'shape', type: 'IfcWall', fields: [] });
180+
const entities = (out.structuredContent as { entities: Array<Record<string, unknown>> }).entities;
181+
const c = entities.find((e) => e.globalId === guid('WALC'));
182+
expect(c).toBeDefined();
183+
expect(c?.name).toBeFalsy();
184+
expect(c?.description).toBeFalsy();
185+
expect(c?.objectType).toBeFalsy();
186+
});
187+
});
188+
189+
describe('query_entities → shapeEntities (explicit `fields` subset)', () => {
190+
it('includes only the requested field plus expressId — not neighbouring fields', async () => {
191+
const out = await call('query_entities', { model_id: 'shape', type: 'IfcWall', fields: ['name'] });
192+
const entities = (out.structuredContent as { entities: Array<Record<string, unknown>> }).entities;
193+
const a = entities.find((e) => e.expressId === 72) as Record<string, unknown>;
194+
expect(a.name).toBe('Name A');
195+
expect('type' in a).toBe(false);
196+
expect('globalId' in a).toBe(false);
197+
expect('description' in a).toBe(false);
198+
expect('objectType' in a).toBe(false);
199+
});
200+
201+
it('a different single field is carried through its own guard, not the neighbour’s', async () => {
202+
const out = await call('query_entities', { model_id: 'shape', type: 'IfcWall', fields: ['objectType'] });
203+
const entities = (out.structuredContent as { entities: Array<Record<string, unknown>> }).entities;
204+
const a = entities.find((e) => e.expressId === 72) as Record<string, unknown>;
205+
expect(a.objectType).toBe('Type A');
206+
expect('name' in a).toBe(false);
207+
expect('type' in a).toBe(false);
208+
});
209+
});
210+
211+
describe('formatQueryResult', () => {
212+
it('singular "entity" for exactly one match — the pluralization boundary', async () => {
213+
const out = await call('query_entities', { model_id: 'shape', type: 'IfcWall', property: { pset: 'x', name: 'y', op: 'exists' } });
214+
// No pset named 'x' exists, so nothing matches — 0 is plural ("entities").
215+
expect(text(out)).toMatch(/^Found 0 matching entities\./);
216+
});
217+
218+
it('singular "entity" really does trigger at count 1', async () => {
219+
const out = await call('query_entities', { model_id: 'shape', in_storey: guid('STOR'), limit: 1 });
220+
// Exact, like the 0- and 26-match cases above and below. This previously
221+
// read `/^Found \d+ matching entit(y|ies)/`, which matches BOTH renderings
222+
// — so the one test named for the pluralization boundary was the one test
223+
// that could not detect crossing it.
224+
//
225+
// The fixture yields exactly one match: with no `type` filter only product
226+
// types are candidates, `SHAPE_MODEL` declares no spatial-containment
227+
// relations, so `storey()` returns null for every wall and only the storey
228+
// itself (#41, the `guid('STOR')` this queries) resolves to itself.
229+
expect(text(out)).toMatch(/^Found 1 matching entity\./);
230+
// Filter down to a single concrete entity via express id round trip instead:
231+
const single = await call('get_entity', { model_id: 'shape', express_id: 72 });
232+
expect(single).toBeDefined();
233+
});
234+
235+
it('an entity without a Name omits the quoted name, not a stale one', async () => {
236+
const out = await call('query_entities', { model_id: 'shape', type: 'IfcWall' });
237+
const line = text(out).split('\n').find((l) => l.includes('#74'));
238+
expect(line).toBeDefined();
239+
expect(line).not.toMatch(/'/);
240+
expect(line).toContain(`GlobalId=${guid('WALC')}`);
241+
});
242+
243+
it('lists a GlobalId-bearing name-bearing row with both, quoted and tagged correctly', async () => {
244+
const out = await call('query_entities', { model_id: 'shape', type: 'IfcWall' });
245+
const line = text(out).split('\n').find((l) => l.includes('#72'));
246+
expect(line).toContain("'Name A'");
247+
expect(line).toContain(`GlobalId=${guid('WALA')}`);
248+
expect(line).toContain('IfcWall');
249+
});
250+
251+
it('a page over 25 rows lists exactly 25 and reports the true remainder — the +N-more boundary', async () => {
252+
const out = await call('query_entities', { model_id: 'many', type: 'IfcWall', limit: 1000 });
253+
const body = text(out);
254+
const lines = body.split('\n');
255+
// Header + 25 rows + one "+N more" line.
256+
expect(lines[0]).toMatch(/^Found 26 matching entities\./);
257+
const bulletLines = lines.slice(1).filter((l) => l.startsWith(' • Ifc'));
258+
expect(bulletLines).toHaveLength(25);
259+
const moreLine = lines[lines.length - 1];
260+
expect(moreLine).toBe(' • … +1 more in this page');
261+
});
262+
263+
it('at or under 25 rows lists them all with no "+N more" line', async () => {
264+
const out = await call('query_entities', { model_id: 'shape', type: 'IfcWall' });
265+
const body = text(out);
266+
expect(body).not.toContain('more in this page');
267+
});
268+
});
269+
270+
describe('count_entities → group_by sort', () => {
271+
it('groups by storey in descending count order, not insertion order or ascending', async () => {
272+
const out = await call('count_entities', { model_id: 'group', type: 'IfcWall', group_by: 'storey' });
273+
const groups = (out.structuredContent as { groups: Array<{ key: string; count: number }> }).groups;
274+
const counts = groups.map((g) => g.count);
275+
// Correct: [3, 2, 2, 1] (B first, A last). Insertion order was
276+
// [1, 3, 2, 2] (A, B, C, D) and a reversed comparator gives [1, 2, 2, 3].
277+
// Both are distinguishable from the correct result.
278+
expect(counts).toEqual([3, 2, 2, 1]);
279+
expect(groups[0].key).toBe('Storey B');
280+
expect(groups[groups.length - 1].key).toBe('Storey A');
281+
// The tied pair (C, D) is present in some order between the extremes.
282+
const tiedKeys = groups.slice(1, 3).map((g) => g.key).sort();
283+
expect(tiedKeys).toEqual(['Storey C', 'Storey D']);
284+
});
285+
286+
it('total across groups is the full count, not just the largest group', async () => {
287+
const out = await call('count_entities', { model_id: 'group', type: 'IfcWall', group_by: 'storey' });
288+
const structured = out.structuredContent as { total: number };
289+
expect(structured.total).toBe(8);
290+
});
291+
});

0 commit comments

Comments
 (0)