Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user