diff --git a/packages/core/src/openbim/IDSSpecifications/index.ts b/packages/core/src/openbim/IDSSpecifications/index.ts index d0e0619c2..2c86e6673 100644 --- a/packages/core/src/openbim/IDSSpecifications/index.ts +++ b/packages/core/src/openbim/IDSSpecifications/index.ts @@ -218,6 +218,9 @@ export class IDSSpecifications extends Component { specification.description = description; specification.instructions = instructions; specification.requirementsDescription = requirementsDescription; + const { minOccurs, maxOccurs } = applicability ?? {}; + if (typeof minOccurs === "number") specification.minOccurs = minOccurs; + if (typeof maxOccurs === "number") specification.maxOccurs = maxOccurs; specification.applicability.add(...applicabilities); specification.requirements.add(...reqs); result.push(specification); diff --git a/packages/core/src/openbim/IDSSpecifications/src/Specification.ts b/packages/core/src/openbim/IDSSpecifications/src/Specification.ts index 96ce69af6..ad17aca8b 100644 --- a/packages/core/src/openbim/IDSSpecifications/src/Specification.ts +++ b/packages/core/src/openbim/IDSSpecifications/src/Specification.ts @@ -20,6 +20,18 @@ export class IDSSpecification implements IDSSpecificationData { description?: string; instructions?: string; requirementsDescription?: string; + + /** + * Minimum number of applicable elements: `1` makes the specification + * required, `0` optional. Read from `minOccurs` of the applicability. + */ + minOccurs = 1; + + /** + * Maximum number of applicable elements: `0` makes the specification + * prohibited. Read from `maxOccurs` of the applicability. + */ + maxOccurs: number | "unbounded" = "unbounded"; applicability = new FRAGS.DataSet(); requirements = new FRAGS.DataSet(); @@ -57,9 +69,40 @@ export class IDSSpecification implements IDSSpecificationData { modelIds: RegExp[], config: { skipIfFails: boolean } = { skipIfFails: true }, ) { - const result: IDSCheckResult = new FRAGS.DataMap(); + return (await this.run(modelIds, config)).result; + } - if (this.requirements.size === 0) return result; + /** + * Tests the models and also applies the specification's cardinality + * (`minOccurs` / `maxOccurs`): a required specification fails when too few + * elements are applicable, a prohibited one fails when any is, and an + * optional one may pass with none. Requirements of a prohibited + * specification are not evaluated. + * + * @returns `pass` is the specification-level verdict, `applicable` the number + * of applicable elements, and `result` the same per-element result as {@link test}. + */ + async verify( + modelIds: RegExp[], + config: { skipIfFails: boolean } = { skipIfFails: true }, + ) { + const { result, applicable } = await this.run(modelIds, config, true); + const max = this.maxOccurs === "unbounded" ? Infinity : this.maxOccurs; + let pass = applicable >= this.minOccurs && applicable <= max; + if (max > 0) { + for (const items of result.values()) { + for (const item of items.values()) if (!item.pass) pass = false; + } + } + return { pass, applicable, result }; + } + + private async run( + modelIds: RegExp[], + config: { skipIfFails: boolean }, + skipProhibitedRequirements = false, + ) { + const result: IDSCheckResult = new FRAGS.DataMap(); // Get applicable elements const entities: ModelIdMap = {}; @@ -70,6 +113,16 @@ export class IDSSpecification implements IDSSpecificationData { await Promise.all(applicabilityPromises); + let applicable = 0; + for (const ids of Object.values(entities)) applicable += ids.size; + + if ( + this.requirements.size === 0 || + (skipProhibitedRequirements && this.maxOccurs === 0) + ) { + return { result, applicable }; + } + // Test applicable elements against requirements const requirementPromises = []; for (const requirement of this.requirements) { @@ -77,7 +130,7 @@ export class IDSSpecification implements IDSSpecificationData { } await Promise.all(requirementPromises); - return result; + return { result, applicable }; } /** @@ -102,7 +155,7 @@ export class IDSSpecification implements IDSSpecificationData { : ""; const xml = ` - + ${[...this.applicability].map((facet) => facet.serialize("applicability")).join("\n")} diff --git a/packages/core/src/openbim/IDSSpecifications/src/specification-cardinality.test.ts b/packages/core/src/openbim/IDSSpecifications/src/specification-cardinality.test.ts new file mode 100644 index 000000000..6b4ebb727 --- /dev/null +++ b/packages/core/src/openbim/IDSSpecifications/src/specification-cardinality.test.ts @@ -0,0 +1,103 @@ +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 = "cardinality"; + +// `tests/fixtures/type-psets.ifc` holds six walls and no slabs. +const ids = ( + cardinality: string, + entity: string, + requirements = "", +) => ` + + Cardinality + + + + ${entity} + + ${requirements} + + +`; + +const waldo = ` + Name + Waldo +`; + +describe("IDSSpecification cardinality (minOccurs / maxOccurs)", () => { + let components: Components; + let model: FRAGS.SingleThreadedFragmentsModel; + + beforeAll(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, + }); + model = new FRAGS.SingleThreadedFragmentsModel(MODEL_ID, bytes, true); + components = new OBC.Components(); + vi.spyOn( + components.get(OBC.FragmentsManager), + "list", + "get", + ).mockReturnValue(new Map([[MODEL_ID, model]]) as never); + }, 60_000); + + afterAll(() => { + vi.restoreAllMocks(); + components?.dispose(); + model?.dispose(); + }); + + const verify = async (xml: string) => { + const [spec] = components.get(OBC.IDSSpecifications).load(xml); + return spec.verify([new RegExp(MODEL_ID)], { skipIfFails: false }); + }; + + const required = 'minOccurs="1" maxOccurs="unbounded"'; + const optional = 'minOccurs="0" maxOccurs="unbounded"'; + const prohibited = 'minOccurs="0" maxOccurs="0"'; + + it("required: fails when nothing is applicable", async () => { + expect((await verify(ids(required, "IFCSLAB", waldo))).pass).toBe(false); + }); + + it("required: applicable elements are still judged by the requirements", async () => { + expect((await verify(ids(required, "IFCWALL", waldo))).pass).toBe(false); + }); + + it("optional: may pass when nothing is applicable", async () => { + expect((await verify(ids(optional, "IFCSLAB", waldo))).pass).toBe(true); + }); + + it("prohibited: fails when the applicability matches", async () => { + const r = await verify(ids(prohibited, "IFCWALL")); + expect(r.applicable).toBe(6); + expect(r.pass).toBe(false); + }); + + it("prohibited: passes when the applicability does not match", async () => { + expect((await verify(ids(prohibited, "IFCSLAB"))).pass).toBe(true); + }); + + it("prohibited: does not throw when requirements are (invalidly) given", async () => { + expect((await verify(ids(prohibited, "IFCSLAB", waldo))).pass).toBe(true); + }); +}); diff --git a/packages/core/src/openbim/IDSSpecifications/src/specification-occurs.test.ts b/packages/core/src/openbim/IDSSpecifications/src/specification-occurs.test.ts new file mode 100644 index 000000000..69caae36b --- /dev/null +++ b/packages/core/src/openbim/IDSSpecifications/src/specification-occurs.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { loadSourceBundle } from "../../../../tests/source-bundle"; + +// Needs the real `Components`: see `loadSourceBundle`. +const OBC = await loadSourceBundle(); + +const ids = (occurs: string) => ` + + Occurs + + + + IFCWALL + + + +`; + +describe("IDSSpecification minOccurs / maxOccurs", () => { + const roundTrip = (occurs: string) => { + const manager = new OBC.Components().get(OBC.IDSSpecifications); + const [spec] = manager.load(ids(occurs)); + const xml = manager.export({ title: "t" }, [spec]); + const [again] = manager.load(xml); + return { spec, xml, again }; + }; + + it("required (1, unbounded) is the default", () => { + const { spec } = roundTrip(""); + expect([spec.minOccurs, spec.maxOccurs]).toEqual([1, "unbounded"]); + }); + + it("optional (0, unbounded) survives load and export", () => { + const { spec, xml, again } = roundTrip( + 'minOccurs="0" maxOccurs="unbounded"', + ); + expect([spec.minOccurs, spec.maxOccurs]).toEqual([0, "unbounded"]); + expect(xml).toContain( + '', + ); + expect([again.minOccurs, again.maxOccurs]).toEqual([0, "unbounded"]); + }); + + it("prohibited (0, 0) survives load and export", () => { + const { spec, xml, again } = roundTrip('minOccurs="0" maxOccurs="0"'); + expect([spec.minOccurs, spec.maxOccurs]).toEqual([0, 0]); + expect(xml).toContain(''); + expect([again.minOccurs, again.maxOccurs]).toEqual([0, 0]); + }); +});