8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
358 lines
13 KiB
Python
358 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Pins the instance-then-type fallback in `ticketqc.pstr` / `pdouble`.
|
|
|
|
THE DEFECT THIS GUARDS AGAINST
|
|
--------------------------------
|
|
`Element.LookupParameter` only ever sees parameters that live on the
|
|
INSTANCE. A knowledge role the plant layer maps to a TYPE parameter (wall
|
|
thickness is the standing example) therefore read back `None` forever, and
|
|
silently -- a role reading `None` just means the check stands down, so the
|
|
report still rendered and the checklist still printed with nothing to show
|
|
it had skipped anything. Verified live 2026-09-02 against a real 8" wall
|
|
panel: the thickness role read `None` off the instance and "8"" off the
|
|
type.
|
|
|
|
`pstr`/`pdouble` now read the instance first (an instance override, even an
|
|
unusual one, is what this particular piece actually carries) and fall back
|
|
to the type only when the instance has nothing -- no such parameter, or one
|
|
that is present but blank.
|
|
|
|
WHAT IS PINNED HERE, why each case matters
|
|
--------------------------------------------
|
|
1. Value only on the type -- the whole point of the fix. Must now resolve.
|
|
2. Value on both -- instance must win. This is the precedence rule, not
|
|
an accident of "whichever is checked first."
|
|
3. A fake with no type reachable at all (no `GetTypeId`) -- must behave
|
|
EXACTLY as before: return the instance's raw answer, never raise. This
|
|
is what keeps every existing fake element in the rest of the suite
|
|
from suddenly needing Revit-shaped attributes it never had.
|
|
4. A type lookup that raises (a `Document.GetElement` that blows up) --
|
|
must degrade to the instance answer, not propagate. Revit can hand
|
|
back all manner of half-built objects mid-edit; this function may
|
|
never be the thing that turns that into an unhandled exception.
|
|
|
|
`pdouble` gets the identical fallback (case 1) for the identical reason --
|
|
same defect, same fix -- but NOT the "instance wins on a tie" case, because
|
|
0.0 is a legitimate instance value and this file does not want to relitigate
|
|
`pdouble`'s own units warning about AsDouble(); its docstring already covers
|
|
that ground and weight/unit-weight roles are read through `pstr`, not this.
|
|
"""
|
|
# pylint: disable=import-error,invalid-name,broad-except
|
|
import sys
|
|
import types
|
|
|
|
import pytest
|
|
|
|
|
|
def _stub_revit():
|
|
"""`Autodesk.Revit.DB`, inert, for a host interpreter.
|
|
|
|
Same shape as `test_ticketqc_evidence.py`'s stub, with `StorageType`
|
|
given real `.String` / `.Double` sentinels -- `pstr`/`pdouble` branch on
|
|
those, so an empty placeholder class would make every read fall through
|
|
the wrong arm rather than exercising the fallback under test.
|
|
"""
|
|
if 'Autodesk.Revit.DB' in sys.modules:
|
|
return []
|
|
autodesk = types.ModuleType('Autodesk')
|
|
revit = types.ModuleType('Autodesk.Revit')
|
|
db = types.ModuleType('Autodesk.Revit.DB')
|
|
for name in ('FilteredElementCollector', 'AssemblyInstance', 'View',
|
|
'ViewSheet', 'ViewType', 'BuiltInCategory', 'Element',
|
|
'ElementId', 'XYZ', 'Transform',
|
|
'Options', 'Solid', 'BoundingBoxXYZ'):
|
|
setattr(db, name, type(str(name), (object,), {}))
|
|
|
|
class StorageType(object):
|
|
String = 'String'
|
|
Double = 'Double'
|
|
|
|
db.StorageType = StorageType
|
|
autodesk.Revit = revit
|
|
revit.DB = db
|
|
sys.modules['Autodesk'] = autodesk
|
|
sys.modules['Autodesk.Revit'] = revit
|
|
sys.modules['Autodesk.Revit.DB'] = db
|
|
return ['Autodesk.Revit.DB', 'Autodesk.Revit', 'Autodesk']
|
|
|
|
|
|
_ADDED = _stub_revit()
|
|
try:
|
|
from vibe import ticketqc as QC # noqa: E402
|
|
finally:
|
|
for _name in _ADDED:
|
|
sys.modules.pop(_name, None)
|
|
|
|
# `vibe.ticketqc` is cached in `sys.modules` process-wide, so if some OTHER
|
|
# test file imported it first through its own bare `StorageType` stub (no
|
|
# `.String` / `.Double`), `QC.StorageType` here is that same class object,
|
|
# not the one `_stub_revit` above just built. Stamp the two sentinels onto
|
|
# whichever `StorageType` class the module actually holds, rather than
|
|
# trusting that this file's stub is the one that won the import race.
|
|
QC.StorageType.String = 'String'
|
|
QC.StorageType.Double = 'Double'
|
|
|
|
|
|
# --------------------------------------------------------------- fake model
|
|
#
|
|
# Deliberately NOT built on any shared base class with the rest of the
|
|
# suite's fakes -- case 3 below needs an element that plainly has no
|
|
# `GetTypeId`, and a base class is exactly the thing that would quietly
|
|
# hand it one.
|
|
|
|
class Param(object):
|
|
def __init__(self, storage, string_value=None, double_value=None):
|
|
self.StorageType = storage
|
|
self._string_value = string_value
|
|
self._double_value = double_value
|
|
|
|
def AsString(self):
|
|
return self._string_value
|
|
|
|
def AsValueString(self):
|
|
return self._string_value
|
|
|
|
def AsDouble(self):
|
|
return self._double_value
|
|
|
|
|
|
def str_param(value):
|
|
return Param(QC.StorageType.String, string_value=value)
|
|
|
|
|
|
def double_param(value):
|
|
return Param(QC.StorageType.Double, double_value=value)
|
|
|
|
|
|
class FakeType(object):
|
|
"""Stands in for the `ElementType` `Document.GetElement` hands back."""
|
|
|
|
def __init__(self, params):
|
|
self._params = params or {}
|
|
|
|
def LookupParameter(self, name):
|
|
return self._params.get(name)
|
|
|
|
|
|
class FakeDoc(object):
|
|
def __init__(self, type_id, type_obj, raise_on_get=False):
|
|
self._type_id = type_id
|
|
self._type_obj = type_obj
|
|
self._raise_on_get = raise_on_get
|
|
|
|
def GetElement(self, type_id):
|
|
if self._raise_on_get:
|
|
raise RuntimeError('boom -- GetElement blew up')
|
|
if type_id != self._type_id:
|
|
return None
|
|
return self._type_obj
|
|
|
|
|
|
class InstanceOnly(object):
|
|
"""No `GetTypeId` at all -- the shape of every fake element already in
|
|
this suite before this change. Must not start raising."""
|
|
|
|
def __init__(self, params=None):
|
|
self._params = params or {}
|
|
|
|
def LookupParameter(self, name):
|
|
return self._params.get(name)
|
|
|
|
|
|
class WithType(InstanceOnly):
|
|
"""An instance whose type is reachable through `GetTypeId`/`Document`."""
|
|
|
|
def __init__(self, params=None, type_params=None, type_id='TYPE-1',
|
|
raise_on_get=False):
|
|
InstanceOnly.__init__(self, params)
|
|
self._type_id = type_id
|
|
self.Document = FakeDoc(type_id, FakeType(type_params),
|
|
raise_on_get=raise_on_get)
|
|
|
|
def GetTypeId(self):
|
|
return self._type_id
|
|
|
|
|
|
class TypeIdRaises(InstanceOnly):
|
|
"""`GetTypeId` itself blows up -- must degrade like `InstanceOnly`."""
|
|
|
|
def GetTypeId(self):
|
|
raise RuntimeError('boom -- GetTypeId blew up')
|
|
|
|
|
|
# --------------------------------------------------------------------- pstr
|
|
|
|
def test_value_only_on_the_type_is_now_found():
|
|
el = WithType(params={}, type_params={'Thickness': str_param('8"')})
|
|
assert QC.pstr(el, 'Thickness') == '8"'
|
|
|
|
|
|
def test_value_on_both_prefers_the_instance():
|
|
el = WithType(params={'Thickness': str_param('9"')},
|
|
type_params={'Thickness': str_param('8"')})
|
|
assert QC.pstr(el, 'Thickness') == '9"'
|
|
|
|
|
|
def test_no_type_reachable_behaves_exactly_as_before():
|
|
el = InstanceOnly(params={})
|
|
assert QC.pstr(el, 'Thickness') is None
|
|
|
|
el2 = InstanceOnly(params={'Mark': str_param('WP101')})
|
|
assert QC.pstr(el2, 'Mark') == 'WP101'
|
|
|
|
|
|
def test_get_type_id_raising_degrades_to_the_instance_answer():
|
|
el = TypeIdRaises(params={})
|
|
assert QC.pstr(el, 'Thickness') is None
|
|
|
|
el2 = TypeIdRaises(params={'Mark': str_param('WP101')})
|
|
assert QC.pstr(el2, 'Mark') == 'WP101'
|
|
|
|
|
|
def test_type_lookup_that_raises_degrades_to_the_instance_answer():
|
|
el = WithType(params={}, type_params={'Thickness': str_param('8"')},
|
|
raise_on_get=True)
|
|
assert QC.pstr(el, 'Thickness') is None
|
|
|
|
el2 = WithType(params={'Mark': str_param('WP101')},
|
|
type_params={'Mark': str_param('WP999')},
|
|
raise_on_get=True)
|
|
assert QC.pstr(el2, 'Mark') == 'WP101'
|
|
|
|
|
|
def test_instance_parameter_present_but_blank_still_falls_back_to_type():
|
|
el = WithType(params={'Thickness': str_param('')},
|
|
type_params={'Thickness': str_param('8"')})
|
|
assert QC.pstr(el, 'Thickness') == '8"'
|
|
|
|
|
|
def test_neither_instance_nor_type_has_it_is_none():
|
|
el = WithType(params={}, type_params={})
|
|
assert QC.pstr(el, 'Thickness') is None
|
|
|
|
|
|
def test_none_element_is_none():
|
|
assert QC.pstr(None, 'Thickness') is None
|
|
|
|
|
|
# ------------------------------------------------------------------ pdouble
|
|
|
|
def test_pdouble_value_only_on_the_type_is_now_found():
|
|
el = WithType(params={}, type_params={'Width': double_param(0.667)})
|
|
assert QC.pdouble(el, 'Width') == 0.667
|
|
|
|
|
|
def test_pdouble_value_on_both_prefers_the_instance():
|
|
el = WithType(params={'Width': double_param(0.5)},
|
|
type_params={'Width': double_param(0.667)})
|
|
assert QC.pdouble(el, 'Width') == 0.5
|
|
|
|
|
|
def test_pdouble_zero_on_instance_is_not_treated_as_missing():
|
|
# 0.0 is a legitimate instance value -- it must NOT fall through to the
|
|
# type just because it is falsy.
|
|
el = WithType(params={'Width': double_param(0.0)},
|
|
type_params={'Width': double_param(0.667)})
|
|
assert QC.pdouble(el, 'Width') == 0.0
|
|
|
|
|
|
def test_pdouble_no_type_reachable_behaves_exactly_as_before():
|
|
el = InstanceOnly(params={})
|
|
assert QC.pdouble(el, 'Width') is None
|
|
|
|
el2 = InstanceOnly(params={'Width': double_param(0.667)})
|
|
assert QC.pdouble(el2, 'Width') == 0.667
|
|
|
|
|
|
def test_pdouble_get_type_id_raising_degrades_to_the_instance_answer():
|
|
el = TypeIdRaises(params={})
|
|
assert QC.pdouble(el, 'Width') is None
|
|
|
|
|
|
def test_pdouble_type_lookup_that_raises_degrades_to_the_instance_answer():
|
|
el = WithType(params={}, type_params={'Width': double_param(0.667)},
|
|
raise_on_get=True)
|
|
assert QC.pdouble(el, 'Width') is None
|
|
|
|
|
|
# ----------------------------------------------------------- rstr unchanged
|
|
#
|
|
# `rstr` needs no change if the layer beneath it is fixed -- it just loops
|
|
# `pstr` over the role's candidate names. Confirmed rather than assumed: it
|
|
# now resolves a role mapped to a type-only parameter, with no edit to
|
|
# `rstr` itself.
|
|
|
|
class _FakeKnowledge(object):
|
|
"""The minimal shape `rstr` needs: `K.param_names(know, role)`."""
|
|
|
|
|
|
def test_rstr_resolves_a_role_mapped_to_a_type_only_parameter(monkeypatch):
|
|
from vibe import knowledge as K
|
|
monkeypatch.setattr(K, 'param_names',
|
|
lambda know, role: ['Thickness'])
|
|
el = WithType(params={}, type_params={'Thickness': str_param('8"')})
|
|
assert QC.rstr(el, _FakeKnowledge(), 'panel.thickness') == '8"'
|
|
|
|
|
|
# ---------------------------------------------------- instance-only / type-only
|
|
#
|
|
# `pstr_instance_only` / `pstr_type_only` (and their `rstr_*` twins) exist for
|
|
# exactly one caller, `ticketchecks._reinforcing_values`, which must keep an
|
|
# instance read and a type read genuinely mutually exclusive -- see that
|
|
# function's docstring. Pinned here so a future edit to `pstr`'s union does
|
|
# not quietly change these too.
|
|
|
|
def test_pstr_instance_only_never_sees_the_type():
|
|
el = WithType(params={}, type_params={'Thickness': str_param('8"')})
|
|
assert QC.pstr_instance_only(el, 'Thickness') is None
|
|
|
|
|
|
def test_pstr_instance_only_returns_the_instance_value_when_present():
|
|
el = WithType(params={'Thickness': str_param('9"')},
|
|
type_params={'Thickness': str_param('8"')})
|
|
assert QC.pstr_instance_only(el, 'Thickness') == '9"'
|
|
|
|
|
|
def test_pstr_instance_only_none_element_is_none():
|
|
assert QC.pstr_instance_only(None, 'Thickness') is None
|
|
|
|
|
|
def test_pstr_type_only_never_sees_the_instance():
|
|
el = WithType(params={'Thickness': str_param('9"')},
|
|
type_params={'Thickness': str_param('8"')})
|
|
assert QC.pstr_type_only(el, 'Thickness') == '8"'
|
|
|
|
|
|
def test_pstr_type_only_none_when_no_type_reachable():
|
|
el = InstanceOnly(params={'Thickness': str_param('9"')})
|
|
assert QC.pstr_type_only(el, 'Thickness') is None
|
|
|
|
|
|
def test_pstr_type_only_none_element_is_none():
|
|
assert QC.pstr_type_only(None, 'Thickness') is None
|
|
|
|
|
|
def test_rstr_instance_only_does_not_fall_back_to_the_type(monkeypatch):
|
|
from vibe import knowledge as K
|
|
monkeypatch.setattr(K, 'param_names',
|
|
lambda know, role: ['Thickness'])
|
|
el = WithType(params={}, type_params={'Thickness': str_param('8"')})
|
|
assert QC.rstr_instance_only(el, _FakeKnowledge(), 'panel.thickness') is None
|
|
|
|
el2 = WithType(params={'Thickness': str_param('9"')},
|
|
type_params={'Thickness': str_param('8"')})
|
|
assert QC.rstr_instance_only(el2, _FakeKnowledge(),
|
|
'panel.thickness') == '9"'
|
|
|
|
|
|
def test_rstr_type_only_does_not_fall_back_to_the_instance(monkeypatch):
|
|
from vibe import knowledge as K
|
|
monkeypatch.setattr(K, 'param_names',
|
|
lambda know, role: ['Thickness'])
|
|
el = WithType(params={'Thickness': str_param('9"')},
|
|
type_params={'Thickness': str_param('8"')})
|
|
assert QC.rstr_type_only(el, _FakeKnowledge(), 'panel.thickness') == '8"'
|
|
|
|
el2 = InstanceOnly(params={'Thickness': str_param('9"')})
|
|
assert QC.rstr_type_only(el2, _FakeKnowledge(), 'panel.thickness') is None
|