diff --git a/.changeset/query-oftype-unknown-typo.md b/.changeset/query-oftype-unknown-typo.md new file mode 100644 index 000000000..32ecc1140 --- /dev/null +++ b/.changeset/query-oftype-unknown-typo.md @@ -0,0 +1,24 @@ +--- +"@ifc-lite/query": major +--- + +**Breaking:** `IfcQuery.ofType()` now throws for a type string that is not an IFC entity name, instead of silently querying the `Unknown` bucket. + +`ofType()` maps each type string through `IfcTypeEnumFromString`, which falls back to `IfcTypeEnum.Unknown` for any name it does not recognize. A typo — `ofType('IfcWal')` — therefore returned every entity whose type the store could not classify: neither the caller's walls nor an empty result, but some other, unrelated set of entities. `ofType()` now rejects such a string with an error naming it. + +What still works unchanged: + +- **Standard IFC types that this build's enum table does not map.** `TYPE_STRING_TO_ENUM` (`@ifc-lite/data`) is a curated subset of IFC, so standard buildingSMART types such as `IfcChiller`, `IfcActuator`, `IfcElectricAppliance` — and IFC2X3's `IfcDoorStyle`, `IfcWindowStyle` and `IfcElectricalDistributionPoint` — resolve to `Unknown`. These are **not** rejected: they keep falling through to the `Unknown` bucket exactly as before, which is the only representation this build has for them and which answers the query correctly in a file whose unclassified entities are of that type. + + The oracle deciding this is `isKnownType()` (`@ifc-lite/parser`), the predicate that already guards `@ifc-lite/sdk`'s `addEntity`: the bundled **IFC2X3 + IFC4 + IFC4X3** schema union, minus EXPRESS defined types (`IfcLengthMeasure`, `IfcArcIndex`), with the IFC4_ADD2_TC1 codegen pin as a fallback, plus the parser's alias table for IFC2X3 leaves the bundled EXPRESS exports omit. Reusing it rather than adding a second name table keeps one source of truth for "is this a real IFC class". The suite asserts the coverage exhaustively — every entity in `SCHEMA_REGISTRY` and in all three per-version tables must pass `ofType()` — rather than by sampling names. +- **The `Unknown` bucket itself**, still reachable by passing the literal string `'Unknown'`. + +Depends on #3069 (`fix(codegen,parser): isKnownEntity must not accept Object.prototype members`), which must land first. `isKnownEntity()` asked `name in SCHEMA_REGISTRY.entities`, and `in` walks the prototype chain, so every `Object.prototype` member name — `constructor`, `toString`, `valueOf`, `hasOwnProperty`, `isPrototypeOf`, `__proto__` — answered `true` and reached `isKnownType()`. Without that fix `ofType('constructor')` passes this guard and returns the `Unknown` bucket, and the suite here asserts it does not. The fix itself belongs in the codegen template that emits the registry, which is what #3069 changes; this branch only relies on it. + +Surrounding whitespace is trimmed once, and the trimmed name feeds both the enum lookup and the acceptance check. `IfcTypeEnumFromString` only uppercases, so before this a padded `ofType(' IfcWall ')` missed the enum table and resolved to `Unknown` while the check — which did trim — found `IfcWall` known and let it through: the query then ran against the `Unknown` bucket and returned entities that are not walls, with no error. For a name with no surrounding whitespace the trim is the identity, so nothing that resolved correctly before resolves differently now. + +What breaks: a call passing a name that is not an IFC entity name in any of those schemas — a typo, or a genuine vendor-specific type name — previously returned an `EntityQuery` over the `Unknown` bucket and now throws. Callers relying on a vendor-specific name to reach unclassified entities must pass `'Unknown'` instead. Hence the major bump: this is a behaviour change on a published SDK export, not a bug fix that is invisible to correct callers. + +The error text says which schemas were searched rather than assuming a misspelling, because a rejected name may well be spelled correctly: + +> `ofType(): "IfcWal" is not an entity name in any IFC schema this build reads (IFC2X3, IFC4, IFC4X3). Check the spelling; for a vendor-specific type name, pass 'Unknown' to query entities whose type could not be classified.` diff --git a/packages/parser/src/ifc-schema.ts b/packages/parser/src/ifc-schema.ts index adbb6d354..e18ac06f5 100644 --- a/packages/parser/src/ifc-schema.ts +++ b/packages/parser/src/ifc-schema.ts @@ -170,7 +170,12 @@ export function getAttributeNamesAcrossSchemas(type: string): string[] { * * Still a real guard, not a pass-through: a typo (`IfcWal`), a vendor extension * and an EXPRESS defined type (`IfcLengthMeasure`, `IfcArcIndex`) are all - * rejected. + * rejected. So are `Object.prototype` member names (`constructor`, `toString`, + * `__proto__`, ...): the union lookup is a `Map`, and the pin fallback's + * own-property test — added in #3063/#3069, replacing an `in` that walked the + * prototype chain and answered `true` for every one of them — keeps the second + * lookup from re-admitting them. Callers of this predicate (`ofType()`, + * `addEntity`) rely on that; the guard is only as good as `isKnownEntity`. * * Known-ness, not instantiability: abstract supertypes (`IfcProduct`, * `IfcRoot`) answer `true`, as they always have — that is a different diff --git a/packages/parser/test/known-type-across-schemas.test.ts b/packages/parser/test/known-type-across-schemas.test.ts index b6a71ed6a..8ce684449 100644 --- a/packages/parser/test/known-type-across-schemas.test.ts +++ b/packages/parser/test/known-type-across-schemas.test.ts @@ -144,6 +144,43 @@ describe('isKnownType across the bundled schema union (#2003)', () => { } }); + it('rejects Object.prototype member names', () => { + // The union lookup is a `Map`, but the pin fallback (`isKnownEntity`) used + // `name in SCHEMA_REGISTRY.entities` — and `in` walks the prototype chain, + // so every member of `Object.prototype` answered `true`. `ofType()` and + // `addEntity` both key on this predicate, so a query for `'constructor'` + // passed the guard and silently returned the Unknown bucket. + // + // Structural, not a denylist: the fix is `Object.hasOwn` in the codegen + // template (#3063/#3069, not this branch), so this list is a sample of the + // class of names, not the definition of it - and these cases only pass + // once #3069 has landed. `NotAThing` rides along as the control — a plain unknown name has always been + // rejected, and must stay rejected. + for (const type of [ + 'constructor', + 'toString', + 'valueOf', + 'hasOwnProperty', + '__proto__', + 'isPrototypeOf', + 'propertyIsEnumerable', + 'toLocaleString', + 'NotAThing', + ]) { + expect(isKnownType(type), type).toBe(false); + } + }); + + it('does not hand back an Object.prototype member as entity metadata', () => { + // `getEntityMetadata` indexed the same object literal, so `'toString'` + // resolved to `Object.prototype.toString` — a `Function` returned under + // the `EntityMetadata` type. Fixed in #3069; pinned here because this is + // the package whose exported guards the defect reached. + for (const type of ['constructor', 'toString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf']) { + expect(getEntityMetadata(type), type).toBeUndefined(); + } + }); + it('rejects EXPRESS defined types that ride along in the bundled entity tables', () => { // The `ENTITIES_*` tables carry `IfcLengthMeasure`, `IfcBoolean` and 130 // other defined types as rows. They are not instantiable, the pin has diff --git a/packages/query/src/ifc-query.ts b/packages/query/src/ifc-query.ts index b8e796551..aea7e8bc1 100644 --- a/packages/query/src/ifc-query.ts +++ b/packages/query/src/ifc-query.ts @@ -6,8 +6,12 @@ * Main query interface - provides multiple access patterns */ -import type { IfcDataStore } from '@ifc-lite/parser'; -import { IfcTypeEnumFromString, type SpatialHierarchy } from '@ifc-lite/data'; +import { isKnownType, resolveEntityNameAlias, type IfcDataStore } from '@ifc-lite/parser'; +import { + IfcTypeEnum, + IfcTypeEnumFromString, + type SpatialHierarchy, +} from '@ifc-lite/data'; import { EntityQuery } from './entity-query.js'; import { EntityNode } from './entity-node.js'; import { DuckDBIntegration, type SQLResult } from './duckdb-integration.js'; @@ -82,7 +86,75 @@ export class IfcQuery { } ofType(...types: string[]): EntityQuery { - const typeEnums = types.map(t => IfcTypeEnumFromString(t)); + // `IfcTypeEnumFromString` falls back to `IfcTypeEnum.Unknown` for any name + // it does not recognize. That fallback conflates two very different cases: + // + // 1. A typo (`ofType('IfcWal')`). `IfcWal` is not an IFC entity name in + // any schema, so the caller can only have meant `IfcWall`. Left + // unchecked the query silently returns the Unknown bucket - every + // entity the store itself could not classify - which is neither the + // caller's wall nor an empty result, but some other, unrelated set of + // entities. + // + // 2. A real IFC entity name that `TYPE_STRING_TO_ENUM` (data/types.ts) + // simply has no entry for. That table is a curated subset, so standard + // types such as `IfcChiller` and `IfcActuator` - and IFC2X3's + // `IfcDoorStyle` and `IfcWindowStyle`, which is how 2X3 files carry + // door and window typing - map to `Unknown` too. For those the Unknown + // bucket is the only representation available and querying it is the + // documented, working behaviour: a file whose sole unclassified + // entities are door styles really does answer `ofType('IfcDoorStyle')` + // correctly this way. + // + // Only case 1 is rejected, and the oracle deciding which case a name falls + // in has to span every schema the parser reads. `IFC_ENTITY_NAMES` does + // not: it is the hand-kept IFC4X3-only display-name table, and keying on + // it rejected `IfcDoorStyle` and `IfcWindowStyle` outright. `isKnownType` + // (@ifc-lite/parser) is the predicate that already answers this question + // for the SDK's authoring guard - the bundled IFC2X3 + IFC4 + IFC4X3 + // schema union, minus EXPRESS defined types (`IfcLengthMeasure`, + // `IfcArcIndex`), with the IFC4_ADD2_TC1 codegen pin as a fallback. Using + // it rather than growing a second name table keeps one source of truth. + // + // `isKnownType` deliberately does not resolve `ENTITY_NAME_ALIASES`, + // because it doubles as a name canonicalizer and an alias maps a leaf to + // its nearest schema-known *supertype*. For a pure known-ness question the + // alias table is exactly the right thing to consult: it lists names real + // STEP files carry that the bundled EXPRESS exports omit, such as IFC2X3's + // `IfcElectricalDistributionPoint`. Hence the second lookup - it is the + // difference between accepting and rejecting those names. + // + // A genuine query for the Unknown bucket is still made by passing the + // literal string `'Unknown'`. + // + // One normalisation feeds both steps. `trim()` has to happen BEFORE the + // enum lookup, not just inside the guard: `IfcTypeEnumFromString` only + // uppercases, so a padded `' IfcWall '` misses `TYPE_STRING_TO_ENUM` and + // yields `Unknown`, while the guard - trimming - finds `IfcWall` known and + // lets it through. The query would then run against the Unknown bucket and + // answer with entities that are not walls, with no error at all. Trimming + // at one place only is what creates that window; trimming at both closes + // it. For every unpadded name `trim()` is the identity, so no name that + // resolves correctly today changes meaning. + const typeEnums = types.map(t => { + const trimmed = t.trim(); + const typeEnum = IfcTypeEnumFromString(trimmed); + if (typeEnum === IfcTypeEnum.Unknown) { + const known = + trimmed.toUpperCase() === 'UNKNOWN' || + isKnownType(trimmed) || + isKnownType(resolveEntityNameAlias(trimmed)); + if (!known) { + throw new Error( + `ofType(): "${t}" is not an entity name in any IFC schema this ` + + `build reads (IFC2X3, IFC4, IFC4X3). Check the spelling; for a ` + + `vendor-specific type name, pass 'Unknown' to query entities ` + + `whose type could not be classified.` + ); + } + } + return typeEnum; + }); return new EntityQuery(this.store, typeEnums); } diff --git a/packages/query/test/oftype-unknown-type.test.ts b/packages/query/test/oftype-unknown-type.test.ts new file mode 100644 index 000000000..de627260b --- /dev/null +++ b/packages/query/test/oftype-unknown-type.test.ts @@ -0,0 +1,320 @@ +/* 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/. */ + +/** + * `IfcQuery.ofType()` maps a type string through `IfcTypeEnumFromString`, + * which falls back to `IfcTypeEnum.Unknown` for any name it does not + * recognize. That single fallback covers two different situations, and only + * one of them is a caller error: + * + * - `'IfcWal'` is not an IFC entity name at all, so the caller meant + * `'IfcWall'`. Silently answering with the Unknown bucket - every entity + * the store could not classify - returns some other, unrelated set of + * entities. `ofType()` rejects this. + * + * - `'IfcChiller'` IS a standard IFC4 entity name; `TYPE_STRING_TO_ENUM` + * (packages/data/src/types.ts) is a curated subset that has no row for it, + * so it maps to Unknown as well. The Unknown bucket is the only + * representation this build has for such an entity, and querying it is the + * correct, pre-existing behaviour. `ofType()` must NOT reject these. + * + * The discriminator therefore has to be an oracle that spans every schema the + * parser reads, not one of them. An earlier revision keyed the check on + * `IFC_ENTITY_NAMES` - IFC4X3-only, and hand-maintained - which rejected + * `IfcDoorStyle` and `IfcWindowStyle`, the entities IFC2X3 files use to carry + * door and window typing. The exhaustive sweeps below exist so that a + * schema-coverage hole cannot pass again: a hand-picked sample of five names + * that all happen to sit in one table cannot see it. + * + * See `ifc-query.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { + ENTITIES_IFC2X3, + ENTITIES_IFC4, + ENTITIES_IFC4X3, + IFC_DATA_TYPES, + type IfcStoreBase, +} from '@ifc-lite/data'; +import { SCHEMA_REGISTRY, isKnownType, type IfcDataStore } from '@ifc-lite/parser'; +import { createMockStore } from './mock-store.js'; +import { IfcQuery } from '../src/ifc-query.js'; + +/** The one error `ofType()`'s guard raises. Nothing else may be mistaken for it. */ +const GUARD_MESSAGE = /is not an entity name in any IFC schema/; + +/** + * `createMockStore` builds an `IfcStoreBase`. `IfcQuery` takes the parser's + * `IfcDataStore`, which adds parse-time-only members (`source`, `parseTime`, + * the deferred indices) that none of the code under test here reads. One + * widening, named and explained, instead of an unchecked cast at every + * construction site - so the mock's own shape stays type-checked against + * `IfcStoreBase`. + */ +function queryFor(store: IfcStoreBase): IfcQuery { + return new IfcQuery(store as unknown as IfcDataStore); +} + +/** + * The names `ofType()`'s guard rejected, out of `names`. + * + * Any error that is NOT the guard's is rethrown. A bare `catch { return true }` + * cannot tell a wrong-name rejection from an unrelated crash - in the mock + * store, the schema tables, or the oracle - so a sweep built on one reports + * "nothing rejected" for a run in which the guard was never actually reached, + * and passes for a reason it never verified. + */ +function namesRejectedByGuard(q: IfcQuery, names: readonly string[]): string[] { + const rejected: string[] = []; + for (const name of names) { + try { + q.ofType(name); + } catch (e) { + if (!(e instanceof Error) || !GUARD_MESSAGE.test(e.message)) throw e; + rejected.push(name); + } + } + return rejected; +} + +/** + * Standard buildingSMART entity names that `TYPE_STRING_TO_ENUM` has no entry + * for. Each maps to `IfcTypeEnum.Unknown`, so a rule keyed on "did this map to + * Unknown?" alone would wrongly reject every one of them. The last two are the + * IFC2X3 door/window typing entities that the `IFC_ENTITY_NAMES` oracle + * rejected. + */ +const STANDARD_BUT_UNMAPPED = [ + 'IfcChiller', + 'IfcActuator', + 'IfcElectricAppliance', + 'IfcBuildingSystem', + 'IfcAudioVisualAppliance', + 'IfcDoorStyle', + 'IfcWindowStyle', + // IFC2X3 leaf that no bundled EXPRESS export carries; the parser's + // `ENTITY_NAME_ALIASES` is the only table that knows it. + 'IfcElectricalDistributionPoint', +] as const; + +/** + * Names that are not IFC entity names in ANY schema this build reads, so + * `ofType()` must keep rejecting them. Without this direction a guard that + * accepted everything would pass the exhaustive sweeps below while having + * removed the feature entirely. + */ +const NOT_IFC_ENTITY_NAMES = [ + 'IfcWal', // the typo this guard exists for + 'IfcWalll', + 'IFCPROPRIETARYVENDORTHING', + 'Wall', + 'IfcLengthMeasure', // a real IFC *defined type*, not an entity + '', + // `Object.prototype` member names. The oracle's pin fallback asked + // `name in SCHEMA_REGISTRY.entities`, and `in` walks the prototype chain, + // so each of these answered "known" and `ofType()` handed back the Unknown + // bucket - the exact silent-wrong-answer this guard exists to stop, reached + // by a name the caller can produce from any untrusted string. Fixed in + // `isKnownEntity`/`getEntityMetadata` (the codegen template) with + // `Object.hasOwn` (#3063/#3069, not this branch - the fix belongs in the + // template that emits the registry), so this list samples the class rather + // than enumerating a denylist that would drift. These entries therefore only + // pass once #3069 has landed. + 'constructor', + 'toString', + 'valueOf', + 'hasOwnProperty', + '__proto__', + 'isPrototypeOf', + // The control: a plain non-IFC name, rejected before and after. + 'NotAThing', +] as const; + +function storeWithUnclassified(unclassifiedType: string) { + return createMockStore({ + entities: [ + { expressId: 10, type: 'IFCWALL', globalId: 'g10', name: 'Real Wall' }, + { + expressId: 20, + type: unclassifiedType.trim().toUpperCase(), + globalId: 'g20', + name: 'Unclassified', + }, + ], + }); +} + +describe('ofType() rejects a type string that is not an IFC entity name', () => { + it('throws on a typo rather than silently matching the Unknown bucket', () => { + const query = queryFor(storeWithUnclassified('IFCCHILLER')); + // Caller made a typo: 'IfcWal' instead of 'IfcWall'. + expect(() => query.ofType('IfcWal')).toThrow(/is not an entity name in any IFC schema/); + }); + + it('throws on a name that is not in the IFC schema at all', () => { + const query = queryFor(storeWithUnclassified('IFCCHILLER')); + expect(() => query.ofType('IFCPROPRIETARYVENDORTHING')).toThrow( + /is not an entity name in any IFC schema/, + ); + }); + + it('rejects a bad name even when a good one is passed alongside it', () => { + const query = queryFor(storeWithUnclassified('IFCCHILLER')); + expect(() => query.ofType('IfcWall', 'IfcWal')).toThrow(/is not an entity name in any IFC schema/); + }); + + it('still allows an explicit query for the Unknown bucket itself', async () => { + const query = queryFor(storeWithUnclassified('IFCPROPRIETARYVENDORTHING')); + const ids = await query.ofType('Unknown').ids(); + expect(ids).toEqual([20]); + }); +}); + +/** + * The guard trims; the resolution must trim too. + * + * `IfcTypeEnumFromString` only uppercases, so a padded `' IfcWall '` misses + * `TYPE_STRING_TO_ENUM` and comes back `Unknown`. The guard then trims, finds + * `IfcWall` known, and does NOT throw - so the query runs against the Unknown + * bucket and answers with entities that are not walls, silently. Two + * normalisations that disagree is the whole defect; the name must be a name the + * enum table DOES map, or both paths yield Unknown for their own reasons and + * the test cannot see it. `' IfcDoorStyle '` is exactly that useless fixture - + * it is Unknown on both paths either way. + */ +describe('ofType() resolves a padded name through the same normalisation the guard uses', () => { + it('a padded mapped name matches its own type, not the Unknown bucket', async () => { + // Entity 10 is the IfcWall; entity 20 is the unclassified IfcChiller that + // the Unknown bucket holds. Answering [20] means the padding turned a wall + // query into an Unknown-bucket query. + const query = queryFor(storeWithUnclassified('IFCCHILLER')); + expect(await query.ofType(' IfcWall ').ids()).toEqual([10]); + }); + + it('the unpadded name is unchanged by the trim', async () => { + const query = queryFor(storeWithUnclassified('IFCCHILLER')); + expect(await query.ofType('IfcWall').ids()).toEqual([10]); + }); + + it('padding does not smuggle a genuine typo past the guard', () => { + const query = queryFor(storeWithUnclassified('IFCCHILLER')); + expect(() => query.ofType(' IfcWal ')).toThrow(GUARD_MESSAGE); + // The message quotes the string the caller actually passed. + expect(() => query.ofType(' IfcWal ')).toThrow(/" IfcWal "/); + }); + + it('a padded name the enum table does not map still reaches the Unknown bucket', async () => { + // The other direction of the same normalisation: trimming must not start + // rejecting - or re-classifying - the standard-but-unmapped names. + const query = queryFor(storeWithUnclassified('IFCCHILLER')); + expect(await query.ofType(' IfcChiller ').ids()).toEqual([20]); + }); +}); + +describe('ofType() accepts standard IFC types the enum table does not map', () => { + for (const typeName of STANDARD_BUT_UNMAPPED) { + it(`${typeName} does not throw and still reaches the Unknown bucket`, async () => { + const query = queryFor(storeWithUnclassified(typeName)); + expect(() => query.ofType(typeName)).not.toThrow(); + // The store's only unclassified entity is the one of this very type, so + // the Unknown bucket answers the query correctly - as it did before the + // guard existed. Entity 10 (a mapped IfcWall) must not leak in. + const ids = await query.ofType(typeName).ids(); + expect(ids).toEqual([20]); + }); + } + + it('accepts a standard unmapped type in any casing, with surrounding space', () => { + const query = queryFor(storeWithUnclassified('IfcChiller')); + expect(() => query.ofType('IFCCHILLER')).not.toThrow(); + expect(() => query.ofType(' ifcchiller ')).not.toThrow(); + }); +}); + +/** + * The exhaustive sweeps. Every entity name in a schema table this build ships + * must survive `ofType()`; a single rejection is a name a real file can carry + * and a correctly spelled query cannot reach. + */ +describe('ofType() accepts every entity name in every schema this build reads', () => { + const query = () => queryFor(storeWithUnclassified('IFCCHILLER')); + + /** + * The upstream SchemaInfo tables carry EXPRESS *defined types* + * (`IfcLengthMeasure`, `IfcBoolean`, `IfcArcIndex`, ...) as rows alongside + * real ENTITY declarations, because IDS needs their names. They are not + * entity names, so they are not part of what `ofType()` promises to accept - + * subtract them rather than weakening the assertion to cover them. + * + * Two tables are needed to name them all, which is why this mirrors the + * parser's own subtraction (`NON_ENTITY_NAMES_UPPER` in + * `packages/parser/src/ifc-schema.ts`) instead of using `IFC_DATA_TYPES` + * alone: the IDS table omits six that `SCHEMA_REGISTRY.types` carries + * (`IfcBinary`, `IfcArcIndex`, `IfcLineIndex`, `IfcComplexNumber`, + * `IfcCompoundPlaneAngleMeasure`, `IfcPropertySetDefinitionSet`). + */ + const DEFINED_TYPES = new Set([ + ...IFC_DATA_TYPES.map((t) => t.name.toUpperCase()), + ...Object.keys(SCHEMA_REGISTRY.types).map((n) => n.toUpperCase()), + ...Object.keys(SCHEMA_REGISTRY.enums).map((n) => n.toUpperCase()), + ...Object.keys(SCHEMA_REGISTRY.selects).map((n) => n.toUpperCase()), + ]); + + const entityNames = (table: readonly { name: string }[]) => + table.map((e) => e.name).filter((n) => !DEFINED_TYPES.has(n.toUpperCase())); + + // The name the maintainer asked for by file: the parser's own registry, + // `packages/parser/src/generated/schema-registry.ts`. + it('accepts every entity in the parser SCHEMA_REGISTRY', () => { + const q = query(); + const names = Object.keys(SCHEMA_REGISTRY.entities); + expect(names.length).toBeGreaterThan(700); + expect(namesRejectedByGuard(q, names)).toEqual([]); + }); + + for (const [schema, table] of [ + ['IFC2X3', ENTITIES_IFC2X3], + ['IFC4', ENTITIES_IFC4], + ['IFC4X3', ENTITIES_IFC4X3], + ] as const) { + it(`accepts every ${schema} entity name`, () => { + const q = query(); + const names = entityNames(table); + expect(names.length).toBeGreaterThan(400); + expect(namesRejectedByGuard(q, names)).toEqual([]); + }); + } +}); + +describe('ofType() still rejects names that are not IFC entity names', () => { + it('the rejected names really are unknown to the parser, not just to ofType()', () => { + // Pins the two directions to the SAME oracle: if a future change made + // `isKnownType` accept these, the sweeps above would still pass while the + // guard had quietly become a no-op. This fails first in that case. + for (const bad of NOT_IFC_ENTITY_NAMES) { + expect(isKnownType(bad)).toBe(false); + } + }); + + for (const bad of NOT_IFC_ENTITY_NAMES) { + it(`rejects ${JSON.stringify(bad)}`, () => { + const q = queryFor(storeWithUnclassified('IFCCHILLER')); + expect(() => q.ofType(bad)).toThrow(/is not an entity name in any IFC schema/); + }); + } + + it('names the offending string and points at Unknown, without blaming spelling alone', () => { + const q = queryFor(storeWithUnclassified('IFCCHILLER')); + let message = ''; + try { + q.ofType('IfcWal'); + } catch (e) { + message = (e as Error).message; + } + expect(message).toContain('"IfcWal"'); + expect(message).toContain('IFC2X3, IFC4, IFC4X3'); + expect(message).toContain("pass 'Unknown'"); + }); +});