/* 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, PropertyTableBuilder, QuantityTableBuilder, PropertyValueType, QuantityType, IfcTypeEnum, } from '@ifc-lite/data'; import type { SpatialHierarchy } from '@ifc-lite/data'; import type { IfcDataStore } from '@ifc-lite/parser'; import { discoverFilterSchema, discoverPropertyAndQuantitySchema } from './filter-schema.js'; interface EntityRow { expressId: number; type: string; globalId: string; name: string; } /** * Build an in-memory IfcDataStore that exercises the schema discovery * fallbacks: empty `source` so the on-demand extractors short-circuit * to the pre-computed PropertyTable / QuantityTable lookup. */ function buildStore(args: { entities: EntityRow[]; storeys?: Array<{ id: number; name: string; elevation?: number }>; elementToStorey?: Array<[number, number]>; psetRows?: Array<{ entityId: number; psetName: string; propName: string }>; qtoRows?: Array<{ entityId: number; qsetName: string; quantityName: string }>; }): IfcDataStore { const strings = new StringTable(); const builder = new EntityTableBuilder(args.entities.length, strings); for (const r of args.entities) { builder.add(r.expressId, r.type, r.globalId, r.name, '', '', false, false); } const entities = builder.build(); const propertyBuilder = new PropertyTableBuilder(strings); for (const r of args.psetRows ?? []) { propertyBuilder.add({ entityId: r.entityId, psetName: r.psetName, psetGlobalId: '', propName: r.propName, propType: PropertyValueType.String, value: '', }); } const properties = propertyBuilder.build(); const quantityBuilder = new QuantityTableBuilder(strings); for (const r of args.qtoRows ?? []) { quantityBuilder.add({ entityId: r.entityId, qsetName: r.qsetName, quantityName: r.quantityName, quantityType: QuantityType.Length, value: 0, }); } const quantities = quantityBuilder.build(); const byType = new Map(); for (const r of args.entities) { const upper = r.type.toUpperCase(); let bucket = byType.get(upper); if (!bucket) { bucket = []; byType.set(upper, bucket); } bucket.push(r.expressId); } let spatialHierarchy: SpatialHierarchy | undefined; if (args.storeys && args.storeys.length > 0) { const byStorey = new Map(); const storeyElevations = new Map(); const elementToStorey = new Map(args.elementToStorey ?? []); for (const s of args.storeys) { byStorey.set(s.id, []); if (s.elevation !== undefined) storeyElevations.set(s.id, s.elevation); } spatialHierarchy = { project: { expressId: 1, type: IfcTypeEnum.IfcProject, name: '', children: [], elements: [] }, byStorey, byBuilding: new Map(), bySite: new Map(), bySpace: new Map(), storeyElevations, storeyHeights: new Map(), elementToStorey, getStoreyElements: () => [], getStoreyByElevation: () => null, getContainingSpace: () => null, getPath: () => [], }; } // Build expressId → entityId-list map for properties / quantities so // the on-demand maps have the right shape. const onDemandPropertyMap = new Map(); for (const r of args.psetRows ?? []) { const list = onDemandPropertyMap.get(r.entityId) ?? []; list.push(r.entityId); // dummy pset id; the value is unused on the // PropertyTable fallback path because source='' onDemandPropertyMap.set(r.entityId, list); } const onDemandQuantityMap = new Map(); for (const r of args.qtoRows ?? []) { const list = onDemandQuantityMap.get(r.entityId) ?? []; list.push(r.entityId); onDemandQuantityMap.set(r.entityId, list); } return { fileSize: 0, schemaVersion: 'IFC4', entityCount: args.entities.length, parseTime: 0, source: new Uint8Array(0), entityIndex: { byId: { ranges: new Uint32Array(0), index: new Map() }, byType }, strings, entities, properties, quantities, relationships: { count: 0 } as unknown as IfcDataStore['relationships'], spatialHierarchy, onDemandPropertyMap, onDemandQuantityMap, } as unknown as IfcDataStore; } describe('discoverFilterSchema — basic pass', () => { it('returns an empty schema when no models / no spatial hierarchy', () => { const store = buildStore({ entities: [] }); const schema = discoverFilterSchema(store); assert.deepStrictEqual(schema.storeys, []); assert.deepStrictEqual(schema.ifcTypes, []); }); it('collects unique IFC types in canonical PascalCase, sorted', () => { const store = buildStore({ entities: [ { expressId: 1, type: 'IFCWALL', globalId: '', name: '' }, { expressId: 2, type: 'IFCWALL', globalId: '', name: '' }, { expressId: 3, type: 'IFCDOOR', globalId: '', name: '' }, { expressId: 4, type: 'IFCWINDOW', globalId: '', name: '' }, ], }); const schema = discoverFilterSchema(store); assert.deepStrictEqual(schema.ifcTypes, ['IfcDoor', 'IfcWall', 'IfcWindow']); }); it('collects storeys with elevations sorted by name, deduped', () => { const store = buildStore({ entities: [ { expressId: 100, type: 'IFCBUILDINGSTOREY', globalId: '', name: 'Level 1' }, { expressId: 101, type: 'IFCBUILDINGSTOREY', globalId: '', name: 'Level 2' }, { expressId: 102, type: 'IFCBUILDINGSTOREY', globalId: '', name: 'Level 1' /* duplicate name */ }, ], storeys: [ { id: 100, name: 'Level 1', elevation: 0.0 }, { id: 101, name: 'Level 2', elevation: 3.0 }, { id: 102, name: 'Level 1', elevation: 0.0 }, ], }); const schema = discoverFilterSchema(store); // Two unique names; "Level 1" is kept once. assert.strictEqual(schema.storeys.length, 2); assert.deepStrictEqual(schema.storeys.map(([n]) => n), ['Level 1', 'Level 2']); }); it('treats missing elevation as null', () => { const store = buildStore({ entities: [{ expressId: 10, type: 'IFCBUILDINGSTOREY', globalId: '', name: 'Roof' }], storeys: [{ id: 10, name: 'Roof' /* no elevation */ }], }); const schema = discoverFilterSchema(store); assert.deepStrictEqual(schema.storeys, [['Roof', null]]); }); }); describe('discoverPropertyAndQuantitySchema — fallback pass via PropertyTable', () => { it('returns empty schema when no on-demand maps', () => { const store = buildStore({ entities: [{ expressId: 1, type: 'IFCWALL', globalId: '', name: '' }] }); // Strip the on-demand maps for the test. delete (store as { onDemandPropertyMap?: unknown }).onDemandPropertyMap; delete (store as { onDemandQuantityMap?: unknown }).onDemandQuantityMap; const schema = discoverPropertyAndQuantitySchema(store); assert.deepStrictEqual(schema.psets, []); assert.deepStrictEqual(schema.qtos, []); }); it('harvests pset → property names from the on-demand iteration', () => { const store = buildStore({ entities: [ { expressId: 1, type: 'IFCWALL', globalId: '', name: '' }, { expressId: 2, type: 'IFCDOOR', globalId: '', name: '' }, ], psetRows: [ { entityId: 1, psetName: 'Pset_WallCommon', propName: 'IsExternal' }, { entityId: 1, psetName: 'Pset_WallCommon', propName: 'LoadBearing' }, { entityId: 2, psetName: 'Pset_DoorCommon', propName: 'FireRating' }, ], }); const schema = discoverPropertyAndQuantitySchema(store); assert.deepStrictEqual(schema.psets.map(([n]) => n).sort(), ['Pset_DoorCommon', 'Pset_WallCommon']); const wall = schema.psets.find(([n]) => n === 'Pset_WallCommon'); assert.deepStrictEqual(wall?.[1].sort(), ['IsExternal', 'LoadBearing']); }); it('harvests qto → quantity names with empty unit', () => { const store = buildStore({ entities: [{ expressId: 1, type: 'IFCWALL', globalId: '', name: '' }], qtoRows: [ { entityId: 1, qsetName: 'Qto_WallBaseQuantities', quantityName: 'NetSideArea' }, { entityId: 1, qsetName: 'Qto_WallBaseQuantities', quantityName: 'GrossVolume' }, ], }); const schema = discoverPropertyAndQuantitySchema(store); assert.strictEqual(schema.qtos.length, 1); const [qsetName, quantities] = schema.qtos[0]; assert.strictEqual(qsetName, 'Qto_WallBaseQuantities'); assert.deepStrictEqual(quantities.map(([n]) => n).sort(), ['GrossVolume', 'NetSideArea']); // Unit is "" until on-demand extractors materialise it. assert.ok(quantities.every(([, unit]) => unit === '')); }); });