From cb5742e0407e90ae19088a5ffc0358c8d58c15e8 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Fri, 31 Jul 2026 20:09:49 +0300 Subject: [PATCH 1/4] ifctester: fix PartOf USERDEFINED false pass get_predefined_type() never returns the literal string "USERDEFINED"; for a userdefined element it substitutes the custom ObjectType (or ElementType/ProcessType) text instead, per its own docstring. Every PartOf relation branch compared that text directly against self.predefinedType, so a requirement for predefinedType="USERDEFINED" could never match. Combined with the prohibited-cardinality flip at the end of __call__, this produced a false PASS: an element genuinely contained in, aggregated into, grouped with, nested under, or voided/filled by a userdefined-type parent was reported as satisfying a "must not" requirement. The required-cardinality case produced the mirror false FAIL. Entity.__call__ already special-cases this via is_userdefined_type(). Added the same handling to PartOf via a shared predefined_type_matches() helper, applied at all 6 relation branches (including the no-relation ancestor walk). Reproduced and verified each of the 6 branches directly against real API-built IFC4 models before and after the fix. Two of the six branches (no-relation ancestor walk, voids/fills) had no prior test coverage at all; added baseline coverage alongside the USERDEFINED regression tests. Generated with the assistance of an AI coding tool. --- src/ifctester/ifctester/facet.py | 22 +++-- src/ifctester/test/test_facet.py | 134 +++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 6 deletions(-) diff --git a/src/ifctester/ifctester/facet.py b/src/ifctester/ifctester/facet.py index 4339a3eea17..bc6184cb892 100644 --- a/src/ifctester/ifctester/facet.py +++ b/src/ifctester/ifctester/facet.py @@ -515,7 +515,7 @@ def __call__(self, inst: ifcopenshell.entity_instance, logger: Optional[Logger] if self.predefinedType: predefined_type = ifcopenshell.util.element.get_predefined_type(parent) ancestors[-1] += f".{predefined_type}" - if predefined_type == self.predefinedType: + if self.predefined_type_matches(parent): is_pass = True else: is_pass = True @@ -537,7 +537,7 @@ def __call__(self, inst: ifcopenshell.entity_instance, logger: Optional[Logger] if self.predefinedType: predefined_type = ifcopenshell.util.element.get_predefined_type(aggregate) ancestors[-1] += f".{predefined_type}" - if predefined_type == self.predefinedType: + if self.predefined_type_matches(aggregate): is_pass = True else: is_pass = True @@ -560,7 +560,7 @@ def __call__(self, inst: ifcopenshell.entity_instance, logger: Optional[Logger] reason = {"type": "ENTITY", "actual": group.is_a().upper()} if self.predefinedType: predefined_type = ifcopenshell.util.element.get_predefined_type(group) - if predefined_type != self.predefinedType: + if not self.predefined_type_matches(group): is_pass = False reason = {"type": "PREDEFINEDTYPE", "actual": predefined_type} elif self.relation == "IFCRELCONTAINEDINSPATIALSTRUCTURE": @@ -574,7 +574,7 @@ def __call__(self, inst: ifcopenshell.entity_instance, logger: Optional[Logger] reason = {"type": "ENTITY", "actual": container.is_a().upper()} if is_pass and self.predefinedType: predefined_type = ifcopenshell.util.element.get_predefined_type(container) - if predefined_type != self.predefinedType: + if not self.predefined_type_matches(container): is_pass = False reason = {"type": "PREDEFINEDTYPE", "actual": predefined_type} elif self.relation == "IFCRELNESTS": @@ -591,7 +591,7 @@ def __call__(self, inst: ifcopenshell.entity_instance, logger: Optional[Logger] if self.predefinedType: predefined_type = ifcopenshell.util.element.get_predefined_type(nest) ancestors[-1] += f".{predefined_type}" - if predefined_type == self.predefinedType: + if self.predefined_type_matches(nest): is_pass = True else: is_pass = True @@ -616,7 +616,7 @@ def __call__(self, inst: ifcopenshell.entity_instance, logger: Optional[Logger] reason = {"type": "ENTITY", "actual": building_element.is_a().upper()} if is_pass and self.predefinedType: predefined_type = ifcopenshell.util.element.get_predefined_type(building_element) - if predefined_type != self.predefinedType: + if not self.predefined_type_matches(building_element): is_pass = False reason = {"type": "PREDEFINEDTYPE", "actual": predefined_type} else: @@ -635,6 +635,16 @@ def get_parent(self, element): break return parent + def predefined_type_matches(self, element: ifcopenshell.entity_instance) -> bool: + # get_predefined_type() never returns the literal "USERDEFINED": for a + # userdefined element it substitutes the custom ObjectType (or + # equivalent) text instead. Comparing that text against the literal + # "USERDEFINED" would always be false, wrongly failing a required + # requirement and wrongly passing a prohibited one. + if self.predefinedType == "USERDEFINED": + return ifcopenshell.util.element.is_userdefined_type(element) + return ifcopenshell.util.element.get_predefined_type(element) == self.predefinedType + class Property(Facet): def __init__( diff --git a/src/ifctester/test/test_facet.py b/src/ifctester/test/test_facet.py index 3c5d91a6e45..25ff9469392 100644 --- a/src/ifctester/test/test_facet.py +++ b/src/ifctester/test/test_facet.py @@ -22,6 +22,7 @@ import ifcopenshell import ifcopenshell.api.aggregate import ifcopenshell.api.classification +import ifcopenshell.api.feature import ifcopenshell.api.group import ifcopenshell.api.material import ifcopenshell.api.nest @@ -1586,6 +1587,25 @@ def test_filtering_using_a_partof_facet(self): expected=False, ) + element.PredefinedType = "USERDEFINED" + element.ObjectType = "SLABRADOR" + facet = PartOf(name="IFCSLAB", predefinedType="USERDEFINED", relation="IFCRELAGGREGATES") + run( + "An aggregate predefined type may specify USERDEFINED itself", + facet=facet, + inst=subelement, + expected=True, + ) + facet = PartOf( + name="IFCSLAB", predefinedType="USERDEFINED", relation="IFCRELAGGREGATES", cardinality="prohibited" + ) + run( + "A prohibited aggregate predefined type check fails a user-defined match", + facet=facet, + inst=subelement, + expected=False, + ) + ifc = ifcopenshell.file() element = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcElementAssembly") subelement = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSlab") @@ -1618,6 +1638,23 @@ def test_filtering_using_a_partof_facet(self): facet = PartOf(name="IFCINVENTORY", predefinedType="BUNNY", relation="IFCRELASSIGNSTOGROUP") run("A group predefined type must match exactly 2/2", facet=facet, inst=element, expected=True) + facet = PartOf(name="IFCINVENTORY", predefinedType="USERDEFINED", relation="IFCRELASSIGNSTOGROUP") + run( + "A group predefined type may specify USERDEFINED itself", + facet=facet, + inst=element, + expected=True, + ) + facet = PartOf( + name="IFCINVENTORY", predefinedType="USERDEFINED", relation="IFCRELASSIGNSTOGROUP", cardinality="prohibited" + ) + run( + "A prohibited group predefined type check fails a user-defined match", + facet=facet, + inst=element, + expected=False, + ) + ifc = ifcopenshell.file() element = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcElementAssembly") container = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSpace") @@ -1642,6 +1679,26 @@ def test_filtering_using_a_partof_facet(self): facet = PartOf(relation="IFCRELCONTAINEDINSPATIALSTRUCTURE", name="IFCSPACE", predefinedType="BURROW") run("The container predefined type must match exactly 2/2", facet=facet, inst=element, expected=True) + facet = PartOf(relation="IFCRELCONTAINEDINSPATIALSTRUCTURE", name="IFCSPACE", predefinedType="USERDEFINED") + run( + "The container predefined type may specify USERDEFINED itself", + facet=facet, + inst=element, + expected=True, + ) + facet = PartOf( + relation="IFCRELCONTAINEDINSPATIALSTRUCTURE", + name="IFCSPACE", + predefinedType="USERDEFINED", + cardinality="prohibited", + ) + run( + "A prohibited container predefined type check fails a user-defined match", + facet=facet, + inst=element, + expected=False, + ) + ifc = ifcopenshell.file() element = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSlab") subelement = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcBeam") @@ -1675,6 +1732,23 @@ def test_filtering_using_a_partof_facet(self): facet = PartOf(relation="IFCRELNESTS", name="IFCFURNITURE", predefinedType="WATERBOTTLE") run("The nest predefined type must match exactly 2/2", facet=facet, inst=subelement, expected=True) + facet = PartOf(relation="IFCRELNESTS", name="IFCFURNITURE", predefinedType="USERDEFINED") + run( + "The nest predefined type may specify USERDEFINED itself", + facet=facet, + inst=subelement, + expected=True, + ) + facet = PartOf( + relation="IFCRELNESTS", name="IFCFURNITURE", predefinedType="USERDEFINED", cardinality="prohibited" + ) + run( + "A prohibited nest predefined type check fails a user-defined match", + facet=facet, + inst=subelement, + expected=False, + ) + ifc = ifcopenshell.file() element = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcFurniture") subelement = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDiscreteAccessory") @@ -1684,6 +1758,66 @@ def test_filtering_using_a_partof_facet(self): facet = PartOf(relation="IFCRELNESTS", name="IFCFURNITURE") run("Nesting may be indirect", facet=facet, inst=subsubelement, expected=True) + ifc = ifcopenshell.file() + element = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSlab") + subelement = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcBeam") + facet = PartOf(name="IFCSLAB") + run("An unattached element fails a default (no-relation) check", facet=facet, inst=subelement, expected=False) + ifcopenshell.api.aggregate.assign_object(ifc, products=[subelement], relating_object=element) + run("A default (no-relation) check walks the aggregation ancestry", facet=facet, inst=subelement, expected=True) + + element.PredefinedType = "USERDEFINED" + element.ObjectType = "SLABRADOR" + facet = PartOf(name="IFCSLAB", predefinedType="USERDEFINED") + run( + "A default (no-relation) predefined type may specify USERDEFINED itself", + facet=facet, + inst=subelement, + expected=True, + ) + facet = PartOf(name="IFCSLAB", predefinedType="USERDEFINED", cardinality="prohibited") + run( + "A prohibited default (no-relation) predefined type check fails a user-defined match", + facet=facet, + inst=subelement, + expected=False, + ) + + ifc = ifcopenshell.file() + wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall") + wall_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWallType", predefined_type="USERDEFINED") + wall_type.ElementType = "CUSTOMWALL" + ifcopenshell.api.type.assign_type(ifc, related_objects=[wall], relating_type=wall_type) + door = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoor") + opening = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcOpeningElement") + ifcopenshell.api.feature.add_feature(ifc, feature=opening, element=wall) + ifcopenshell.api.feature.add_filling(ifc, opening=opening, element=door) + facet = PartOf(name="IFCWALL", relation="IFCRELVOIDSELEMENT IFCRELFILLSELEMENT") + run( + "A filling element passes a voids/fills relationship via its opening", facet=facet, inst=door, expected=True + ) + run("An opening passes a voids/fills relationship with its host", facet=facet, inst=opening, expected=True) + + facet = PartOf(name="IFCWALL", predefinedType="USERDEFINED", relation="IFCRELVOIDSELEMENT IFCRELFILLSELEMENT") + run( + "A voids/fills predefined type may specify USERDEFINED itself", + facet=facet, + inst=door, + expected=True, + ) + facet = PartOf( + name="IFCWALL", + predefinedType="USERDEFINED", + relation="IFCRELVOIDSELEMENT IFCRELFILLSELEMENT", + cardinality="prohibited", + ) + run( + "A prohibited voids/fills predefined type check fails a user-defined match", + facet=facet, + inst=door, + expected=False, + ) + class TestRestriction: def test_creating_a_restriction(self): From 0f2b2ed233581669700e6b6e64433bf891e4751a Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Fri, 31 Jul 2026 20:09:55 +0300 Subject: [PATCH 2/4] docs: add ifctester verdict audit notes Records the branch enumeration, existing-work check against #9187- #9190/#9058/#9059/#9142/#8407/#8292/#8253/#8161, the PartOf USERDEFINED false-pass reproduction and fix, and the areas checked and not pursued (bounded minOccurs/maxOccurs, reporter layer). Generated with the assistance of an AI coding tool. --- docs/dev-notes/ifctester-verdict-audit.md | 205 ++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 docs/dev-notes/ifctester-verdict-audit.md diff --git a/docs/dev-notes/ifctester-verdict-audit.md b/docs/dev-notes/ifctester-verdict-audit.md new file mode 100644 index 00000000000..ca5b0bfd898 --- /dev/null +++ b/docs/dev-notes/ifctester-verdict-audit.md @@ -0,0 +1,205 @@ +# ifctester wrong-verdict audit + +Scope: `src/ifctester/ifctester/facet.py`, `reporter.py`, `ids.py`. Goal: find +defects that make an IDS check report the wrong pass/fail verdict, on top of +the four already fixed this week in #9187, #9188, #9189, #9190. + +## Existing-work check + +Before starting, all open PRs and `bimvoice` branches touching `ifctester` or +`ids` were enumerated (805 open PRs total; the first `--limit 400` search +missed older PRs, corrected with `--limit 900`, zero skipped after that). +Confirmed already fixed or already in flight, and therefore out of scope: + +- #9190 (`optional` masking failures, reporter forced-fail pass count, + console prohibited success count, `Txt.print` operator precedence) +- #9189 (prohibited specification requirement status) +- #9188 (property table `dataType` silent fail) +- #9187 (crash on unset layer material) +- #9142 (`totalDigits`/`fractionDigits` restriction) +- #9059 (Classification `SYSTEM` false fail when no system specified; + IFC2X3 predefined property sets) +- #9058 (unrecognised boolean spellings matching `True`) +- #8407 (scalar IDS values not normalised to their XML string form) +- #8292 (Entity restriction crash on IFC2X3 fail path) +- #8253 (Entity `predefinedType` restriction including `USERDEFINED`) +- #8161 (`IFCLOGICAL` `UNKNOWN` value wrongly treated as absent) + +None of these touch `PartOf`. + +## Branch enumeration + +`__call__` branch count per facet type, `Restriction.__eq__` constraint +count, and reporter verdict-affecting computations: + +| Facet | `__call__` branches | +|---|---| +| `Entity` | 4 (exact match, IFC2X3 type inference, `predefinedType` literal, `predefinedType` USERDEFINED) | +| `Attribute` | ~10 (single/restriction name resolution, LOGICAL UNKNOWN emptiness, value type dispatch x5, prohibited flip) | +| `Classification` | 5 (no-reference, value match, system match, prohibited flip, optional short-circuit) | +| `Property` | 7 property-entity classes x (existence, dataType, unit-convert) + value-dispatch x5 + prohibited flip | +| `Material` | 5 material classes x value-set-build + prohibited flip | +| `PartOf` | 6 relation branches (none / `IFCRELAGGREGATES` / `IFCRELASSIGNSTOGROUP` / `IFCRELCONTAINEDINSPATIALSTRUCTURE` / `IFCRELNESTS` / `IFCRELVOIDSELEMENT IFCRELFILLSELEMENT`), each with entity-name and `predefinedType` sub-checks, x prohibited flip | +| `Restriction.__eq__` | 9 constraints (enumeration, pattern, length, minLength, maxLength, maxExclusive, maxInclusive, minExclusive, minInclusive) | + +Total facet-level decision points examined: 6 facet types, 6 `PartOf` +relation branches, 9 restriction constraints, plus 3 reporter aggregation +sites (`Console`, `Json`, `Txt`) already covered by #9190. + +## FALSE PASS found: `PartOf` never recognises `predefinedType="USERDEFINED"` + +**Every `PartOf` relation branch, plus the ancestor-walk (no-`relation`) and +`IFCRELAGGREGATES` branches, silently mismatch `predefinedType="USERDEFINED"`, +producing a false PASS on a `prohibited` requirement and a false FAIL on a +`required` one.** + +### Root cause + +`ifcopenshell.util.element.get_predefined_type()` never returns the literal +string `"USERDEFINED"`. Its own docstring: + +> "If the predefined type is user defined, the custom type (such as object +> type, element type, or process type depending on the class) is returned +> instead." + +`Entity.__call__` (facet.py:249) already special-cases this: + +```python +if self.predefinedType == "USERDEFINED": + is_pass = ifcopenshell.util.element.is_userdefined_type(inst) +``` + +`PartOf.__call__` had no such special case in any of its six sites +(facet.py:516, 538, 562, 576, 592, 618, pre-fix), each comparing +`get_predefined_type(x) != self.predefinedType` directly. Since the custom +text (e.g. `"CustomSpace"`) never equals the literal `"USERDEFINED"`, a +`predefinedType="USERDEFINED"` requirement can never be satisfied through +this comparison, in either direction. + +### Reproduction (before fix) + +```python +ifc = ifcopenshell.file(schema='IFC4') +ifc.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name='P') +space = ifcopenshell.api.root.create_entity(ifc, ifc_class='IfcSpace', name='Room1') +space.PredefinedType = 'USERDEFINED' +space.ObjectType = 'CustomSpace' +wall = ifcopenshell.api.root.create_entity(ifc, ifc_class='IfcWall', name='Wall1') +ifcopenshell.api.spatial.assign_container(ifc, relating_structure=space, products=[wall]) + +facet = PartOf(name='IFCSPACE', predefinedType='USERDEFINED', + relation='IFCRELCONTAINEDINSPATIALSTRUCTURE', cardinality='prohibited') +result = facet(wall) +``` + +IDS requirement: "An element must not be contained in a user-defined-type +`IfcSpace`." The wall genuinely is contained in a userdefined-type space. + +- **Verdict obtained (before fix): `PASS`** — `is_pass=True`, + `reason={'type': 'PROHIBITED'}` +- **Correct verdict: `FAIL`** — the prohibited relationship exists. + +The mirror `cardinality='required'` case gives the opposite wrong answer: + +- **Verdict obtained (before fix): `FAIL`** — `is_pass=False`, + `reason={'type': 'PREDEFINEDTYPE', 'actual': 'CustomSpace'}` +- **Correct verdict: `PASS`** — the required relationship exists. + +This is the severe case: an IDS author writing "no element may sit in a +user-defined space/zone/group/nest/opening host" gets a clean PASS report on +a model that violates the rule. + +### All 6 branches verified, before and after the fix + +Each branch was independently built and run (IFC4, real API-created models, +type-relationship-driven `PredefinedType="USERDEFINED"`, `predefinedType` +resolved through `get_type()` where the class needs a type object to carry +`PredefinedType`, e.g. `IfcSpaceType`, `IfcWallType`, `IfcTaskType`, +`IfcFurnitureType`, `IfcDoorType`, or through the bare-attribute fallback for +classes with no type object, e.g. `IfcInventory`/`IfcZone`): + +| Branch | Before fix (prohibited / required) | After fix (prohibited / required) | +|---|---|---| +| no relation (ancestor walk) | PASS / FAIL (wrong both ways) | FAIL / PASS (correct) | +| `IFCRELAGGREGATES` | PASS / FAIL | FAIL / PASS | +| `IFCRELASSIGNSTOGROUP` | PASS / FAIL | FAIL / PASS | +| `IFCRELCONTAINEDINSPATIALSTRUCTURE` | PASS / FAIL | FAIL / PASS | +| `IFCRELNESTS` | PASS / FAIL | FAIL / PASS | +| `IFCRELVOIDSELEMENT IFCRELFILLSELEMENT` | PASS / FAIL | FAIL / PASS | + +### Fix + +Added `PartOf.predefined_type_matches()`, mirroring `Entity.__call__`'s +existing literal-`"USERDEFINED"` handling, and used it at all 6 call sites +instead of the direct `==`/`!=` comparison against `get_predefined_type()`. +Scope kept to the literal-string case that `Entity.__call__` already handles +(pre-#8253); the `Restriction`-enumeration-including-`USERDEFINED` case for +`Entity` is #8253's separate, already-open fix and was not duplicated here. + +`src/ifctester/ifctester/facet.py`: +- `PartOf.predefined_type_matches()` (new helper) +- 6 call sites updated: the no-relation ancestor walk, + `IFCRELAGGREGATES`, `IFCRELASSIGNSTOGROUP`, + `IFCRELCONTAINEDINSPATIALSTRUCTURE`, `IFCRELNESTS`, + `IFCRELVOIDSELEMENT IFCRELFILLSELEMENT` + +`src/ifctester/test/test_facet.py`: added `USERDEFINED`-itself coverage +(required and prohibited) to all 6 branches. Two of the six branches (the +no-`relation` ancestor walk and `IFCRELVOIDSELEMENT IFCRELFILLSELEMENT`) had +**zero** prior test coverage of any kind; basic pass/fail coverage was added +for those alongside the `USERDEFINED` regression tests. + +## Considered and not pursued + +- **`Specification.minOccurs`/`maxOccurs` as exact numeric bounds** (e.g. + `minOccurs=2`): `Specification.validate()` only branches on "zero + applicable entities" for the required/prohibited cases, never on an exact + count. This looks like it could under-enforce a `minOccurs=2` spec with + only 1 applicable entity. Not pursued as a bug: the `Cardinality` type + used throughout the codebase (`facet.py`, `ids.py`) is a strict + `required`/`optional`/`prohibited` ternary, `get_usage()`/`set_usage()` + only ever produce `{0,1,unbounded}` combinations, and no test fixture or + IDS example in the repo uses any other value. Flagging this without + checking the buildingSMART IDS documentation's stated intent for + non-`{0,1,unbounded}` `minOccurs`/`maxOccurs` risks the "spec being odd, + not code being wrong" trap; left as an open question rather than a claimed + defect. +- **`Attribute`/`Classification`/`Property` case-sensitivity and casting**: + re-checked every `==`/`!=`/`cast_to_value` site outside the branches + already fixed by #9058/#9059/#9188. All remaining comparisons are either + intentionally case-sensitive (matches the IDS spec's literal string + matching for names/systems/references) or already routed through + `cast_to_value`/`is_x` with symmetric tolerance handling verified correct + for both positive and negative bounds. +- **Reporter layer**: read `reporter.py` end to end (`Console`, `Txt`, + `Json`, `Html`, `Ods`, `OdsSummary`, `Bcf`). The one remaining + verdict-affecting computation, `Json.report_specification`'s + `total_fail = len(requirement.failures)`, is exactly the site #9190 + already fixes. No further wrong-verdict computation found; `Ods`/`Html`/ + `Bcf` all consume the already-corrected `Json` results without + re-deriving pass/fail counts. + +## Measured ratio + +1 verdict-affecting defect found and fixed (`PartOf` USERDEFINED +`predefinedType`, a false PASS on `prohibited` and a false FAIL on +`required`, across 6 branches) out of the systematic re-check of every +`facet.py` `__call__` branch not already covered by #9187-#9190, #9058, +#9059, #9142, #8407, #8292, #8253, #8161, plus a full re-read of +`reporter.py`. Two previously-untested `PartOf` branches (no-relation +ancestor walk, voids/fills) gained baseline test coverage as a side effect. + +## Verification + +- Baseline (before any change): `pytest -p no:pytest-blender test/` under + the scratch python-3.13.6 interpreter (rsynced `.py` sources over the + built `.so` files, `ifcopenshell.__file__`/`ifctester.__file__` confirmed + resolving into the scratch copy) — 37 passed. +- After fix + new tests: 37 passed (test count unchanged; new assertions + were added inside existing test methods, one new import + `ifcopenshell.api.feature`). +- `black`/`ruff` clean on `facet.py` and `test_facet.py`. + +## Branch + +`bimvoice/fix/ifctester-partof-userdefined`. No PR opened per instructions. From 5ccf00b581acacca076449ceb7b79e66aed0dc70 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sat, 1 Aug 2026 07:18:24 +0300 Subject: [PATCH 3/4] ifctester: add fixture pair for PartOf USERDEFINED Minimal .ids/.ifc pair demonstrating the false pass fixed by this branch: a wall inside a user-defined-type IfcSpace, checked with a prohibited PartOf(predefinedType="USERDEFINED"). Verified red (PASS) against the pre-fix code and green (FAIL) against this branch's fix, both through ids.open/ifcopenshell.open, not by calling the facet directly. Requested by aothms on PR #9203. Generated with the assistance of an AI coding tool. --- .../partof_userdefined/partof_userdefined.ids | 30 ++++++++++++ .../partof_userdefined/partof_userdefined.ifc | 13 +++++ src/ifctester/test/test_fixtures.py | 47 +++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 src/ifctester/test/fixtures/partof_userdefined/partof_userdefined.ids create mode 100644 src/ifctester/test/fixtures/partof_userdefined/partof_userdefined.ifc create mode 100644 src/ifctester/test/test_fixtures.py diff --git a/src/ifctester/test/fixtures/partof_userdefined/partof_userdefined.ids b/src/ifctester/test/fixtures/partof_userdefined/partof_userdefined.ids new file mode 100644 index 00000000000..e9bf1609374 --- /dev/null +++ b/src/ifctester/test/fixtures/partof_userdefined/partof_userdefined.ids @@ -0,0 +1,30 @@ + + + + PartOf USERDEFINED prohibition + A wall must not sit in a user-defined-type space. + + + + + + + IFCWALL + + + + + + + + IFCSPACE + + + USERDEFINED + + + + + + + \ No newline at end of file diff --git a/src/ifctester/test/fixtures/partof_userdefined/partof_userdefined.ifc b/src/ifctester/test/fixtures/partof_userdefined/partof_userdefined.ifc new file mode 100644 index 00000000000..504963f0937 --- /dev/null +++ b/src/ifctester/test/fixtures/partof_userdefined/partof_userdefined.ifc @@ -0,0 +1,13 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); +FILE_NAME('','2026-08-01T07:15:40',(''),(''),'IfcOpenShell 0.8.6-3e7b739','IfcOpenShell 0.8.6-3e7b739',''); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPROJECT('0neHLLIQD7PfnET3xUMUD4',$,'P',$,$,$,$,$,$); +#2=IFCSPACE('0Y8DYxGJn2ZvXqMKuBwiaQ',$,'Room1',$,'CustomSpace',$,$,$,$,.USERDEFINED.,$); +#3=IFCWALL('0w5TacK0fCJPdDdO9hoTET',$,'Wall1',$,$,$,$,$,$); +#4=IFCRELCONTAINEDINSPATIALSTRUCTURE('1hb_WnBcjAFP9qal5SwqWv',$,$,$,(#3),#2); +ENDSEC; +END-ISO-10303-21; diff --git a/src/ifctester/test/test_fixtures.py b/src/ifctester/test/test_fixtures.py new file mode 100644 index 00000000000..35aaf14e730 --- /dev/null +++ b/src/ifctester/test/test_fixtures.py @@ -0,0 +1,47 @@ +# IfcTester - IDS based model auditing +# Copyright (C) 2021-2022 Thomas Krijnen , Dion Moult +# +# This file is part of IfcTester. +# +# IfcTester is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcTester is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcTester. If not, see . + +# This file was generated with the assistance of an AI coding tool. + +"""End-to-end regression tests for specific reported defects. + +Each test here loads a minimal .ids/.ifc pair from test/fixtures/ through +the same entry points ifctester's own CLI uses (ids.open, ifcopenshell.open, +Ids.validate), rather than exercising a facet's __call__ directly. +""" + +import os + +import ifcopenshell + +from ifctester import ids + +FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures") + + +class TestPartOfUserdefinedFixture: + def test_wall_prohibited_from_userdefined_space_but_is_in_one(self): + # A wall sitting in a user-defined-type IfcSpace, checked against a + # PartOf requirement that prohibits exactly that relationship. The + # relationship genuinely exists, so the specification must fail. + specs = ids.open(os.path.join(FIXTURES, "partof_userdefined", "partof_userdefined.ids")) + ifc = ifcopenshell.open(os.path.join(FIXTURES, "partof_userdefined", "partof_userdefined.ifc")) + specs.validate(ifc) + spec = specs.specifications[0] + assert spec.status is False + assert spec.requirements[0].status is False From 405d1ac28466fd7ac41ea4a7b82e687bbad561a8 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Tue, 4 Aug 2026 12:57:21 +0300 Subject: [PATCH 4/4] ifctester: rename to per-fixture test file test_fixtures.py is shared with our other open ids-fixture PRs (#371-branch, #9205), which each independently create the same file and collide with each other and with this branch. Renaming to a fixture-specific file name avoids the clash; fixture folders under test/fixtures/ never collide since each PR uses its own subfolder. Generated with the assistance of an AI coding tool. --- .../test/{test_fixtures.py => test_fixture_partof_userdefined.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/ifctester/test/{test_fixtures.py => test_fixture_partof_userdefined.py} (100%) diff --git a/src/ifctester/test/test_fixtures.py b/src/ifctester/test/test_fixture_partof_userdefined.py similarity index 100% rename from src/ifctester/test/test_fixtures.py rename to src/ifctester/test/test_fixture_partof_userdefined.py