# -*- 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