Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/core/src/openbim/IDSSpecifications/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
61 changes: 57 additions & 4 deletions packages/core/src/openbim/IDSSpecifications/src/Specification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IDSFacet>();
requirements = new FRAGS.DataSet<IDSFacet>();

Expand Down Expand Up @@ -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 = {};
Expand All @@ -70,14 +113,24 @@ 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) {
requirementPromises.push(requirement.test(entities, result, config));
}

await Promise.all(requirementPromises);
return result;
return { result, applicable };
}

/**
Expand All @@ -102,7 +155,7 @@ export class IDSSpecification implements IDSSpecificationData {
: "";

const xml = `<specification ifcVersion="${[...this.ifcVersion].join(" ")}" ${name} ${identifier} ${description} ${instructions}>
<applicability minOccurs="1" maxOccurs="unbounded">
<applicability minOccurs="${this.minOccurs}" maxOccurs="${this.maxOccurs}">
${[...this.applicability].map((facet) => facet.serialize("applicability")).join("\n")}
</applicability>
<requirements>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = "",
) => `<?xml version="1.0" encoding="UTF-8"?>
<ids:ids xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:ids="http://standards.buildingsmart.org/IDS">
<ids:info><ids:title>Cardinality</ids:title></ids:info>
<ids:specifications>
<ids:specification name="spec" ifcVersion="IFC4">
<ids:applicability ${cardinality}>
<ids:entity><ids:name><ids:simpleValue>${entity}</ids:simpleValue></ids:name></ids:entity>
</ids:applicability>
${requirements}
</ids:specification>
</ids:specifications>
</ids:ids>`;

const waldo = `<ids:requirements><ids:attribute>
<ids:name><ids:simpleValue>Name</ids:simpleValue></ids:name>
<ids:value><ids:simpleValue>Waldo</ids:simpleValue></ids:value>
</ids:attribute></ids:requirements>`;

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);
});
});
Original file line number Diff line number Diff line change
@@ -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) => `<?xml version="1.0" encoding="UTF-8"?>
<ids:ids xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:ids="http://standards.buildingsmart.org/IDS">
<ids:info><ids:title>Occurs</ids:title></ids:info>
<ids:specifications>
<ids:specification name="spec" ifcVersion="IFC4">
<ids:applicability ${occurs}>
<ids:entity><ids:name><ids:simpleValue>IFCWALL</ids:simpleValue></ids:name></ids:entity>
</ids:applicability>
</ids:specification>
</ids:specifications>
</ids:ids>`;

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(
'<applicability minOccurs="0" maxOccurs="unbounded">',
);
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('<applicability minOccurs="0" maxOccurs="0">');
expect([again.minOccurs, again.maxOccurs]).toEqual([0, 0]);
});
});
Loading