|
| 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