diff --git a/packages/core/src/openbim/IDSSpecifications/src/facets/Facet.ts b/packages/core/src/openbim/IDSSpecifications/src/facets/Facet.ts index c4b7f43d6..6a078cbed 100644 --- a/packages/core/src/openbim/IDSSpecifications/src/facets/Facet.ts +++ b/packages/core/src/openbim/IDSSpecifications/src/facets/Facet.ts @@ -41,6 +41,8 @@ export abstract class IDSFacet { facetParameter: IDSFacetParameter, parameter: IDSFacetParameterName, checks?: IDSCheck[], + cardinality: IDSSimpleCardinality | IDSConditionalCardinaltiy = this + .cardinality, ) => { const checkLog: IDSCheck = { parameter, @@ -96,8 +98,8 @@ export abstract class IDSFacet { pass = minPass && maxPass; } - if (this.cardinality === "prohibited") pass = !pass; - if (this.cardinality === "optional") pass = true; + if (cardinality === "prohibited") pass = !pass; + if (cardinality === "optional") pass = true; checkLog.pass = pass; return checkLog.pass; diff --git a/packages/core/src/openbim/IDSSpecifications/src/facets/Property.ts b/packages/core/src/openbim/IDSSpecifications/src/facets/Property.ts index c15b9e210..ea67320ce 100644 --- a/packages/core/src/openbim/IDSSpecifications/src/facets/Property.ts +++ b/packages/core/src/openbim/IDSSpecifications/src/facets/Property.ts @@ -107,6 +107,8 @@ export class IDSProperty extends IDSFacet { set.Name.value, this.propertySet, "PropertySet", + undefined, + "required", ); if (!nameMatches) continue; @@ -310,91 +312,132 @@ export class IDSProperty extends IDSFacet { ); if (!checks) continue; - const sets = await this.getPsets(item); - const matchingSets = sets.filter((set) => { - if (!(set.Name && "value" in set.Name)) return false; - const result = this.evalRequirement( - set.Name.value, - this.propertySet, - "PropertySet", - ); - if (!result) return false; + // Evaluate the facet itself (as if required), then apply the + // cardinality once. Prohibited: passes when the facet does not match. + // Optional: never fails ("If the requirement is optional but would fail + // if it were required instead, the checker tool must not log an + // error", IDS developer guide). + const facetChecks: IDSCheck[] = []; + const present = await this.evalFacet(item, facetChecks); + const matches = + facetChecks.length > 0 && facetChecks.every(({ pass }) => pass); + + if (this.cardinality === "prohibited") { + checks.push({ + parameter: "BaseName", + currentValue: present ? this.baseName.parameter : null, + pass: !matches, + requiredValue: this.baseName, + } as IDSCheck); + } else if (this.cardinality === "optional") { checks.push({ - currentValue: set.Name.value, - parameter: "PropertySet", + parameter: "BaseName", + currentValue: present ? this.baseName.parameter : null, pass: true, - requiredValue: this.propertySet, - }); - return true; + requiredValue: this.baseName, + } as IDSCheck); + } else { + checks.push(...facetChecks); + } + } + } + } + + /** + * Checks the property set, name, value and data type of one item, as for a + * required facet, and returns whether a property with the required set and + * name is present at all. + */ + private async evalFacet(item: FRAGS.ItemData, checks: IDSCheck[]) { + const sets = await this.getPsets(item); + const matchingSets = sets.filter((set) => { + if (!(set.Name && "value" in set.Name)) return false; + const result = this.evalRequirement( + set.Name.value, + this.propertySet, + "PropertySet", + undefined, + "required", + ); + if (!result) return false; + checks.push({ + currentValue: set.Name.value, + parameter: "PropertySet", + pass: true, + requiredValue: this.propertySet, + }); + return true; + }); + + if (matchingSets.length === 0) { + checks.push({ + currentValue: null, + parameter: "PropertySet", + pass: false, + requiredValue: this.propertySet, + }); + return false; + } + + let present = false; + for (const set of matchingSets) { + const propsListName = this.getPropertyListName(set); + if (!propsListName) continue; + + const props = set[propsListName]; + if (!Array.isArray(props)) { + checks.push({ + currentValue: null, + parameter: "BaseName", + pass: false, + requiredValue: this.baseName, }); + continue; + } - if (matchingSets.length === 0) { - checks.push({ - currentValue: null, - parameter: "PropertySet", - pass: false, - requiredValue: this.propertySet, - }); - continue; + const matchingProps = props.filter((item) => { + if (!(item._category && item.Name)) return false; + if (!("value" in item._category && "value" in item.Name)) return false; + if (this._unsupportedTypes.includes(item._category.value)) { + return false; } - for (const set of matchingSets) { - const propsListName = this.getPropertyListName(set); - if (!propsListName) continue; - - const props = set[propsListName]; - if (!Array.isArray(props)) { - checks.push({ - currentValue: null, - parameter: "BaseName", - pass: false, - requiredValue: this.baseName, - }); - continue; - } + const result = this.evalRequirement( + item.Name.value, + this.baseName, + "BaseName", + undefined, + "required", + ); + if (!result) return false; - const matchingProps = props.filter((item) => { - if (!(item._category && item.Name)) return false; - if (!("value" in item._category && "value" in item.Name)) - return false; - if (this._unsupportedTypes.includes(item._category.value)) { - return false; - } - - const result = this.evalRequirement( - item.Name.value, - this.baseName, - "BaseName", - ); - if (!result) return false; + checks.push({ + currentValue: item.Name.value, + parameter: "BaseName", + pass: true, + requiredValue: this.baseName, + }); + return true; + }); - checks.push({ - currentValue: item.Name.value, - parameter: "BaseName", - pass: true, - requiredValue: this.baseName, - }); - return true; - }); - - if (matchingProps.length === 0) { - checks.push({ - currentValue: null, - parameter: "BaseName", - pass: false, - requiredValue: this.baseName, - }); - continue; - } + if (matchingProps.length === 0) { + checks.push({ + currentValue: null, + parameter: "BaseName", + pass: false, + requiredValue: this.baseName, + }); + continue; + } - for (const item of matchingProps) { - this.evalValue(item, checks); - this.evalDataType(item, checks); - this.evalURI(); - } - } + present = true; + for (const item of matchingProps) { + this.evalValue(item, checks); + this.evalDataType(item, checks); + this.evalURI(); } } + return present; } private getPropertyListName(item: FRAGS.ItemData) { @@ -531,7 +574,13 @@ export class IDSProperty extends IDSFacet { if (typeof value === "string" && parameter.type === "simple") { parameter.parameter = String(parameter.parameter); } - return this.evalRequirement(value, parameter, "Value"); + return this.evalRequirement( + value, + parameter, + "Value", + undefined, + "required", + ); }); const pass = match !== undefined; if (checks) { @@ -580,7 +629,7 @@ export class IDSProperty extends IDSFacet { const types = this.getValueAttrs(item).map((attr) => attr.type ?? null); const dataType = { type: "simple" as const, parameter: this.dataType }; const match = types.find((type) => - this.evalRequirement(type, dataType, "DataType"), + this.evalRequirement(type, dataType, "DataType", undefined, "required"), ); const pass = match !== undefined; if (checks) { diff --git a/packages/core/src/openbim/IDSSpecifications/src/facets/property-cardinality.test.ts b/packages/core/src/openbim/IDSSpecifications/src/facets/property-cardinality.test.ts new file mode 100644 index 000000000..75016fa06 --- /dev/null +++ b/packages/core/src/openbim/IDSSpecifications/src/facets/property-cardinality.test.ts @@ -0,0 +1,91 @@ +import * as FRAGS from "@thatopen/fragments"; +import * as fs from "fs"; +import * as path from "path"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import type { Components } from "../../../../index"; +import { loadSourceBundle } from "../../../../../tests/source-bundle"; + +// Needs the real `Components`: see `loadSourceBundle`. +const OBC = await loadSourceBundle(); + +const MODEL_ID = "type-psets"; + +// `tests/fixtures/type-psets.ifc` (see property.test.ts): IsExternal is TRUE +// on walls 40, 41, 42 and 46, FALSE on 45, and absent on 43. +const WALLS = [40, 41, 42, 43, 45, 46]; + +const convert = async () => { + const importer = new FRAGS.IfcImporter(); + const root = path.resolve(__dirname, "../../../../.."); + importer.wasm = { + path: `${path.resolve(root, "../../node_modules/web-ifc")}/`, + absolute: true, + }; + const ifc = fs.readFileSync( + path.join(root, "tests", "fixtures", "type-psets.ifc"), + ); + const bytes = await importer.process({ + bytes: new Uint8Array(ifc), + raw: true, + }); + return new FRAGS.SingleThreadedFragmentsModel(MODEL_ID, bytes, true); +}; + +describe("IDSProperty cardinality as a requirement", () => { + let components: Components; + let model: FRAGS.SingleThreadedFragmentsModel; + + beforeAll(async () => { + model = await convert(); + components = new OBC.Components(); + const fragments = components.get(OBC.FragmentsManager); + vi.spyOn(fragments, "list", "get").mockReturnValue( + new Map([[MODEL_ID, model]]) as never, + ); + }, 60_000); + + afterAll(() => { + vi.restoreAllMocks(); + components?.dispose(); + model?.dispose(); + }); + + const passing = async ( + cardinality: "required" | "optional" | "prohibited", + value?: boolean, + ) => { + const facet = new OBC.IDSProperty( + components, + { type: "simple", parameter: "Pset_WallCommon" }, + { type: "simple", parameter: "IsExternal" }, + ); + if (value !== undefined) facet.value = { type: "simple", parameter: value }; + facet.cardinality = cardinality; + const results = new FRAGS.DataMap< + string, + FRAGS.DataMap + >(); + await facet.test({ [MODEL_ID]: new Set(WALLS) }, results as never, { + skipIfFails: false, + }); + return [...results.get(MODEL_ID)!.entries()] + .filter(([, result]) => result.pass) + .map(([id]) => id) + .sort((a, b) => a - b); + }; + + it("required: passes only where the property has the value", async () => { + expect(await passing("required", true)).toEqual([40, 41, 42, 46]); + }); + + it("prohibited without a value: passes only where the property is absent", async () => { + expect(await passing("prohibited")).toEqual([43]); + }); + + it("optional: never fails, whether the property is absent, matches or differs", async () => { + // buildingSMART IDS test cases "an optional facet always passes regardless + // of outcome", and the developer guide: an optional requirement that would + // fail if required must not be logged as an error. + expect(await passing("optional", true)).toEqual(WALLS); + }); +});