/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { describe, it } from 'node:test'; import assert from 'node:assert'; import { StringTable, EntityTableBuilder } from '@ifc-lite/data'; import type { IfcDataStore } from '@ifc-lite/parser'; import { evaluateFilterRules, evaluateFilterRulesFederated, __internal } from './filter-evaluate.js'; import { Rule } from './filter-rules.js'; interface Row { expressId: number; type: string; globalId: string; name: string; description?: string; objectType?: string; } function buildStore(rows: Row[]): IfcDataStore { const strings = new StringTable(); const builder = new EntityTableBuilder(rows.length, strings); for (const r of rows) { builder.add( r.expressId, r.type, r.globalId, r.name, r.description ?? '', r.objectType ?? '', false, false, ); } const entities = builder.build(); // Populate byType so the prefilter has something to chew on. STEP // type names are stored UPPERCASE in this index — match the parser. const byType = new Map(); for (const r of rows) { const key = r.type.toUpperCase(); let bucket = byType.get(key); if (!bucket) { bucket = []; byType.set(key, bucket); } bucket.push(r.expressId); } return { fileSize: 0, schemaVersion: 'IFC4', entityCount: rows.length, parseTime: 0, source: new Uint8Array(0), entityIndex: { byId: { ranges: new Uint32Array(0), index: new Map() }, byType }, strings, entities, properties: { count: 0 }, quantities: { count: 0 }, relationships: { count: 0 }, } as unknown as IfcDataStore; } const rows: Row[] = [ { expressId: 10, type: 'IFCWALL', globalId: '1abcdefghijklmnopqrstu', name: 'Wall-EXT-001' }, { expressId: 20, type: 'IFCWALL', globalId: '2abcdefghijklmnopqrstu', name: 'Wall-INT-002' }, { expressId: 30, type: 'IFCDOOR', globalId: '3abcdefghijklmnopqrstu', name: 'Door-A-201' }, { expressId: 40, type: 'IFCSLAB', globalId: '4abcdefghijklmnopqrstu', name: 'Slab-G-1' }, ]; describe('evaluateFilterRules — column-only rules', () => { it('IfcType IN narrows to walls', () => { const store = buildStore(rows); const out = evaluateFilterRules('m1', store, [Rule.ifcType(['IfcWall'])], 'AND'); assert.deepStrictEqual(out.map((r) => r.expressId).sort(), [10, 20]); }); it('IfcType NOT IN excludes walls', () => { const store = buildStore(rows); const out = evaluateFilterRules('m1', store, [Rule.ifcType(['IfcWall'], 'notIn')], 'AND'); assert.deepStrictEqual(out.map((r) => r.expressId).sort(), [30, 40]); }); it('Name contains is case-insensitive', () => { const store = buildStore(rows); const out = evaluateFilterRules('m1', store, [Rule.name('contains', 'EXT')], 'AND'); assert.deepStrictEqual(out.map((r) => r.expressId), [10]); }); it('AND combinator narrows; OR widens', () => { const store = buildStore(rows); const andOut = evaluateFilterRules('m1', store, [ Rule.ifcType(['IfcWall']), Rule.name('contains', 'EXT'), ], 'AND'); assert.deepStrictEqual(andOut.map((r) => r.expressId), [10]); const orOut = evaluateFilterRules('m1', store, [ Rule.ifcType(['IfcDoor']), Rule.name('contains', 'EXT'), ], 'OR'); assert.deepStrictEqual(orOut.map((r) => r.expressId).sort(), [10, 30]); }); it('respects candidateExpressIds (Tier-1 narrowing)', () => { const store = buildStore(rows); const out = evaluateFilterRules('m1', store, [Rule.ifcType(['IfcWall'])], 'AND', { candidateExpressIds: [20, 30, 40], }); assert.deepStrictEqual(out.map((r) => r.expressId), [20]); }); it('honours the limit option', () => { const store = buildStore(rows); const out = evaluateFilterRules('m1', store, [Rule.ifcType(['IfcWall'])], 'AND', { limit: 1 }); assert.strictEqual(out.length, 1); }); it('returns matching elements with model id and ifc type populated', () => { const store = buildStore(rows); const out = evaluateFilterRules('m1', store, [Rule.name('eq', 'Door-A-201')], 'AND'); assert.strictEqual(out.length, 1); assert.strictEqual(out[0].modelId, 'm1'); assert.strictEqual(out[0].ifcType, 'IfcDoor'); assert.strictEqual(out[0].globalId, '3abcdefghijklmnopqrstu'); }); }); describe('evaluateFilterRules — storey & predefinedType resolvers', () => { it('uses storeyNameOf when provided', () => { const store = buildStore(rows); const storeyByExpressId = new Map([[10, 'Level 1'], [20, 'Level 2'], [30, 'Level 1']]); const out = evaluateFilterRules('m1', store, [Rule.storey(['Level 1'])], 'AND', { storeyNameOf: (id) => storeyByExpressId.get(id) ?? '', }); assert.deepStrictEqual(out.map((r) => r.expressId).sort(), [10, 30]); }); it('uses predefinedTypeOf when provided', () => { const store = buildStore(rows); const ptByExpressId = new Map([[10, 'SOLIDWALL'], [20, 'PARTITIONING'], [30, 'DOOR']]); const out = evaluateFilterRules('m1', store, [ Rule.predefinedType(['SOLIDWALL']), ], 'AND', { predefinedTypeOf: (id) => ptByExpressId.get(id) ?? '' }); assert.deepStrictEqual(out.map((r) => r.expressId), [10]); }); }); describe('evaluateFilterRulesFederated', () => { it('merges results from multiple models', async () => { const a = buildStore(rows); const b = buildStore([ { expressId: 100, type: 'IFCWALL', globalId: 'aabcdefghijklmnopqrstu', name: 'Wall-B-1' }, ]); const out = await evaluateFilterRulesFederated( [{ id: 'a', store: a }, { id: 'b', store: b }], [Rule.ifcType(['IfcWall'])], 'AND', ); assert.strictEqual(out.length, 3); const modelIds = new Set(out.map((r) => r.modelId)); assert.deepStrictEqual([...modelIds].sort(), ['a', 'b']); }); it('caps total across federated models', async () => { const a = buildStore(rows); const b = buildStore(rows.map((r) => ({ ...r, expressId: r.expressId + 1000 }))); const out = await evaluateFilterRulesFederated( [{ id: 'a', store: a }, { id: 'b', store: b }], [Rule.ifcType(['IfcWall'])], 'AND', { limit: 3 }, ); assert.strictEqual(out.length, 3); }); }); describe('flattenPsets / matchPropertyRule', () => { it('stringifies booleans and numbers consistently', () => { const flat = __internal.flattenPsets([ { name: 'Pset_WallCommon', properties: [ { name: 'IsExternal', type: 0, value: true }, { name: 'ThermalTransmittance', type: 0, value: 0.24 }, { name: 'Reference', type: 0, value: 'EXT-A' }, { name: 'Empty', type: 0, value: null }, ], }, ]); assert.deepStrictEqual(flat.map((r) => r.value), ['true', '0.24', 'EXT-A', '']); }); it('matches isSet / isNotSet by (set, prop) presence only', () => { const flat = __internal.flattenPsets([ { name: 'Pset_WallCommon', properties: [{ name: 'IsExternal', type: 0, value: true }] }, ]); assert.strictEqual( __internal.matchPropertyRule(Rule.property('Pset_WallCommon', 'IsExternal', 'isSet', ''), flat), true, ); assert.strictEqual( __internal.matchPropertyRule(Rule.property('Pset_WallCommon', 'Missing', 'isSet', ''), flat), false, ); assert.strictEqual( __internal.matchPropertyRule(Rule.property('Pset_WallCommon', 'Missing', 'isNotSet', ''), flat), true, ); }); it('contains is case-insensitive over the stringified value', () => { const flat = __internal.flattenPsets([ { name: 'Pset_WallCommon', properties: [{ name: 'Reference', type: 0, value: 'WALL-EXT-A' }] }, ]); assert.strictEqual( __internal.matchPropertyRule( Rule.property('Pset_WallCommon', 'Reference', 'contains', 'ext'), flat, ), true, ); }); it('numeric value ops parse both sides; NaN fails closed', () => { const flat = __internal.flattenPsets([ { name: 'Pset_WallCommon', properties: [{ name: 'U', type: 0, value: 0.24 }] }, ]); assert.strictEqual( __internal.matchPropertyRule(Rule.property('Pset_WallCommon', 'U', 'lt', '0.3'), flat), true, ); assert.strictEqual( __internal.matchPropertyRule(Rule.property('Pset_WallCommon', 'U', 'gt', 'abc'), flat), false, ); }); }); describe('matchQuantityRule', () => { it('matches by (set, qty) with numeric op', () => { const flat = __internal.flattenQtys([ { name: 'Qto_WallBaseQuantities', quantities: [{ name: 'NetSideArea', type: 0, value: 12.5 }] }, ]); assert.strictEqual( __internal.matchQuantityRule( Rule.quantity('Qto_WallBaseQuantities', 'NetSideArea', 'gt', 10), flat, ), true, ); assert.strictEqual( __internal.matchQuantityRule( Rule.quantity('Qto_WallBaseQuantities', 'Missing', 'gt', 10), flat, ), false, ); }); }); describe('materialNamesOf', () => { it('collects top-level, layer, constituent, profile, and list names', () => { const names = __internal.materialNamesOf({ type: 'MaterialLayerSet', name: 'Wall Buildup', layers: [ { materialName: 'Concrete C30/37' }, { materialName: 'Rigid Insulation', name: 'Insulation Layer' }, ], materials: [{ name: 'Steel S355' }], }); assert.deepStrictEqual(names, [ 'Wall Buildup', 'Concrete C30/37', 'Rigid Insulation', 'Insulation Layer', 'Steel S355', ]); }); it('returns [] for a null MaterialInfo (no association)', () => { assert.deepStrictEqual(__internal.materialNamesOf(null), []); }); }); describe('matchClassificationRule', () => { const refs = [ { system: 'Uniclass 2015', identification: 'Pr_60_10_32', name: 'External wall' }, { system: 'OmniClass', identification: '23-13 11 11', name: 'Walls' }, ]; it('isSet / isNotSet check presence, optionally scoped by system', () => { assert.strictEqual(__internal.matchClassificationRule(Rule.classification('', 'isSet', ''), refs), true); assert.strictEqual(__internal.matchClassificationRule(Rule.classification('OmniClass', 'isSet', ''), refs), true); assert.strictEqual(__internal.matchClassificationRule(Rule.classification('SfB', 'isSet', ''), refs), false); assert.strictEqual(__internal.matchClassificationRule(Rule.classification('SfB', 'isNotSet', ''), refs), true); assert.strictEqual(__internal.matchClassificationRule(Rule.classification('', 'isNotSet', ''), []), true); }); it('value ops match code (identification) OR name', () => { assert.strictEqual(__internal.matchClassificationRule(Rule.classification('', 'contains', 'Pr_60'), refs), true); assert.strictEqual(__internal.matchClassificationRule(Rule.classification('', 'contains', 'external'), refs), true); assert.strictEqual(__internal.matchClassificationRule(Rule.classification('', 'eq', 'Pr_60_10_32'), refs), true); assert.strictEqual(__internal.matchClassificationRule(Rule.classification('', 'contains', 'Ss_'), refs), false); }); it('system scope excludes refs from other systems', () => { // Pr_60 only exists in the Uniclass ref — scoping to OmniClass misses it. assert.strictEqual(__internal.matchClassificationRule(Rule.classification('OmniClass', 'contains', 'Pr_60'), refs), false); assert.strictEqual(__internal.matchClassificationRule(Rule.classification('OmniClass', 'contains', '23-13'), refs), true); }); }); describe('elevationOf + elevation rule', () => { function withHierarchy(store: IfcDataStore): IfcDataStore { // elementToStorey: 10,20 → storey 100 (z=0); 30 → storey 200 (z=3.5). // 40 is unplaced (no storey) → elevation null → never matches. (store as unknown as { spatialHierarchy: unknown }).spatialHierarchy = { elementToStorey: new Map([[10, 100], [20, 100], [30, 200]]), storeyElevations: new Map([[100, 0], [200, 3.5]]), }; return store; } it('resolves elevation from the element’s storey, null when unplaced', () => { const store = withHierarchy(buildStore(rows)); assert.strictEqual(__internal.elevationOf(store, 10), 0); assert.strictEqual(__internal.elevationOf(store, 30), 3.5); assert.strictEqual(__internal.elevationOf(store, 40), null); }); it('elevation > 3 matches only elements on the high storey', () => { const store = withHierarchy(buildStore(rows)); const out = evaluateFilterRules('m1', store, [Rule.elevation('gt', 3)], 'AND'); assert.deepStrictEqual(out.map((r) => r.expressId), [30]); }); it('elevation rule excludes unplaced elements even with lte', () => { const store = withHierarchy(buildStore(rows)); const out = evaluateFilterRules('m1', store, [Rule.elevation('lte', 100)], 'AND'); // 10, 20 (z=0) and 30 (z=3.5) qualify; 40 (unplaced) is excluded. assert.deepStrictEqual(out.map((r) => r.expressId).sort(), [10, 20, 30]); }); }); describe('evaluateFilterRulesFederated — per-model candidate narrowing', () => { it('candidateExpressIdsByModel narrows each model independently', async () => { const a = buildStore(rows); const b = buildStore([ { expressId: 100, type: 'IFCWALL', globalId: 'aabcdefghijklmnopqrstu', name: 'Wall-B-1' }, { expressId: 101, type: 'IFCDOOR', globalId: 'babcdefghijklmnopqrstu', name: 'Door-B-2' }, ]); const candidatesByModel = new Map>([ ['a', [10]], // only Wall-EXT-001 from a ['b', [101]], // only Door-B-2 from b ]); const out = await evaluateFilterRulesFederated( [{ id: 'a', store: a }, { id: 'b', store: b }], [Rule.ifcType(['IfcWall', 'IfcDoor'])], 'AND', { candidateExpressIdsByModel: candidatesByModel }, ); // Two narrow hits — one wall from `a`, one door from `b`. assert.deepStrictEqual( out.map((r) => `${r.modelId}:${r.expressId}`).sort(), ['a:10', 'b:101'], ); }); it('an empty candidate set for a model yields zero results from that model (intersection semantics)', async () => { // Codex P1 invariant: a misspelt text query that produced zero // Tier-0/Tier-1 hits must NOT degrade to a full-table scan when // the user has structured rules. Empty Iterable per model ⇒ no rows. const a = buildStore(rows); const candidatesByModel = new Map>([['a', []]]); const out = await evaluateFilterRulesFederated( [{ id: 'a', store: a }], [Rule.ifcType(['IfcWall'])], 'AND', { candidateExpressIdsByModel: candidatesByModel }, ); assert.deepStrictEqual(out, []); }); it('omitting the map keeps the legacy full-scan behaviour', async () => { const a = buildStore(rows); const out = await evaluateFilterRulesFederated( [{ id: 'a', store: a }], [Rule.ifcType(['IfcWall'])], 'AND', ); assert.deepStrictEqual(out.map((r) => r.expressId).sort(), [10, 20]); }); it('storeyNameOf / predefinedTypeOf flow through the federated wrapper', async () => { const a = buildStore(rows); const out = await evaluateFilterRulesFederated( [{ id: 'a', store: a }], [Rule.storey(['Level 1'])], 'AND', { storeyNameOf: (id) => (id === 10 ? 'Level 1' : '') }, ); assert.deepStrictEqual(out.map((r) => r.expressId), [10]); }); }); describe('evaluateFilterRules — empty rules', () => { it('returns [] when rules is empty (matches Rust behaviour)', () => { const store = buildStore(rows); assert.deepStrictEqual(evaluateFilterRules('m1', store, [], 'AND'), []); }); }); describe('orderRulesByCost — cheap-first reordering', () => { const order = __internal.orderRulesByCost; it('lifts cheap kinds (ifcType, name, storey) before expensive (property, quantity)', () => { const reordered = order([ Rule.property('Pset_X', 'P', 'eq', 'v'), Rule.ifcType(['IfcWall']), Rule.quantity('Qto_X', 'Q', 'gt', 1), Rule.name('contains', 'wall'), ]); // Equal-cost rules retain their authored order — `ifcType` before // `name` because cost(ifcType)=0 < cost(name)=2. assert.deepStrictEqual(reordered.map((r) => r.kind), ['ifcType', 'name', 'property', 'quantity']); }); it('is a stable sort — two equal-cost rules keep their input order', () => { const a = Rule.name('contains', 'a'); const b = Rule.name('contains', 'b'); const reordered = order([a, b]); assert.strictEqual(reordered[0], a); assert.strictEqual(reordered[1], b); }); it('does not mutate the input array', () => { const input = [ Rule.property('Pset_X', 'P', 'eq', 'v'), Rule.ifcType(['IfcWall']), ]; const before = input.map((r) => r.kind); void order(input); assert.deepStrictEqual(input.map((r) => r.kind), before); }); }); describe('selectIterationSource — index prefilter (AND + op:in)', () => { const select = __internal.selectIterationSource; it('AND + ifcType op:in narrows to byType bucket(s)', () => { const store = buildStore(rows); const source = select(store, [Rule.ifcType(['IfcWall'])], 'AND', undefined); const ids = Array.from(source as Iterable); // Bucket holds only the two walls — not the door / slab. assert.deepStrictEqual(ids.sort(), [10, 20]); }); it('AND + multiple narrowing rules picks the smallest bucket', () => { const store = buildStore(rows); // ifcType {IfcWall} = 2 entries; ifcType {IfcDoor} = 1 entry. // The smaller of the two should be chosen as the iteration source. const source = select( store, [Rule.ifcType(['IfcWall']), Rule.ifcType(['IfcDoor'])], 'AND', undefined, ); const ids = Array.from(source as Iterable); assert.deepStrictEqual(ids, [30]); }); it('OR combinator skips the prefilter and falls back to the full table', () => { const store = buildStore(rows); const source = select(store, [Rule.ifcType(['IfcWall'])], 'OR', undefined); const ids = Array.from(source as Iterable); // Generator over the full expressId column — all four entities. assert.deepStrictEqual(ids.sort(), [10, 20, 30, 40]); }); it('notIn ops skip the prefilter (inverting a small set is still big)', () => { const store = buildStore(rows); const source = select(store, [Rule.ifcType(['IfcWall'], 'notIn')], 'AND', undefined); const ids = Array.from(source as Iterable); // No bucket suggested → full-table iteration. assert.strictEqual(ids.length, 4); }); it('explicit candidateExpressIds wins over the prefilter', () => { const store = buildStore(rows); const source = select(store, [Rule.ifcType(['IfcWall'])], 'AND', [99]); assert.deepStrictEqual(Array.from(source as Iterable), [99]); }); }); describe('evaluateFilterRulesFederated — large-model scaling', () => { // Synthetic 50K-entity store: 200 walls in a sea of slabs. The // prefilter MUST narrow the scan to the wall bucket (≤ 200 entities) // rather than walking the full table — otherwise huge models would // freeze the main thread on Fast Run, which is the AGENTS.md §2 trap // this whole module is built to avoid. it('AND + ifcType prefilter scans only the bucket on a 50K-entity model', async () => { const big: Row[] = []; for (let i = 0; i < 50_000; i++) { big.push({ expressId: i + 1, type: i % 250 === 0 ? 'IFCWALL' : 'IFCSLAB', globalId: `${String(i).padStart(22, '0')}`.slice(0, 22), name: `entity-${i}`, }); } const store = buildStore(big); let lastTotal = 0; const out = await evaluateFilterRulesFederated( [{ id: 'm', store }], [Rule.ifcType(['IfcWall'])], 'AND', { chunkSize: 1_000, onProgress: (_scanned, total) => { lastTotal = total; }, }, ); // 50_000 / 250 = 200 walls. assert.strictEqual(out.length, 200); // Progress total is the SCAN size (the bucket, not the full table). // Without the prefilter this would have been 50_000. assert.strictEqual(lastTotal, 200); }); it('OR mode falls back to full scan (prefilter is unsafe under OR)', async () => { const big: Row[] = []; for (let i = 0; i < 1_000; i++) { big.push({ expressId: i + 1, type: i % 100 === 0 ? 'IFCWALL' : 'IFCSLAB', globalId: `${String(i).padStart(22, '0')}`.slice(0, 22), name: i === 0 ? 'special' : `entity-${i}`, }); } const store = buildStore(big); let lastTotal = 0; const out = await evaluateFilterRulesFederated( [{ id: 'm', store }], [Rule.ifcType(['IfcWall']), Rule.name('eq', 'special')], 'OR', { chunkSize: 100, onProgress: (_scanned, total) => { lastTotal = total; }, }, ); // 10 walls + 1 special = 10 results (the 'special' wall is also in // the wall bucket, so it counts once via dedupe of the OR — but // the evaluator doesn't dedupe; it just scans, which produces 10 // hits since 'special' IS one of the walls). Either way the test // verifies OR scans the full table. assert.strictEqual(out.length, 10); assert.strictEqual(lastTotal, 1_000); }); }); describe('evaluateFilterRulesFederated — async chunking, abort, progress', () => { it('reports onProgress with monotonically growing scanned counter', async () => { const store = buildStore(rows); const ticks: Array<{ scanned: number; total: number }> = []; await evaluateFilterRulesFederated( [{ id: 'm', store }], [Rule.ifcType(['IfcWall'])], 'AND', { chunkSize: 1, onProgress: (scanned, total) => { ticks.push({ scanned, total }); }, }, ); // First tick is the initial 0/total emission; subsequent ticks // monotonically grow; final tick equals total. assert.ok(ticks.length >= 2, `expected ≥2 progress ticks, got ${ticks.length}`); assert.strictEqual(ticks[0].scanned, 0); for (let i = 1; i < ticks.length; i++) { assert.ok( ticks[i].scanned >= ticks[i - 1].scanned, `progress regressed from ${ticks[i - 1].scanned} → ${ticks[i].scanned}`, ); } }); it('honours AbortSignal at chunk boundaries', async () => { const store = buildStore(rows); const controller = new AbortController(); // Abort before the first await — the evaluator's chunk-boundary // check fires after the first chunk completes. controller.abort(); let threwAbort = false; try { await evaluateFilterRulesFederated( [{ id: 'm', store }], [Rule.ifcType(['IfcWall'])], 'AND', { chunkSize: 1, signal: controller.signal }, ); } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') threwAbort = true; else throw err; } assert.ok(threwAbort, 'expected AbortError when signal is pre-aborted'); }); it('limit short-circuits the run before scanning the rest', async () => { const store = buildStore(rows); let lastScanned = 0; const out = await evaluateFilterRulesFederated( [{ id: 'm', store }], [Rule.ifcType(['IfcWall'])], 'AND', { limit: 1, chunkSize: 1, onProgress: (scanned) => { lastScanned = scanned; }, }, ); assert.strictEqual(out.length, 1); // We should have stopped before scanning all four entities. assert.ok(lastScanned < rows.length, `expected early termination, scanned ${lastScanned}`); }); });