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:
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,112 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Makes the Revit-free parts of `vibe` importable on host CPython.
|
||||
|
||||
`vibe/__init__.py` does `from vibe.helpers import (...)` at module scope, and
|
||||
`helpers` imports the Revit API the moment it is loaded. That is correct for
|
||||
the extension -- every button script and every `/vibe/exec` snippet gets `q`,
|
||||
`p`, `tx` and friends for free -- but it means a plain `import vibe.knowledge`
|
||||
from an ordinary CPython interpreter runs `vibe/__init__.py` first and dies
|
||||
with `ImportError: No module named Autodesk` before it ever reaches
|
||||
`knowledge.py`.
|
||||
|
||||
Most of `vibe` does not actually touch Revit at module scope. `knowledge.py`,
|
||||
`liftexport.py` and `configdump.py` only reach for the Revit API, or for
|
||||
`vibe.designspec` / `vibe.ticketqc` (which do), *inside* the functions that
|
||||
need a live document -- on purpose, precisely so the rest of the file can be
|
||||
exercised without one. `vibe.compat` has no Revit dependency at all.
|
||||
|
||||
THE TRICK, and why it works
|
||||
----------------------------
|
||||
We never execute `vibe/__init__.py`. Instead we register a bare, empty module
|
||||
object as `sys.modules['vibe']` with its `__path__` pointed at the real
|
||||
`WillTools.extension/lib/vibe` directory, then get out of the way. From then
|
||||
on, ordinary `import vibe.knowledge` / `from vibe import knowledge` statements
|
||||
-- inside test files AND inside the vibe modules themselves, e.g.
|
||||
`liftexport.py`'s `from vibe import knowledge` -- are resolved by Python's
|
||||
normal import machinery: `vibe` is already in `sys.modules` (so its
|
||||
`__init__.py` is never run again, and never run at all), and `vibe.knowledge`
|
||||
is found and executed as an ordinary submodule via that `__path__`.
|
||||
|
||||
USING THIS IN A NEW TEST
|
||||
-------------------------
|
||||
Nothing to import from this file. Once pytest has loaded this conftest.py
|
||||
(automatic for anything under `WillTools.extension/tests/`), plain imports
|
||||
just work:
|
||||
|
||||
from vibe import knowledge
|
||||
from vibe import liftexport
|
||||
from vibe import configdump
|
||||
from vibe import compat
|
||||
|
||||
A module that reaches for Revit or for `vibe.designspec` / `vibe.ticketqc` at
|
||||
MODULE SCOPE (rather than inside a function) will still fail to import this
|
||||
way -- that is not a bug in this trick, it is this trick correctly reporting
|
||||
that the module has a hard Revit dependency. `vibe.ticketqc` is one such
|
||||
module (it does `from Autodesk.Revit.DB import (...)` at the top), and so is
|
||||
`vibe.helpers`. Nothing under test here should ever need either.
|
||||
|
||||
`liftexport.export()` and anything else that needs a live `doc` is out of
|
||||
scope for this suite by the same logic -- see the docstring in
|
||||
`test_liftexport.py`.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
VIBE_DIR = Path(__file__).resolve().parents[1] / 'lib' / 'vibe'
|
||||
assert VIBE_DIR.is_dir(), 'expected to find vibe at {0}'.format(VIBE_DIR)
|
||||
|
||||
if 'vibe' not in sys.modules:
|
||||
_stub = types.ModuleType('vibe')
|
||||
_stub.__path__ = [str(VIBE_DIR)] # makes it a package, not a module
|
||||
_stub.__file__ = str(VIBE_DIR / '__init__.py')
|
||||
sys.modules['vibe'] = _stub
|
||||
|
||||
# The pyRevit YAML reader is faked in `pyrevit_yaml_fake.py`, NOT here, and
|
||||
# the module name is deliberately long: `panellift/tests/` has a conftest of
|
||||
# its own, so a whole-repo `python -m pytest` run leaves exactly ONE module
|
||||
# named `conftest` importable and it is whichever pytest reached first.
|
||||
# `from conftest import ...` inside a test file is therefore a coin flip.
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Appended by tools/make_dist.py -- knowledge-content test exclusion
|
||||
# ============================================================================
|
||||
# See knowledge/catalogue.yaml's own header (build_catalogue_carveout() in
|
||||
# tools/make_dist.py) for the full accounting. Short version: these 7
|
||||
# tests read `catalogue.inserts` and assert the owner's real published
|
||||
# manufacturer numbers, which this distribution does not ship. Skipped by
|
||||
# name, not faked with invented numbers standing in for real knowledge.
|
||||
#
|
||||
# AUTO-DETECTING: if `catalogue.inserts` is ever non-empty in the
|
||||
# knowledge/catalogue.yaml this test run actually finds -- e.g. a colleague
|
||||
# who has since been handed the owner's real knowledge layers -- these run
|
||||
# normally, unskipped, no edit needed here.
|
||||
_KNOWLEDGE_CONTENT_TESTS = ['test_the_shipped_catalogue_file_agrees_with_the_defaults', 'test_the_factor_resolves_per_part_and_is_never_borrowed', 'test_the_spacing_minimum_is_derived_from_the_part_s_own_edge_distance', 'test_a_published_spacing_overrides_the_multiple', 'test_a_part_with_no_edge_distance_to_double_stands_down_naming_it', 'test_st100_carries_the_owners_number_and_st50_still_does_not', 'test_qc589_plant_override_beats_the_public_catalogue']
|
||||
|
||||
|
||||
def _dist_real_inserts_present():
|
||||
import io as _io
|
||||
import os as _os
|
||||
here = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))
|
||||
path = _os.path.join(_os.path.dirname(here), 'knowledge', 'catalogue.yaml')
|
||||
try:
|
||||
import yaml as _yaml
|
||||
with _io.open(path, encoding='utf-8') as fh:
|
||||
data = _yaml.safe_load(fh.read()) or {}
|
||||
except Exception:
|
||||
return False
|
||||
return bool(((data.get('catalogue') or {}).get('inserts')) or None)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
import pytest as _pytest
|
||||
if _dist_real_inserts_present():
|
||||
return
|
||||
skip = _pytest.mark.skip(
|
||||
reason='knowledge-content test: asserts the owner\'s real product '
|
||||
'capacity data, which this distribution does not ship -- '
|
||||
'see knowledge/catalogue.yaml\'s header and CONTRIBUTING.md')
|
||||
for item in items:
|
||||
if item.originalname in _KNOWLEDGE_CONTENT_TESTS or item.name in _KNOWLEDGE_CONTENT_TESTS:
|
||||
item.add_marker(skip)
|
||||
@@ -0,0 +1,175 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The pyRevit YAML reader, faked HONESTLY -- strings only, like the real one.
|
||||
|
||||
`pyrevit/coreutils/yaml.py` is a YamlDotNet wrapper and its ENTIRE scalar
|
||||
conversion is:
|
||||
|
||||
elif isinstance(ynode, libyaml.RepresentationModel.YamlScalarNode):
|
||||
return ynode.Value
|
||||
|
||||
Every scalar therefore comes back as a STRING: `1.5` as `'1.5'`, `true` as
|
||||
`'true'`, and `null` as the four characters `'null'`. Two modules in this tree
|
||||
read through it -- `knowledge._merge_file`, which runs `_coerce()` afterwards,
|
||||
and `ticketpage._read_yaml`, which until 2026-08-31 did not.
|
||||
|
||||
WHY THIS FILE EXISTS: THE OLD FAKE WAS KINDER THAN PRODUCTION.
|
||||
The suite's only pyRevit YAML double was backed by `vibe.yamlkit`, which TYPES
|
||||
its scalars -- `_parse_scalar` turns 'null' into None. So every guard in this
|
||||
repository that reads YAML read it through a typing reader while the button
|
||||
read it through one that types nothing. The master checklist consequently
|
||||
arrived at the button with `CHECKLIST_FOR_ENGINEER` derived EMPTY (5.3 and 5.5
|
||||
red on the owner's screen) and the WORD 'null' in 37 of 39 rows' reason slots
|
||||
-- with the whole suite green, because the corruption is a non-empty string and
|
||||
so satisfies the very guard that demands a sentence from every row.
|
||||
|
||||
A double that is more forgiving than the thing it doubles is not a test double,
|
||||
it is a blindfold. Everything faking this reader now comes here, so the
|
||||
kindness cannot creep back one test file at a time.
|
||||
|
||||
AND THE SAME LESSON, AGAIN, ON DUPLICATE KEYS (2026-09-01).
|
||||
YamlDotNet -- what pyRevit's reader is -- REFUSES a mapping key stated twice:
|
||||
`load_as_dict` raises `Duplicate key`. This fake read through `yamlkit`, which
|
||||
takes the last one and says nothing, so the production reader and the double
|
||||
disagreed about whether a file loads AT ALL. A nested duplicate in one plant
|
||||
file therefore threw away that entire 255KB layer inside Revit -- every
|
||||
parameter role None, the sweep enumerating 0 assemblies, every report normal --
|
||||
and nothing on the host could reproduce it. `_UniqueKeyLoader` below closes
|
||||
that: this reader now raises on a duplicate at any depth, exactly like the one
|
||||
it doubles. `knowledge._merge_file` is the code that had to learn to say so,
|
||||
and `tools/knowledgelint.py` is the same strictness as a host-side gate.
|
||||
|
||||
WHY NOT conftest.py: `panellift/tests/` has a conftest of its own, so a
|
||||
whole-repo `python -m pytest` leaves exactly one module named `conftest`
|
||||
importable and it is whichever pytest reached first. `from conftest import ...`
|
||||
inside a test file is a coin flip; this name is unique in the tree.
|
||||
"""
|
||||
# pylint: disable=import-error,invalid-name
|
||||
import io
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def stringify_like_yamldotnet(node):
|
||||
"""Every scalar as text, the way the real pyRevit reader returns it.
|
||||
|
||||
Maps and sequences keep their shape; only the leaves change.
|
||||
"""
|
||||
if isinstance(node, dict):
|
||||
return dict([(k, stringify_like_yamldotnet(v))
|
||||
for k, v in node.items()])
|
||||
if isinstance(node, (list, tuple)):
|
||||
return [stringify_like_yamldotnet(v) for v in node]
|
||||
if node is None:
|
||||
return u'null'
|
||||
if node is True:
|
||||
return u'true'
|
||||
if node is False:
|
||||
return u'false'
|
||||
if isinstance(node, bytes):
|
||||
return node.decode('utf-8')
|
||||
if isinstance(node, str):
|
||||
return node
|
||||
return u'{0}'.format(node)
|
||||
|
||||
|
||||
def install_pyrevit_yaml(monkeypatch, load_as_dict):
|
||||
"""Register a fake `pyrevit.coreutils.yaml` exposing `load_as_dict`.
|
||||
|
||||
The readers under test import the module chain and swallow every
|
||||
exception, so a half-registered chain makes the read quietly do nothing
|
||||
and the test passes for the wrong reason. All three names are set.
|
||||
"""
|
||||
yaml_mod = types.ModuleType('pyrevit.coreutils.yaml')
|
||||
yaml_mod.load_as_dict = load_as_dict
|
||||
coreutils = types.ModuleType('pyrevit.coreutils')
|
||||
coreutils.yaml = yaml_mod
|
||||
pyrevit = types.ModuleType('pyrevit')
|
||||
pyrevit.coreutils = coreutils
|
||||
monkeypatch.setitem(sys.modules, 'pyrevit', pyrevit)
|
||||
monkeypatch.setitem(sys.modules, 'pyrevit.coreutils', coreutils)
|
||||
monkeypatch.setitem(sys.modules, 'pyrevit.coreutils.yaml', yaml_mod)
|
||||
return yaml_mod
|
||||
|
||||
|
||||
class DuplicateKeyError(ValueError):
|
||||
"""A mapping key stated twice, refused the way YamlDotNet refuses it.
|
||||
|
||||
A `ValueError` so a caller guarding a parse with `except ValueError` still
|
||||
catches it, and its own class so a test can name exactly this failure.
|
||||
The message leads with the same two words the real reader's does --
|
||||
`Duplicate key` -- because that string is what a person sees in Self Check
|
||||
and in `config_problems()`, and a double that produces a different one
|
||||
teaches the wrong thing about what to search for.
|
||||
"""
|
||||
|
||||
|
||||
def raise_on_duplicate_keys(text, path=None):
|
||||
"""Refuse `text` if any mapping in it states a key twice, at any depth.
|
||||
|
||||
PyYAML with the standard unique-key loader recipe: `construct_mapping` is
|
||||
called once per mapping node, so nesting is covered without walking the
|
||||
tree by hand, and `key_node.start_mark` gives the line and column of the
|
||||
SECOND statement of the key -- the one to delete.
|
||||
|
||||
PyYAML is used only as the duplicate DETECTOR; the value still comes back
|
||||
from `yamlkit`, whose grammar is the one the rest of this fake is built
|
||||
on. So a document PyYAML cannot parse for some other reason is passed
|
||||
through here rather than refused: a grammar disagreement between PyYAML
|
||||
and yamlkit is not evidence about YamlDotNet, and this checker has exactly
|
||||
one question. The cost is honest and worth writing down -- a duplicate key
|
||||
inside a document PyYAML rejects for an unrelated reason is not seen here.
|
||||
`tools/knowledgelint.py` reports that case, because it strict-parses the
|
||||
real files with PyYAML and nothing else.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
class _UniqueKeyLoader(yaml.SafeLoader):
|
||||
pass
|
||||
|
||||
def _construct_mapping(loader, node, deep=False):
|
||||
seen = set()
|
||||
for key_node, _value_node in node.value:
|
||||
key = loader.construct_object(key_node, deep=deep)
|
||||
try:
|
||||
duplicate = key in seen
|
||||
except TypeError: # an unhashable key: not our
|
||||
continue # question, and not our error
|
||||
if duplicate:
|
||||
mark = key_node.start_mark
|
||||
raise DuplicateKeyError(
|
||||
'Duplicate key: {0!r} at line {1}, column {2}{3} -- a '
|
||||
'mapping may state a key only once.'.format(
|
||||
key, mark.line + 1, mark.column + 1,
|
||||
' of {0}'.format(path) if path else ''))
|
||||
seen.add(key)
|
||||
return yaml.SafeLoader.construct_mapping(loader, node, deep=deep)
|
||||
|
||||
_UniqueKeyLoader.add_constructor(
|
||||
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping)
|
||||
|
||||
try:
|
||||
yaml.load(text, Loader=_UniqueKeyLoader)
|
||||
except DuplicateKeyError:
|
||||
raise
|
||||
except Exception: # see the docstring: not this question
|
||||
return
|
||||
|
||||
|
||||
def strings_only_yaml_reader():
|
||||
"""`load_as_dict` with yamlkit's grammar and YamlDotNet's (absent) types.
|
||||
|
||||
Strict about duplicate keys as well as untyped about scalars, because the
|
||||
reader it stands in for is both. See the module docstring.
|
||||
"""
|
||||
from vibe import yamlkit
|
||||
|
||||
def load_as_dict(path):
|
||||
handle = io.open(str(path), 'r', encoding='utf-8')
|
||||
try:
|
||||
text = handle.read()
|
||||
finally:
|
||||
handle.close()
|
||||
raise_on_duplicate_keys(text, path)
|
||||
return stringify_like_yamldotnet(yamlkit.parse(text))
|
||||
|
||||
return load_as_dict
|
||||
@@ -0,0 +1,381 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The batch worker must never throw, and must never look a module global up.
|
||||
|
||||
A pyRevit command can return while the thread it started is still running,
|
||||
and pyRevit then tears the command module's scope down. A thread that looks
|
||||
a module global up from that scope raises UnboundNameException -- on a
|
||||
non-main thread, which Revit cannot catch, so the whole process dies
|
||||
(measured 2026-08-27, Panel Lift Check batch thread, event log
|
||||
IronPython.PythonOps.GetVariable as the killer).
|
||||
|
||||
These tests build the same callable vibe.batchrun.make_worker returns,
|
||||
inject the failure modes that used to kill Revit -- a job that raises, a
|
||||
status path that cannot be written -- and assert the worker still does not
|
||||
raise, and that the error reached the status file (or was silently
|
||||
dropped when even that was impossible). Plus the static half: the
|
||||
worker's code object contains no name that would resolve to a module
|
||||
global of the command script; every free name is a parameter bound at
|
||||
definition time.
|
||||
"""
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
import threading as _threading
|
||||
import time
|
||||
|
||||
from vibe import batchrun
|
||||
|
||||
|
||||
class BoomIO(object):
|
||||
"""An io whose open always fails -- the status file cannot be written."""
|
||||
|
||||
def open(self, path, mode, encoding=None):
|
||||
raise IOError('cannot write status: {0}'.format(path))
|
||||
|
||||
|
||||
class RecorderIO(object):
|
||||
"""An io that records every write, so the test can read the log back."""
|
||||
|
||||
def __init__(self):
|
||||
self.writes = []
|
||||
|
||||
def open(self, path, mode, encoding=None):
|
||||
return _FakeFile(self)
|
||||
|
||||
|
||||
class _FakeFile(object):
|
||||
def __init__(self, io):
|
||||
self._io = io
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def write(self, text):
|
||||
self._io.writes.append(text)
|
||||
|
||||
|
||||
def test_worker_survives_a_job_that_raises_and_records_it():
|
||||
"""An exploding liftexport.run must not propagate; the ERROR line does."""
|
||||
recorder = RecorderIO()
|
||||
|
||||
def run_job(name, job_path, out_path):
|
||||
if name == 'A':
|
||||
raise RuntimeError('the calculation exploded')
|
||||
return None
|
||||
|
||||
worker = batchrun.make_worker(
|
||||
[['A', 'a.job.json'], ['B', 'b.job.json']],
|
||||
'batch-status.txt', run_job, io=recorder)
|
||||
|
||||
worker() # must not raise
|
||||
|
||||
text = '\n'.join(recorder.writes)
|
||||
assert 'BATCH RUNNING (2 panels)' in text
|
||||
assert 'ERROR A: the calculation exploded' in text
|
||||
assert 'DONE B' in text
|
||||
assert 'BATCH FINISHED' in text
|
||||
|
||||
|
||||
def test_worker_survives_an_unwritable_status_path():
|
||||
"""Even when every status write fails, nothing may escape the thread."""
|
||||
recorder = RecorderIO()
|
||||
|
||||
def run_job(name, job_path, out_path):
|
||||
return None
|
||||
|
||||
worker = batchrun.make_worker(
|
||||
[['A', 'a.job.json']], 'batch-status.txt', run_job, io=BoomIO())
|
||||
|
||||
worker() # must not raise
|
||||
assert recorder.writes == [] # and it truly dropped the log, not faked it
|
||||
|
||||
|
||||
def test_worker_continues_after_a_failed_job():
|
||||
"""One bad piece must not stop the rest of the batch.
|
||||
|
||||
workers=1 on purpose: this asserts the ORDER, and order is exactly what
|
||||
more than one worker is allowed to change.
|
||||
"""
|
||||
recorder = RecorderIO()
|
||||
ran = []
|
||||
|
||||
def run_job(name, job_path, out_path):
|
||||
ran.append(name)
|
||||
if name == 'A':
|
||||
raise RuntimeError('boom')
|
||||
|
||||
worker = batchrun.make_worker(
|
||||
[['A', 'a.job.json'], ['B', 'b.job.json'], ['C', 'c.job.json']],
|
||||
'batch-status.txt', run_job, io=recorder, workers=1)
|
||||
|
||||
worker()
|
||||
assert ran == ['A', 'B', 'C']
|
||||
text = '\n'.join(recorder.writes)
|
||||
assert 'DONE C' in text
|
||||
|
||||
|
||||
def test_one_worker_starts_no_thread_at_all():
|
||||
"""workers=1 is today's behaviour exactly, including the thread count."""
|
||||
recorder = RecorderIO()
|
||||
spawned = []
|
||||
|
||||
class CountingThreading(object):
|
||||
Lock = _threading.Lock
|
||||
|
||||
@staticmethod
|
||||
def Thread(target=None, args=()):
|
||||
spawned.append(target)
|
||||
return _threading.Thread(target=target, args=args)
|
||||
|
||||
worker = batchrun.make_worker(
|
||||
[['A', 'a.job.json']], 's', lambda *a: None, io=recorder,
|
||||
workers=1, threading=CountingThreading)
|
||||
worker()
|
||||
assert spawned == []
|
||||
|
||||
|
||||
def test_every_piece_runs_exactly_once_across_many_workers():
|
||||
"""The shared queue is the whole coordination risk: no piece may be
|
||||
computed twice and none may be skipped."""
|
||||
recorder = RecorderIO()
|
||||
ran = []
|
||||
lock = _threading.Lock()
|
||||
|
||||
def run_job(name, job_path, out_path):
|
||||
time.sleep(0.001) # long enough for the threads to overlap
|
||||
lock.acquire()
|
||||
ran.append(name)
|
||||
lock.release()
|
||||
|
||||
rows = [['P{0}'.format(i), 'p{0}.job.json'.format(i)] for i in range(60)]
|
||||
worker = batchrun.make_worker(rows, 's', run_job, io=recorder, workers=8)
|
||||
worker()
|
||||
|
||||
assert sorted(ran) == sorted(r[0] for r in rows)
|
||||
text = '\n'.join(recorder.writes)
|
||||
for row in rows:
|
||||
assert 'DONE {0}'.format(row[0]) in text
|
||||
assert text.count('BATCH FINISHED') == 1
|
||||
|
||||
|
||||
def test_the_batch_really_overlaps():
|
||||
"""Not just correct, actually concurrent -- otherwise the whole change
|
||||
bought nothing. Eight workers on eight jobs that each sleep must finish
|
||||
in well under eight sleeps."""
|
||||
recorder = RecorderIO()
|
||||
|
||||
def run_job(name, job_path, out_path):
|
||||
time.sleep(0.25)
|
||||
|
||||
rows = [['P{0}'.format(i), 'p{0}.job.json'.format(i)] for i in range(8)]
|
||||
worker = batchrun.make_worker(rows, 's', run_job, io=recorder, workers=8)
|
||||
start = time.time()
|
||||
worker()
|
||||
elapsed = time.time() - start
|
||||
assert elapsed < 1.0, elapsed # serial would be 2.0 s
|
||||
|
||||
|
||||
def test_finished_is_written_after_every_worker_has_stopped():
|
||||
"""BATCH FINISHED means finished. A reader polling the status file uses
|
||||
it to decide the reports are all on disk."""
|
||||
recorder = RecorderIO()
|
||||
|
||||
def run_job(name, job_path, out_path):
|
||||
time.sleep(0.02)
|
||||
|
||||
rows = [['P{0}'.format(i), 'p{0}.job.json'.format(i)] for i in range(20)]
|
||||
worker = batchrun.make_worker(rows, 's', run_job, io=recorder, workers=8)
|
||||
worker()
|
||||
lines = [w.strip() for w in recorder.writes if w.strip()]
|
||||
assert lines[-1] == 'BATCH FINISHED'
|
||||
assert len([l for l in lines if l.startswith('DONE')]) == 20
|
||||
|
||||
|
||||
def test_the_header_says_how_many_run_at_once():
|
||||
"""Eight at a time and one at a time look identical from outside, and
|
||||
they are 40 minutes apart on the reference sweep."""
|
||||
recorder = RecorderIO()
|
||||
worker = batchrun.make_worker(
|
||||
[['A', 'a.job.json'], ['B', 'b.job.json']], 's',
|
||||
lambda *a: None, io=recorder, workers=4)
|
||||
worker()
|
||||
text = '\n'.join(recorder.writes)
|
||||
assert 'BATCH RUNNING (2 panels)' in text # the old line survives
|
||||
assert '4 at a time' in text
|
||||
|
||||
|
||||
def test_a_thread_that_will_not_start_still_finishes_the_batch():
|
||||
"""No thread, no batch is not acceptable: the calling thread pulls too."""
|
||||
recorder = RecorderIO()
|
||||
ran = []
|
||||
|
||||
class NoThreads(object):
|
||||
Lock = _threading.Lock
|
||||
|
||||
@staticmethod
|
||||
def Thread(target=None, args=()):
|
||||
raise RuntimeError('out of threads')
|
||||
|
||||
worker = batchrun.make_worker(
|
||||
[['A', 'a.job.json'], ['B', 'b.job.json']], 's',
|
||||
lambda name, j, o: ran.append(name), io=recorder,
|
||||
workers=8, threading=NoThreads)
|
||||
worker() # must not raise
|
||||
assert ran == ['A', 'B']
|
||||
assert 'BATCH FINISHED' in '\n'.join(recorder.writes)
|
||||
|
||||
|
||||
def test_no_lock_falls_back_to_one_worker():
|
||||
"""Without a lock there is no safe shared queue, so it runs serially
|
||||
rather than racing one."""
|
||||
recorder = RecorderIO()
|
||||
ran = []
|
||||
|
||||
class NoLock(object):
|
||||
@staticmethod
|
||||
def Lock():
|
||||
raise RuntimeError('no locks here')
|
||||
|
||||
@staticmethod
|
||||
def Thread(target=None, args=()):
|
||||
raise AssertionError('must not spawn without a lock')
|
||||
|
||||
worker = batchrun.make_worker(
|
||||
[['A', 'a.job.json'], ['B', 'b.job.json']], 's',
|
||||
lambda name, j, o: ran.append(name), io=recorder,
|
||||
threading=NoLock)
|
||||
worker()
|
||||
assert ran == ['A', 'B']
|
||||
|
||||
|
||||
def test_a_malformed_row_no_longer_swallows_the_rest_of_the_batch():
|
||||
"""The serial version let the outer guard eat the whole remainder when a
|
||||
row would not unpack -- every piece after it silently never ran."""
|
||||
recorder = RecorderIO()
|
||||
ran = []
|
||||
|
||||
worker = batchrun.make_worker(
|
||||
[[], ['B', 'b.job.json']], 's',
|
||||
lambda name, j, o: ran.append(name), io=recorder, workers=1)
|
||||
worker() # must not raise
|
||||
assert ran == ['B']
|
||||
assert 'BATCH FINISHED' in '\n'.join(recorder.writes)
|
||||
|
||||
|
||||
# --------------------------------------------------- how many run at once
|
||||
|
||||
def test_worker_count_leaves_two_cores_for_revit():
|
||||
"""Revit is open and the user is still working in it."""
|
||||
assert batchrun.worker_count(_FakeOS('16')) == 8 # capped
|
||||
assert batchrun.worker_count(_FakeOS('8')) == 6
|
||||
assert batchrun.worker_count(_FakeOS('4')) == 2
|
||||
|
||||
|
||||
def test_worker_count_never_returns_zero():
|
||||
"""A batch that starts no workers computes nothing at all."""
|
||||
assert batchrun.worker_count(_FakeOS('2')) == 1
|
||||
assert batchrun.worker_count(_FakeOS('1')) == 1
|
||||
assert batchrun.worker_count(_FakeOS('')) == 1
|
||||
assert batchrun.worker_count(_FakeOS('not a number')) == 1
|
||||
|
||||
|
||||
def test_worker_count_is_capped():
|
||||
"""A 64-core workstation must not start 62 CPython subprocesses."""
|
||||
assert batchrun.worker_count(_FakeOS('64')) == batchrun.MAX_WORKERS
|
||||
|
||||
|
||||
class _FakeOS(object):
|
||||
"""An os that reports a given NUMBER_OF_PROCESSORS and nothing else.
|
||||
|
||||
No cpu_count attribute, deliberately: that is IronPython 2.7, where the
|
||||
environment variable is the only answer available.
|
||||
"""
|
||||
|
||||
def __init__(self, cores):
|
||||
self.environ = {'NUMBER_OF_PROCESSORS': cores}
|
||||
|
||||
|
||||
def test_worker_looks_up_no_module_global_names():
|
||||
"""Static half: every free name is a bound parameter, not a global.
|
||||
|
||||
co_names holds the names a code object resolves by LOAD_GLOBAL /
|
||||
LOAD_ATTR. The module globals that killed Revit -- io, os, liftexport,
|
||||
the caller's compute/status_path/run_job/python_exe -- must not be
|
||||
among them: they are all parameters bound at definition time, when the
|
||||
defining scope was still alive. (Method/attribute names like 'open'
|
||||
and 'write' belong in co_names and are fine; the point is that the
|
||||
MODULE names do not.)
|
||||
"""
|
||||
def quiet(name, job_path, out_path):
|
||||
return None
|
||||
|
||||
worker = batchrun.make_worker([], 's', quiet)
|
||||
|
||||
names = worker.__code__.co_names
|
||||
for forbidden in ('io', 'os', 'liftexport', 'compute', 'status_path',
|
||||
'run_job', 'python_exe',
|
||||
# added with the parallel batch: the module and the
|
||||
# helpers it needs are parameters too, and `worker_count`
|
||||
# is answered on the UI thread before any thread exists.
|
||||
'threading', 'worker_count', 'MAX_WORKERS',
|
||||
'SPARE_CORES', 'note', 'pump', 'take', 'lock'):
|
||||
assert forbidden not in names, forbidden
|
||||
|
||||
|
||||
def test_the_functions_that_run_on_worker_threads_look_up_nothing_either():
|
||||
"""There are now N threads that can kill Revit, not one.
|
||||
|
||||
`pump`, `take` and `note` run on the spawned threads, so they are held to
|
||||
the same rule as `worker`: every module name they need is a parameter
|
||||
bound in `make_worker`, while its scope was alive.
|
||||
"""
|
||||
holder = {}
|
||||
|
||||
class Spy(object):
|
||||
Lock = _threading.Lock
|
||||
|
||||
@staticmethod
|
||||
def Thread(target=None, args=()):
|
||||
holder['pump'] = target
|
||||
return _threading.Thread(target=target, args=args)
|
||||
|
||||
# A recorder io, like every sibling test: this one actually CALLS the
|
||||
# worker, and the real io would write a file named 's' into the CWD.
|
||||
worker = batchrun.make_worker([], 's', lambda *a: None, workers=2,
|
||||
threading=Spy, io=RecorderIO())
|
||||
worker()
|
||||
pump = holder.get('pump')
|
||||
assert pump is not None
|
||||
# pump's own free names, plus those of the take/note it was handed.
|
||||
seen = set(pump.__code__.co_names)
|
||||
for closed in pump.__defaults__ or ():
|
||||
code = getattr(closed, '__code__', None)
|
||||
if code is not None:
|
||||
seen |= set(code.co_names)
|
||||
for forbidden in ('io', 'os', 'liftexport', 'threading', 'batchrun',
|
||||
'status_path', 'run_job', 'lock', 'compute'):
|
||||
assert forbidden not in seen, forbidden
|
||||
|
||||
|
||||
def test_worker_survives_malformed_inputs():
|
||||
"""The outer guard covers statements before any inner try.
|
||||
|
||||
"A function whose contract is never raises must keep EVERY statement
|
||||
inside the protection, including the first one" -- list(compute),
|
||||
unpacking a row, and os.path.splitext can all raise before the inner
|
||||
guards run.
|
||||
"""
|
||||
recorder = RecorderIO()
|
||||
|
||||
def quiet(name, job_path, out_path):
|
||||
return None
|
||||
|
||||
malformed = [
|
||||
None, # compute not iterable
|
||||
[[], []], # row not unpackable
|
||||
[['A', None]], # job_path not a path
|
||||
]
|
||||
for compute in malformed:
|
||||
worker = batchrun.make_worker(compute, 's', quiet, io=recorder)
|
||||
worker() # must not raise, for every shape
|
||||
@@ -0,0 +1,578 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""`tools/rvt.py` must never hand back another request's answer.
|
||||
|
||||
THE INCIDENT (measured 2026-09-01, twice, during a 282-piece sweep)
|
||||
-------------------------------------------------------------------
|
||||
One request timed out client-side::
|
||||
|
||||
[260/282] no response from Revit within 300s.
|
||||
|
||||
and from then on the pairing was off by one. `tools/sweep.py`'s
|
||||
`open_model_title()` asked for `doc.Title` and was handed the SWEEP's progress
|
||||
string, which it printed as the document title; an agent polling `doc.Title` as
|
||||
a drift guard read the same stolen answer as "the model changed underneath me"
|
||||
and raised a false alarm that cost real time. Every file on disk was correct.
|
||||
Only the attribution was wrong -- and **a response from the wrong request is
|
||||
indistinguishable from a valid answer**, which is the exact shape of silence
|
||||
this tool is built against.
|
||||
|
||||
WHERE THE DESYNC LIVES -- not in this client
|
||||
--------------------------------------------
|
||||
`rvt.py` is stateless per request: `urlopen` opens a new connection each call
|
||||
and urllib sends `Connection: close`. There is no client socket to go out of
|
||||
step, so "drain the socket" would have been a fix for a mechanism that is not
|
||||
there. The shared state is inside pyRevit, in `routes/server/server.py`: ONE
|
||||
module-level `REQUEST_HNDLR` / `EVENT_HNDLR` pair, whose single `_response` /
|
||||
`_done` slot every connection thread of the ThreadingMixIn server resets, waits
|
||||
on, and reads. A client giving up cancels nothing -- the server thread keeps
|
||||
spinning and Revit still owes it an answer -- so the next request's thread can
|
||||
be released by, and read, the abandoned request's response.
|
||||
|
||||
These tests reproduce that against a stub server (no Revit, no licence, host
|
||||
CPython) and show it refused.
|
||||
|
||||
WHAT THIS SUITE CANNOT DO is prove the in-Revit half echoes anything: `startup.py`
|
||||
does `from pyrevit import routes` at module scope and cannot be imported here
|
||||
(same category as `vibe.ticketqc`; see `tests/conftest.py`). Its half is checked
|
||||
by source/AST, exactly as `test_bridge_query_channel.py` checks the JSON-body
|
||||
fix, and it does not take effect until Revit is RESTARTED (never a pyRevit
|
||||
reload). Until then the client meets an in-Revit half that echoes no id, which
|
||||
is the case `test_no_id_*` pins down.
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
_EXT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
TOOLS = os.path.join(os.path.dirname(_EXT), 'tools')
|
||||
if TOOLS not in sys.path:
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
import rvt # noqa: E402
|
||||
|
||||
STARTUP = os.path.join(_EXT, 'startup.py')
|
||||
|
||||
|
||||
# --------------------------------------------------------------- the stub bridge
|
||||
|
||||
class StubBridge(object):
|
||||
"""A fake bridge whose answers are decided by a policy callable.
|
||||
|
||||
`policy(path, method, body)` returns the JSON dict to answer with, or None
|
||||
to hang until the client gives up -- which is how the 300s timeout is
|
||||
reproduced in a second. Every request is recorded in `.requests`.
|
||||
"""
|
||||
|
||||
def __init__(self, policy, hang=2.0):
|
||||
self.policy = policy
|
||||
self.hang = hang
|
||||
self.requests = []
|
||||
self._stop = threading.Event()
|
||||
bridge = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = 'HTTP/1.0'
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def handle_one_request(self):
|
||||
try:
|
||||
BaseHTTPRequestHandler.handle_one_request(self)
|
||||
except Exception:
|
||||
self.close_connection = True
|
||||
|
||||
def _serve(self):
|
||||
length = int(self.headers.get('content-length') or 0)
|
||||
raw = self.rfile.read(length) if length else b''
|
||||
try:
|
||||
body = json.loads(raw.decode('utf-8')) if raw else {}
|
||||
except ValueError:
|
||||
body = {}
|
||||
path = urlparse(self.path).path
|
||||
bridge.requests.append((path, self.command, body))
|
||||
answer = bridge.policy(path, self.command, body)
|
||||
if answer is None:
|
||||
# The abandoned request: Revit is still working, the client
|
||||
# will time out, and the answer never reaches this socket.
|
||||
# Where it DOES go is the next test's problem, which is the
|
||||
# whole point.
|
||||
bridge._stop.wait(bridge.hang)
|
||||
self.close_connection = True
|
||||
return
|
||||
data = json.dumps(answer).encode('utf-8')
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
do_GET = _serve
|
||||
do_POST = _serve
|
||||
|
||||
self.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||
self.server.daemon_threads = True
|
||||
# Do not join the connection threads at close: one of them is
|
||||
# deliberately sitting on an abandoned request, which is the whole
|
||||
# point of the fixture and not something to wait out per test.
|
||||
self.server.block_on_close = False
|
||||
self.port = self.server.server_address[1]
|
||||
# 0.05s, not the 0.5s default: shutdown() polls at this interval and
|
||||
# half a second of teardown per test is most of this suite's runtime.
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, kwargs={'poll_interval': 0.05})
|
||||
self.thread.daemon = True
|
||||
self.thread.start()
|
||||
|
||||
def close(self):
|
||||
self._stop.set()
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
|
||||
def bodies(self):
|
||||
return [body for _path, _method, body in self.requests]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def serve(tmp_path, monkeypatch):
|
||||
"""Start a stub bridge and point every file/port `rvt.py` reads at it."""
|
||||
log = tmp_path / 'sessions.log'
|
||||
log.write_text('2026-09-01T09:00:00|4242|2026-09-01T08:00:00.0000000\n',
|
||||
encoding='utf-8')
|
||||
monkeypatch.setattr(rvt, 'SESSION_LOG', log)
|
||||
monkeypatch.setattr(rvt, 'LATCH_DIR', tmp_path)
|
||||
monkeypatch.setattr(rvt, 'TIMEOUT', 0.6)
|
||||
monkeypatch.setattr(rvt, 'DRAIN_TIMEOUTS', (0.6, 0.6))
|
||||
monkeypatch.setattr(rvt, '_STRICT', False)
|
||||
monkeypatch.delenv(rvt.STRICT_ENV, raising=False)
|
||||
|
||||
made = []
|
||||
|
||||
def _serve(policy, hang=2.0):
|
||||
bridge = StubBridge(policy, hang=hang)
|
||||
made.append(bridge)
|
||||
monkeypatch.setenv(rvt.PORT_ENV, str(bridge.port))
|
||||
return bridge
|
||||
|
||||
yield _serve
|
||||
|
||||
for bridge in made:
|
||||
bridge.close()
|
||||
|
||||
|
||||
def echo(answer):
|
||||
"""A policy that answers `answer`, stamped with the caller's own id."""
|
||||
def policy(path, method, body):
|
||||
out = dict(answer)
|
||||
if body.get(rvt.CORRELATION_KEY) is not None:
|
||||
out[rvt.CORRELATION_KEY] = body[rvt.CORRELATION_KEY]
|
||||
return out
|
||||
return policy
|
||||
|
||||
|
||||
def like_exec(body, default):
|
||||
"""What a healthy `/exec` would answer.
|
||||
|
||||
Enough of it to be honest about the one snippet shape that matters here:
|
||||
`result = '<literal>'`, which is what the drain probe sends. A stub that
|
||||
ignored the code would let a probe "identify itself" without the bridge
|
||||
having run anything, which is the opposite of what the probe proves.
|
||||
"""
|
||||
code = (body.get('code') or '').strip()
|
||||
match = re.match(r"^result = '([^']*)'$", code)
|
||||
if match:
|
||||
return {'ok': True, 'stdout': '', 'result': match.group(1)}
|
||||
return dict(default)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the normal path
|
||||
|
||||
def test_a_matching_id_returns_the_answer_unchanged(serve, capsys):
|
||||
"""The shape callers see must not move: id in, id checked, id stripped."""
|
||||
serve(echo({'ok': True, 'doc_title': 'sample_job', 'pid': 4242}))
|
||||
res = rvt.call('/status')
|
||||
assert res == {'ok': True, 'doc_title': 'sample_job', 'pid': 4242}
|
||||
assert rvt.CORRELATION_KEY not in res, (
|
||||
'the correlation id leaked into the value handed to callers -- '
|
||||
'sweep.py and snapshot_cycle.py parse these dicts')
|
||||
assert rvt.UNVERIFIED_KEY not in res
|
||||
assert capsys.readouterr().err == '', (
|
||||
'a verified answer must be silent; a warning on every good call is a '
|
||||
'warning nobody reads')
|
||||
|
||||
|
||||
def test_every_request_carries_an_id_including_the_GET_routes(serve):
|
||||
"""The id travels in the JSON body, on GET too.
|
||||
|
||||
Not a header: pyRevit's `base.Request` sets `_headers = {}` and
|
||||
`HttpRequestHandler._prepare_request` never fills it, so a header would be
|
||||
discarded exactly as the query string is. The server parses a body whenever
|
||||
Content-Length is set, whatever the method -- so this is the one channel
|
||||
that reaches `/status` and `/selection` as well as the POST routes.
|
||||
"""
|
||||
bridge = serve(echo({'ok': True}))
|
||||
rvt.call('/status')
|
||||
rvt.call('/params', 'POST', {'id': 123456})
|
||||
|
||||
path, method, body = bridge.requests[0]
|
||||
assert (path, method) == ('/vibe/status', 'GET')
|
||||
assert body.get(rvt.CORRELATION_KEY), (
|
||||
'the GET route sent no correlation id -- a GET with no body is exactly '
|
||||
'the request whose answer cannot be attributed')
|
||||
|
||||
_path, _method, body2 = bridge.requests[1]
|
||||
assert body2['id'] == 123456, 'the payload must survive the stamping'
|
||||
assert body2[rvt.CORRELATION_KEY] != body[rvt.CORRELATION_KEY], (
|
||||
'two requests got the same id -- then a match proves nothing')
|
||||
|
||||
|
||||
# ------------------------------------------------------------- a stolen answer
|
||||
|
||||
def test_a_mismatched_id_is_refused_and_names_both_ids(serve, tmp_path, capsys):
|
||||
"""The refusal is loud, and it is a refusal -- never a returned value."""
|
||||
serve(lambda p, m, b: {'ok': True, 'result': 'did 10 of 282',
|
||||
rvt.CORRELATION_KEY: 'somebody-elses-id'})
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
rvt.call('/exec', 'POST', {'code': 'result = doc.Title'})
|
||||
assert exc.value.code == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'somebody-elses-id' in err, 'the id that answered must be named'
|
||||
assert 'REFUSING' in err
|
||||
assert 'did 10 of 282' not in err, (
|
||||
"the stolen body must not be printed as if it were an answer")
|
||||
assert list(tmp_path.glob('.bridge-timeout-*.json')), (
|
||||
'a proven mispairing must latch the channel: it is proof the bridge '
|
||||
'is serving somebody else\'s answers, and the next call must drain')
|
||||
|
||||
|
||||
# --------------------------------------------------- an in-Revit half with no id
|
||||
|
||||
def test_no_id_warns_and_flags_but_still_answers(serve, capsys):
|
||||
"""The default against an OLD in-Revit half: warn, flag, return.
|
||||
|
||||
Refusing here would brick the bridge for every tool until the owner
|
||||
restarts Revit -- and the in-Revit half cannot be loaded any other way,
|
||||
because reloading pyRevit kills Revit outright. A third state (`_unverified`
|
||||
plus a warning naming the risk) keeps "could not judge" distinct from
|
||||
"judged" without turning a data-attribution hazard into an outage.
|
||||
"""
|
||||
serve(lambda p, m, b: {'ok': True, 'doc_title': 'sample_job'})
|
||||
res = rvt.call('/status')
|
||||
assert res['ok'] is True
|
||||
assert res[rvt.UNVERIFIED_KEY] is True, (
|
||||
'an unattributable answer must carry its own doubt into the value; a '
|
||||
'warning on a stream somebody captured is not enough')
|
||||
err = capsys.readouterr().err
|
||||
assert 'could not be attributed' in err
|
||||
assert 'RESTART Revit' in err, 'the warning must say how to fix it'
|
||||
|
||||
|
||||
def test_no_id_is_refused_under_strict(serve, monkeypatch):
|
||||
serve(lambda p, m, b: {'ok': True, 'doc_title': 'sample_job'})
|
||||
monkeypatch.setattr(rvt, '_STRICT', True)
|
||||
with pytest.raises(SystemExit):
|
||||
rvt.call('/status')
|
||||
|
||||
|
||||
def test_no_id_is_refused_under_the_strict_env_var(serve, monkeypatch):
|
||||
"""`sweep.py` and `snapshot_cycle.py` shell out; the env is how they opt in."""
|
||||
serve(lambda p, m, b: {'ok': True, 'doc_title': 'sample_job'})
|
||||
monkeypatch.setenv(rvt.STRICT_ENV, '1')
|
||||
with pytest.raises(SystemExit):
|
||||
rvt.call('/status')
|
||||
|
||||
|
||||
def test_strict_flag_reaches_the_client(serve, monkeypatch):
|
||||
"""`rvt.py --strict status` -- the flag sits before the subcommand."""
|
||||
serve(echo({'ok': True, 'doc_title': 'sample_job'}))
|
||||
monkeypatch.setattr(rvt, '_STRICT', False)
|
||||
assert rvt.main(['--strict', 'status']) == 0
|
||||
assert rvt._STRICT is True
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the timeout
|
||||
|
||||
def test_a_timeout_is_an_explicit_could_not_judge(serve, tmp_path, capsys):
|
||||
"""A timed-out call may not return anything a caller can read as data."""
|
||||
serve(lambda p, m, b: None)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
rvt.call('/exec', 'POST', {'code': 'result = doc.Title'})
|
||||
assert exc.value.code == 1, (
|
||||
'a timeout must be a non-zero exit: sweep.py reads the exit code, and '
|
||||
'a zero would make an empty stdout look like an empty answer')
|
||||
err = capsys.readouterr().err
|
||||
assert 'COULD NOT JUDGE' in err
|
||||
assert 'may still be running inside' in err
|
||||
latches = list(tmp_path.glob('.bridge-timeout-*.json'))
|
||||
assert len(latches) == 1, 'the timeout must latch the channel'
|
||||
latch = json.loads(latches[0].read_text(encoding='utf-8'))
|
||||
assert latch['path'] == '/exec'
|
||||
assert latch['port'] == rvt._port(), (
|
||||
'the latch is per port -- which Revit owns which port is decided by '
|
||||
'start order, so one session\'s timeout must not gate another\'s call')
|
||||
|
||||
|
||||
def test_the_latch_is_per_port(serve, tmp_path, monkeypatch):
|
||||
serve(lambda p, m, b: None)
|
||||
with pytest.raises(SystemExit):
|
||||
rvt.call('/status')
|
||||
monkeypatch.setenv(rvt.PORT_ENV, str(rvt._port() + 1))
|
||||
assert rvt._read_latch() is None, (
|
||||
'a latch written for one port blocked a call to another')
|
||||
|
||||
|
||||
# --------------------------------------------- the desync, reproduced and fixed
|
||||
|
||||
def _desync_policy(state, stale_body, good_body, stamp=True):
|
||||
"""Hang once, then serve the abandoned request's answer to the next caller.
|
||||
|
||||
That is what pyRevit's single shared response slot does after a client-side
|
||||
timeout: the late answer is still sitting there when the next connection
|
||||
thread reads it. It is served exactly once, because reading it is what
|
||||
takes it out of the slot.
|
||||
"""
|
||||
def policy(path, method, body):
|
||||
rid = body.get(rvt.CORRELATION_KEY)
|
||||
if state['phase'] == 'hang':
|
||||
state['phase'] = 'stale'
|
||||
state['rid_a'] = rid
|
||||
return None
|
||||
if state['phase'] == 'stale':
|
||||
state['phase'] = 'normal'
|
||||
out = dict(stale_body)
|
||||
if stamp and state['rid_a'] is not None:
|
||||
out[rvt.CORRELATION_KEY] = state['rid_a']
|
||||
return out
|
||||
out = like_exec(body, good_body)
|
||||
if stamp and rid is not None:
|
||||
out[rvt.CORRELATION_KEY] = rid
|
||||
return out
|
||||
return policy
|
||||
|
||||
|
||||
def test_the_2026_09_01_desync_is_drained_instead_of_returned(serve, tmp_path,
|
||||
capsys):
|
||||
"""The whole incident, end to end, against the stub.
|
||||
|
||||
Call A times out. Call B asks for `doc.Title` and, on a bridge with a stale
|
||||
answer in the slot, would be handed "did 10 of 282" -- which is exactly
|
||||
what `open_model_title()` printed as a document title on 2026-09-01. Here
|
||||
the drain consumes the stale answer before B is sent, and B gets its own.
|
||||
"""
|
||||
state = {'phase': 'hang'}
|
||||
stale = {'ok': True, 'stdout': 'did 10 of 282\n', 'result': 'did 10 of 282'}
|
||||
bridge = serve(_desync_policy(state, stale,
|
||||
{'ok': True, 'result': 'sample_job'}))
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
rvt.call('/exec', 'POST', {'code': 'measure()'})
|
||||
capsys.readouterr()
|
||||
|
||||
res = rvt.call('/exec', 'POST', {'code': 'result = doc.Title'})
|
||||
assert res == {'ok': True, 'result': 'sample_job'}, (
|
||||
'the second call was served the first call\'s body -- this is the '
|
||||
'defect, not a variation of it')
|
||||
assert res['result'] != 'did 10 of 282'
|
||||
|
||||
err = capsys.readouterr().err
|
||||
assert 'Draining' in err and 'channel clear' in err, (
|
||||
'the drain must say it happened: a channel silently repaired is a '
|
||||
'channel nobody knows was broken')
|
||||
assert not list(tmp_path.glob('.bridge-timeout-*.json')), (
|
||||
'a drained channel must clear its latch, or every later call pays for '
|
||||
'one timeout forever')
|
||||
|
||||
# The stale answer went to a drain probe, not to the caller. The probe is
|
||||
# an /exec whose snippet returns a nonce, so it is self-identifying.
|
||||
codes = [b.get('code', '') for b in bridge.bodies()]
|
||||
assert any('vibe-drain-' in c for c in codes), (
|
||||
'nothing was actually sent to consume the stale reply')
|
||||
|
||||
|
||||
def test_the_desync_is_drained_against_an_old_in_revit_half(serve, capsys):
|
||||
"""The same recovery, with an in-Revit half that echoes no ids at all.
|
||||
|
||||
This is the half that is running until the owner restarts Revit, so it is
|
||||
the case that matters most today. The drain does not depend on the echo:
|
||||
its probe asks Revit to return a nonce this process just invented, and an
|
||||
answer whose `result` is not that nonce is somebody else's, id or no id.
|
||||
"""
|
||||
state = {'phase': 'hang'}
|
||||
serve(_desync_policy(state,
|
||||
{'ok': True, 'result': 'did 10 of 282'},
|
||||
{'ok': True, 'result': 'sample_job'},
|
||||
stamp=False))
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
rvt.call('/exec', 'POST', {'code': 'measure()'})
|
||||
capsys.readouterr()
|
||||
|
||||
res = rvt.call('/exec', 'POST', {'code': 'result = doc.Title'})
|
||||
assert res['result'] == 'sample_job'
|
||||
assert res[rvt.UNVERIFIED_KEY] is True, (
|
||||
'an old half still cannot attribute its answer, and the drain does not '
|
||||
'change that -- it only removes the stale one from the channel')
|
||||
|
||||
|
||||
def test_a_channel_that_stays_dirty_refuses_rather_than_guesses(serve, capsys):
|
||||
"""When the drain cannot identify itself, nothing real is sent."""
|
||||
def policy(path, method, body):
|
||||
if not policy.hung:
|
||||
policy.hung = True
|
||||
return None
|
||||
return {'ok': True, 'result': 'did 10 of 282'} # forever somebody else's
|
||||
policy.hung = False
|
||||
serve(policy)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
rvt.call('/status')
|
||||
capsys.readouterr()
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
rvt.call('/exec', 'POST', {'code': 'result = doc.Title'})
|
||||
assert exc.value.code == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'REFUSING to send' in err
|
||||
assert 'cannot be proven clean' in err
|
||||
|
||||
|
||||
def test_a_latched_channel_whose_bridge_refuses_does_not_pretend_to_be_clean(
|
||||
serve, capsys):
|
||||
"""A refusal answers "which session am I on", not "is the channel clean"."""
|
||||
def policy(path, method, body):
|
||||
if not policy.hung:
|
||||
policy.hung = True
|
||||
return None
|
||||
return {'ok': False, 'refused': True, 'doc_title': 'sample_job',
|
||||
'error': 'REFUSED: bridge.refuse_titles'}
|
||||
policy.hung = False
|
||||
serve(policy)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
rvt.call('/status')
|
||||
capsys.readouterr()
|
||||
with pytest.raises(SystemExit):
|
||||
rvt.call('/status')
|
||||
assert 'refusing every route' in capsys.readouterr().err
|
||||
|
||||
|
||||
# ------------------------------------------------- the in-Revit half, by source
|
||||
|
||||
def _route_functions(tree):
|
||||
"""Every function carrying an `@api.route(...)` decorator."""
|
||||
out = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.FunctionDef):
|
||||
continue
|
||||
for dec in node.decorator_list:
|
||||
if (isinstance(dec, ast.Call)
|
||||
and isinstance(dec.func, ast.Attribute)
|
||||
and dec.func.attr == 'route'):
|
||||
path = (dec.args[0].value
|
||||
if dec.args and isinstance(dec.args[0], ast.Constant)
|
||||
else None)
|
||||
out.append((path, node))
|
||||
return out
|
||||
|
||||
|
||||
def _startup_tree():
|
||||
with open(STARTUP, 'r', encoding='utf-8') as fh:
|
||||
return ast.parse(fh.read())
|
||||
|
||||
|
||||
def test_every_route_takes_request_and_replies_through_reply():
|
||||
"""A new route that forgets to echo is a new silent desync.
|
||||
|
||||
Enforced here rather than trusted, because the failure is invisible from
|
||||
inside Revit: an unstamped answer looks exactly like a correct one, and the
|
||||
client would only report it as unverifiable long after the route shipped.
|
||||
"""
|
||||
tree = _startup_tree()
|
||||
routes = _route_functions(tree)
|
||||
assert routes, 'no @api.route handlers found in startup.py -- did it move?'
|
||||
for path, node in routes:
|
||||
args = [a.arg for a in node.args.args]
|
||||
assert 'request' in args, (
|
||||
'{0} ({1}) does not take `request`, so it cannot echo the caller\'s '
|
||||
'correlation id and every answer it gives is unattributable.'
|
||||
.format(path, node.name))
|
||||
calls = [n.func.id for n in ast.walk(node)
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)]
|
||||
assert '_reply' in calls, (
|
||||
'{0} ({1}) does not return through _reply(request, ...) -- its '
|
||||
'answers carry no correlation id, and rvt.py cannot tell one of '
|
||||
'them from an abandoned request\'s answer.'.format(path, node.name))
|
||||
|
||||
|
||||
def test_the_in_revit_half_reads_the_id_from_the_body_only():
|
||||
"""Not from a header. `Request._headers` is never populated by the server."""
|
||||
tree = _startup_tree()
|
||||
funcs = {n.name: n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
|
||||
assert '_rid_of' in funcs and '_reply' in funcs
|
||||
assert '_body' in [n.func.id for n in ast.walk(funcs['_rid_of'])
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)]
|
||||
hits = [n for n in ast.walk(tree)
|
||||
if isinstance(n, ast.Attribute) and n.attr == 'headers']
|
||||
assert not hits, (
|
||||
'startup.py reads request headers at line(s) {0} -- pyRevit\'s routes '
|
||||
'server builds its Request without them (`_headers = {{}}`, never '
|
||||
'filled), so that channel is as silently empty as the query string was.'
|
||||
.format([getattr(n, 'lineno', '?') for n in hits]))
|
||||
|
||||
|
||||
def test_client_and_server_agree_on_the_key():
|
||||
"""One string, two files. A typo here is a bridge that never correlates."""
|
||||
tree = _startup_tree()
|
||||
keys = [n.value.value for n in ast.walk(tree)
|
||||
if isinstance(n, ast.Assign)
|
||||
and any(isinstance(t, ast.Name) and t.id == 'RID_KEY'
|
||||
for t in n.targets)
|
||||
and isinstance(n.value, ast.Constant)]
|
||||
assert keys == [rvt.CORRELATION_KEY], (
|
||||
'startup.py stamps {0!r} but tools/rvt.py checks {1!r}'
|
||||
.format(keys, rvt.CORRELATION_KEY))
|
||||
|
||||
|
||||
def test_the_drain_probe_snippet_is_a_valid_ironpython_statement():
|
||||
"""The probe is code that runs inside Revit; it has to be legal there.
|
||||
|
||||
IronPython 2.7: no f-strings, and `result = <literal>` is a statement, so
|
||||
`/exec` falls through to its exec branch and reads `result` back out.
|
||||
"""
|
||||
nonce = 'vibe-drain-deadbeef'
|
||||
snippet = 'result = {0!r}'.format(nonce)
|
||||
ns = {}
|
||||
exec(compile(snippet, '<probe>', 'exec'), ns)
|
||||
assert ns['result'] == nonce
|
||||
|
||||
|
||||
def test_call_still_refuses_a_bridge_that_survived_a_reload(serve, tmp_path,
|
||||
capsys):
|
||||
"""The never-reload tripwire runs BEFORE any of this. Order matters.
|
||||
|
||||
Correlation is about which answer you got; the reload guard is about
|
||||
whether asking at all kills Revit. A drain probe sent into a post-reload
|
||||
bridge would be the fatal request.
|
||||
"""
|
||||
serve(echo({'ok': True}))
|
||||
log = tmp_path / 'sessions.log'
|
||||
log.write_text('2026-09-01T09:00:00|4242|2026-09-01T08:00:00.0000000\n'
|
||||
'2026-09-01T09:30:00|4242|2026-09-01T08:00:00.0000000\n',
|
||||
encoding='utf-8')
|
||||
(tmp_path / '.bridge-timeout-{0}.json'.format(rvt._port())).write_text(
|
||||
'{"path": "/exec", "reason": "no answer", "port": %d}' % rvt._port(),
|
||||
encoding='utf-8')
|
||||
with pytest.raises(SystemExit):
|
||||
rvt.call('/status')
|
||||
err = capsys.readouterr().err
|
||||
assert 'reloaded' in err
|
||||
assert 'Draining' not in err, (
|
||||
'the drain probe was sent to a bridge that has survived a pyRevit '
|
||||
'reload -- that request is the one that kills Revit')
|
||||
@@ -0,0 +1,191 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Regression guard for the `rvt.py params <id>` AttributeError.
|
||||
|
||||
An agent hit this on 2026-08-31: `/params` read `request.query_params`, and
|
||||
pyRevit's routes `Request` (`pyrevit/routes/server/base.py`, read from
|
||||
`%APPDATA%\\pyRevit-Master\\pyrevitlib\\pyrevit\\` on this machine, pyRevit
|
||||
5.0.1) has no such attribute -- it exposes `path`, `method`, `data`,
|
||||
`headers`, `params` (route-*pattern* captures like `<int:id>`, not the query
|
||||
string) and `callback_url`. Worse, there is no query-string channel to read
|
||||
under ANY name on this version: `routes/server/server.py`'s
|
||||
`HttpRequestHandler._parse_api_path` builds the routed path from
|
||||
`urlparse(self.path).path` and drops `.query` entirely before a `Request` is
|
||||
ever constructed, so query-string arguments never survive to reach handler
|
||||
code at all here.
|
||||
|
||||
The fix moves `/collect` and `/params` onto the same channel `/exec` already
|
||||
used successfully: a JSON POST body, read through `startup.py`'s `_body()`.
|
||||
`startup.py` cannot be imported here -- it does `from pyrevit import routes`
|
||||
at module scope, a hard host dependency, same category as `vibe.ticketqc`
|
||||
(see `tests/conftest.py`'s docstring) -- so this suite checks the source
|
||||
text/AST instead, the same approach `test_button_tooltips.py` uses for
|
||||
bundle/script content that also cannot be imported on host CPython.
|
||||
|
||||
This suite cannot open Revit, so it cannot prove the endpoints work live.
|
||||
See CLAUDE.md Sec 6 for the one bridge call that does, run by a human after a
|
||||
Revit restart (never a pyRevit reload).
|
||||
"""
|
||||
import ast
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_EXT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
STARTUP = os.path.join(_EXT, 'startup.py')
|
||||
RVT_PY = os.path.join(os.path.dirname(_EXT), 'tools', 'rvt.py')
|
||||
|
||||
|
||||
def _read(path):
|
||||
with open(path, 'r', encoding='utf-8') as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def _route_decorators(tree):
|
||||
"""Every `@api.route(...)` call in the module, as (path, methods, func_name).
|
||||
|
||||
`methods` is the list of method strings if given as a literal list of
|
||||
string constants, else None (not expected here, but a test that could
|
||||
not see a change is worse than one that fails loudly).
|
||||
"""
|
||||
found = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.FunctionDef):
|
||||
continue
|
||||
for dec in node.decorator_list:
|
||||
if not isinstance(dec, ast.Call):
|
||||
continue
|
||||
func = dec.func
|
||||
if not (isinstance(func, ast.Attribute) and func.attr == 'route'):
|
||||
continue
|
||||
path = None
|
||||
if dec.args and isinstance(dec.args[0], ast.Constant):
|
||||
path = dec.args[0].value
|
||||
methods = None
|
||||
for kw in dec.keywords:
|
||||
if kw.arg == 'methods' and isinstance(kw.value, ast.List):
|
||||
vals = [e.value for e in kw.value.elts
|
||||
if isinstance(e, ast.Constant)]
|
||||
methods = vals
|
||||
found.append((path, methods, node.name))
|
||||
return found
|
||||
|
||||
|
||||
def test_startup_never_reads_query_params():
|
||||
"""The exact AttributeError this suite exists for must never come back.
|
||||
|
||||
`request.query_params` does not exist on pyRevit 5.0.1's `Request`, and
|
||||
swapping in some other version's attribute name (`request.query`,
|
||||
`request.args`, ...) would just trade this machine's break for another
|
||||
machine's -- CLAUDE.md's rule on guarding API names that moved between
|
||||
versions applies here too. The fix removes the query-string channel
|
||||
entirely rather than guessing a name for it, so no CODE in this file may
|
||||
access it again.
|
||||
|
||||
AST-based, not a text search: the word `query_params` legitimately
|
||||
appears in this file's own comments and docstrings (explaining exactly
|
||||
this history), and a text search would flag its own explanation.
|
||||
"""
|
||||
tree = ast.parse(_read(STARTUP))
|
||||
hits = [n for n in ast.walk(tree)
|
||||
if isinstance(n, ast.Attribute) and n.attr == 'query_params']
|
||||
assert not hits, (
|
||||
'startup.py reads .query_params again at line(s) {0} -- that '
|
||||
'attribute does not exist on pyRevit 5.0.1\'s routes Request and '
|
||||
'raises exactly the AttributeError this test guards against. Route '
|
||||
'arguments belong in a JSON body (see _body()), not a query string.'
|
||||
.format([getattr(n, 'lineno', '?') for n in hits])
|
||||
)
|
||||
|
||||
|
||||
def test_body_helper_exists_and_is_shared():
|
||||
"""`_body()` is the one channel every argument-taking route should use."""
|
||||
tree = ast.parse(_read(STARTUP))
|
||||
names = {n.name for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
|
||||
assert '_body' in names, (
|
||||
'_body() is gone from startup.py -- /exec, /collect and /params were '
|
||||
'all rewritten to share it so a query-string channel never comes '
|
||||
'back through only one of them.'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('route_path,expect_post', [
|
||||
('/collect', True),
|
||||
('/params', True),
|
||||
('/exec', True),
|
||||
('/status', False),
|
||||
('/selection', False),
|
||||
])
|
||||
def test_argument_taking_routes_are_post(route_path, expect_post):
|
||||
"""`/collect` and `/params` now take POST + JSON body, like `/exec`.
|
||||
|
||||
`/status` and `/selection` take no arguments and are deliberately left
|
||||
as GET -- this parametrization pins both sides so a future change to
|
||||
either group is a visible decision, not a silent drift.
|
||||
"""
|
||||
tree = ast.parse(_read(STARTUP))
|
||||
routes = {path: methods for path, methods, _name in _route_decorators(tree)}
|
||||
assert route_path in routes, (
|
||||
'{0} route not found in startup.py -- did it get renamed or '
|
||||
'removed?'.format(route_path)
|
||||
)
|
||||
methods = routes[route_path]
|
||||
assert methods is not None, (
|
||||
'{0}\'s @api.route(...) does not declare methods as a literal list; '
|
||||
'this test cannot see what it allows.'.format(route_path)
|
||||
)
|
||||
if expect_post:
|
||||
assert methods == ['POST'], (
|
||||
'{0} is registered with methods={1!r}, expected [\'POST\'] -- '
|
||||
'arguments travel as a JSON body now, not a query string.'
|
||||
.format(route_path, methods)
|
||||
)
|
||||
else:
|
||||
assert 'POST' not in methods, (
|
||||
'{0} takes no arguments and was not expected to move to POST; '
|
||||
'got methods={1!r}.'.format(route_path, methods)
|
||||
)
|
||||
|
||||
|
||||
def test_collect_and_params_read_body_not_query_string():
|
||||
"""Both handlers call `_body(request)`, not `request.query_params`."""
|
||||
tree = ast.parse(_read(STARTUP))
|
||||
funcs = {n.name: n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
|
||||
for fn_name in ('vibe_collect', 'vibe_params'):
|
||||
assert fn_name in funcs, '{0} missing from startup.py'.format(fn_name)
|
||||
body_src = ast.dump(funcs[fn_name])
|
||||
assert '_body' in body_src, (
|
||||
'{0} no longer calls _body(request) -- check it still reads '
|
||||
'arguments from the JSON body.'.format(fn_name)
|
||||
)
|
||||
|
||||
|
||||
def test_rvt_py_client_sends_json_body_not_query_string():
|
||||
"""`rvt.py`'s client-side half of the same fix.
|
||||
|
||||
`call()` used to build a `?key=value` query string for /collect and
|
||||
/params; that string was silently discarded server-side on pyRevit
|
||||
5.0.1 (see this file's module docstring), so it must be gone from both
|
||||
the helper and the two callers.
|
||||
"""
|
||||
source = _read(RVT_PY)
|
||||
assert 'query=' not in source, (
|
||||
'tools/rvt.py still builds a query= argument somewhere -- the '
|
||||
'bridge server on pyRevit 5.0.1 never reads it back out, so this is '
|
||||
'a silent no-op waiting to happen again.'
|
||||
)
|
||||
assert 'urlencode' not in source, (
|
||||
'tools/rvt.py still references urlencode -- the query-string '
|
||||
'channel it built was removed along with the last caller.'
|
||||
)
|
||||
|
||||
tree = ast.parse(source)
|
||||
funcs = {n.name: n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
|
||||
for fn_name in ('cmd_collect', 'cmd_params'):
|
||||
assert fn_name in funcs, '{0} missing from tools/rvt.py'.format(fn_name)
|
||||
src = ast.dump(funcs[fn_name])
|
||||
assert "value='POST'" in src, (
|
||||
'{0} no longer calls call(..., "POST", ...) -- /collect and '
|
||||
'/params both require POST now that arguments travel in the '
|
||||
'body.'.format(fn_name)
|
||||
)
|
||||
@@ -0,0 +1,514 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The live-model guard: what the bridge refuses to serve, and why.
|
||||
|
||||
This is the test the commission asked to be possible at the FUNCTION level
|
||||
rather than as a source scan. `test_bridge_query_channel.py` had to parse
|
||||
`startup.py`'s AST because that file does `from pyrevit import routes` at
|
||||
module scope and cannot be imported off-Revit. The rule this suite exercises
|
||||
was deliberately put in `vibe/bridgeguard.py` instead -- which imports nothing
|
||||
that touches Revit and reads no files -- so the decision itself can be tested
|
||||
directly, and `startup.py` is left holding only the plumbing.
|
||||
|
||||
THE CASE IT ALL TURNS ON
|
||||
-------------------------
|
||||
The snapshot copy carries the SAME `doc.Title` as the model it was copied
|
||||
from. That is not sloppiness, it is `snapshotmeta.plan`'s default layout doing
|
||||
its job: the job folder is keyed on the title, so the stamp goes in the folder
|
||||
name and the title stays put. Which means a denylist of titles, on its own,
|
||||
refuses the sandbox exactly as hard as it refuses the live model -- and a
|
||||
bridge that refuses everything is a bridge nobody keeps.
|
||||
|
||||
So every test below is really about the same question: given two documents
|
||||
with the same name, can the guard tell which one is the copy?
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe import bridgeguard as G
|
||||
|
||||
# Built rather than spelled out, for the same reason `test_snapshotmeta.py`
|
||||
# builds its filenames: a literal <word>.rvt in a tracked file reads as a
|
||||
# model filename to `tools/scrub.py` and fails the leak audit.
|
||||
LIVE = 'sample_job'
|
||||
SNAP_ROOT = os.path.join('C:', os.sep, 'snapshots')
|
||||
SNAP_FILE = os.path.join(SNAP_ROOT, LIVE + '_20260901-0930',
|
||||
LIVE + G.MODEL_EXT)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ normalisation
|
||||
|
||||
@pytest.mark.parametrize('given', [
|
||||
LIVE, LIVE + G.MODEL_EXT, ' ' + LIVE + ' ', LIVE.upper(),
|
||||
LIVE.upper() + G.MODEL_EXT.upper(),
|
||||
])
|
||||
def test_a_title_compares_the_same_however_revit_spelled_it(given):
|
||||
"""`doc.Title` comes back with and without the extension depending on
|
||||
version and on how the document was opened. Both have to match the same
|
||||
denylist entry or the guard protects one machine and not the next."""
|
||||
assert G.normalize_title(given) == G.normalize_title(LIVE)
|
||||
|
||||
|
||||
def test_normalising_never_raises_on_rubbish():
|
||||
for value in (None, 0, [], object()):
|
||||
assert isinstance(G.normalize_title(value), str)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('raw,expect', [
|
||||
(None, []),
|
||||
('', []),
|
||||
([], []),
|
||||
('one', ['one']),
|
||||
('one;two', ['one', 'two']),
|
||||
(' one ; ; two ', ['one', 'two']),
|
||||
(['one', ' two '], ['one', 'two']),
|
||||
])
|
||||
def test_a_denylist_reads_the_same_from_yaml_or_from_the_environment(raw,
|
||||
expect):
|
||||
"""A list is what the YAML layer produces and a `;` string is what an
|
||||
environment variable produces. A bare string in the YAML is a predictable
|
||||
mis-edit and is read as one entry, never as a list of characters."""
|
||||
assert G.split_titles(raw) == expect
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- paths
|
||||
|
||||
def test_a_path_inside_a_root_is_inside_it_whatever_the_slashes():
|
||||
assert G.is_under('C:/snapshots/a/b' + G.MODEL_EXT, SNAP_ROOT)
|
||||
assert G.is_under(SNAP_FILE, 'c:\\SNAPSHOTS')
|
||||
|
||||
|
||||
def test_a_sibling_folder_with_the_same_prefix_is_not_inside():
|
||||
"""The bug a bare `startswith` ships with: `C:\\snapshots-old` is not
|
||||
under `C:\\snapshots`, and a sandbox root that swallowed its neighbours
|
||||
would hand the guard's exemption to files nobody meant to exempt."""
|
||||
assert not G.is_under('C:\\snapshots-old\\x' + G.MODEL_EXT, SNAP_ROOT)
|
||||
|
||||
|
||||
def test_no_path_is_under_nothing():
|
||||
assert not G.is_under('', SNAP_ROOT)
|
||||
assert not G.is_under(SNAP_FILE, '')
|
||||
assert not G.is_under(None, None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- the verdict
|
||||
|
||||
def test_an_empty_denylist_protects_nothing_and_says_so_by_allowing():
|
||||
"""The default, and it has to stay the default: the tool does not know
|
||||
which of a machine's models is somebody's live work, and inventing an
|
||||
answer would refuse a bridge on a machine that never asked for one."""
|
||||
assert G.refusal(LIVE, None, []) is None
|
||||
assert G.refusal(LIVE, None, None) is None
|
||||
|
||||
|
||||
def test_the_live_cloud_model_is_refused_and_the_message_names_it():
|
||||
"""The whole point. A cloud model has no local `PathName`, so it can
|
||||
offer neither alibi, and the refusal is the outcome."""
|
||||
reason = G.refusal(LIVE, None, [LIVE])
|
||||
assert reason
|
||||
assert LIVE in reason
|
||||
assert 'REFUSED' in reason
|
||||
|
||||
|
||||
def test_a_model_not_on_the_list_is_served():
|
||||
assert G.refusal('something_else', None, [LIVE]) is None
|
||||
|
||||
|
||||
def test_a_snapshot_under_the_root_is_served_despite_the_identical_title():
|
||||
"""The case the whole design turns on: same title, different file."""
|
||||
assert G.refusal(LIVE, SNAP_FILE, [LIVE], [SNAP_ROOT]) is None
|
||||
|
||||
|
||||
def test_a_snapshot_elsewhere_is_refused_until_it_can_prove_itself():
|
||||
other = os.path.join('C:', os.sep, 'elsewhere', LIVE + G.MODEL_EXT)
|
||||
assert G.refusal(LIVE, other, [LIVE], [SNAP_ROOT])
|
||||
|
||||
|
||||
def test_the_launched_model_is_served_on_the_environment_proof_alone():
|
||||
"""`WILLTOOLS_SANDBOX_MODEL` is set by `tools/snapshot_cycle.py` on the
|
||||
Revit it starts, and by nothing else. A session started from the taskbar
|
||||
cannot have it, which is exactly the distinction being drawn."""
|
||||
other = os.path.join('C:', os.sep, 'elsewhere', LIVE + G.MODEL_EXT)
|
||||
assert G.refusal(LIVE, other, [LIVE], [], sandbox_model=other) is None
|
||||
|
||||
|
||||
def test_the_environment_proof_has_to_name_THIS_file():
|
||||
other = os.path.join('C:', os.sep, 'elsewhere', LIVE + G.MODEL_EXT)
|
||||
assert G.refusal(LIVE, SNAP_FILE, [LIVE], [], sandbox_model=other)
|
||||
|
||||
|
||||
def test_a_document_with_no_title_is_not_refused():
|
||||
"""No document open is already useless in the ordinary way; there is
|
||||
nothing to protect and nothing to touch."""
|
||||
assert G.refusal(None, None, [LIVE]) is None
|
||||
assert G.refusal('', None, [LIVE]) is None
|
||||
|
||||
|
||||
def test_could_not_read_the_denylist_is_refused_not_treated_as_empty():
|
||||
"""CLAUDE.md's rule, at the one place it costs the most to get wrong:
|
||||
"could not judge" and "judged, found nothing" are different answers. A
|
||||
bridge that cannot find out whether it is in the live model has not
|
||||
established that it is not."""
|
||||
reason = G.refusal(LIVE, SNAP_FILE, [], [SNAP_ROOT],
|
||||
config_error='knowledge.yaml is malformed')
|
||||
assert reason and 'REFUSED' in reason
|
||||
assert 'malformed' in reason
|
||||
assert G.ENV_TITLES in reason # and it says how to answer it
|
||||
|
||||
|
||||
def test_the_refusal_body_still_answers_which_session_you_reached():
|
||||
body = G.payload(LIVE, G.refusal(LIVE, None, [LIVE]))
|
||||
assert body['ok'] is False
|
||||
assert body['refused'] is True
|
||||
assert body['doc_title'] == LIVE
|
||||
assert 'REFUSED' in body['error']
|
||||
|
||||
|
||||
# ------------------------------------------------------------ configuration
|
||||
|
||||
def _get(tree, dotted, default=None):
|
||||
node = tree
|
||||
for part in dotted.split('.'):
|
||||
if not isinstance(node, dict) or part not in node:
|
||||
return default
|
||||
node = node[part]
|
||||
return default if node is None else node
|
||||
|
||||
|
||||
def test_the_snapshot_root_counts_as_a_sandbox_root_without_being_listed():
|
||||
"""`snapshot.detached_dir` is where the copies go, so a copy that lives
|
||||
there needs no second declaration. Requiring one would be a setting whose
|
||||
only job is to repeat another setting."""
|
||||
know = {'snapshot': {'detached_dir': SNAP_ROOT}}
|
||||
titles, roots = G.config_from(know, _get, environ={})
|
||||
assert titles == []
|
||||
assert SNAP_ROOT in roots
|
||||
|
||||
|
||||
def test_the_environment_adds_to_the_denylist_and_cannot_shrink_it():
|
||||
"""A denylist you can empty by unsetting a variable is not a denylist."""
|
||||
know = {'bridge': {'refuse_titles': [LIVE]}}
|
||||
titles, _roots = G.config_from(know, _get,
|
||||
environ={G.ENV_TITLES: 'another'})
|
||||
assert LIVE in titles and 'another' in titles
|
||||
|
||||
|
||||
def test_a_title_listed_in_both_places_is_listed_once():
|
||||
know = {'bridge': {'refuse_titles': [LIVE]}}
|
||||
titles, _roots = G.config_from(know, _get,
|
||||
environ={G.ENV_TITLES: LIVE.upper()})
|
||||
assert len(titles) == 1
|
||||
|
||||
|
||||
def test_config_reading_survives_a_tree_that_is_not_a_tree():
|
||||
titles, roots = G.config_from(None, _get, environ={})
|
||||
assert titles == [] and roots == []
|
||||
|
||||
|
||||
def test_readonly_titles_reads_the_same_from_yaml_or_from_the_environment():
|
||||
know = {'bridge': {'readonly_titles': [LIVE]}}
|
||||
titles = G.readonly_titles_from(know, _get, environ={})
|
||||
assert titles == [LIVE]
|
||||
|
||||
|
||||
def test_readonly_environment_adds_to_the_list_and_cannot_shrink_it():
|
||||
know = {'bridge': {'readonly_titles': [LIVE]}}
|
||||
titles = G.readonly_titles_from(
|
||||
know, _get, environ={G.ENV_READONLY_TITLES: 'another'})
|
||||
assert LIVE in titles and 'another' in titles
|
||||
|
||||
|
||||
def test_readonly_titles_reading_survives_a_tree_that_is_not_a_tree():
|
||||
assert G.readonly_titles_from(None, _get, environ={}) == []
|
||||
|
||||
|
||||
# ============================================================ read-only /exec
|
||||
#
|
||||
# 2026-09-01, the owner reversed refuse_titles for the live model the same
|
||||
# morning it shipped: "我要把revit桥给你。你直接来sample_job。铁律是在live里面只
|
||||
# 读不写。" These exercise `write_signature` and `exec_refusal`, the pieces
|
||||
# that make that rule mechanical rather than a memory again.
|
||||
|
||||
@pytest.mark.parametrize('snippet,expect_name_contains', [
|
||||
('with tx("rename"):\n pass', 'tx'),
|
||||
('vibe.set_p(wall, "Comments", "x")', 'set_p'),
|
||||
('t = Transaction(doc, "x"); t.Start()', 'Transaction'),
|
||||
('prm.Set(5)', 'Set'),
|
||||
('doc.Delete(el.Id)', 'Delete'),
|
||||
('doc.Create.NewFamilyInstance(pt, sym, level, st)', 'New'),
|
||||
('ElementTransformUtils.MoveElement(doc, id, XYZ(1,0,0))',
|
||||
'ElementTransformUtils'),
|
||||
('wtype.Duplicate("copy")', 'Duplicate'),
|
||||
('doc.Regenerate()', 'Regenerate'),
|
||||
('doc.SaveAs(path)', 'SaveAs'),
|
||||
('doc.Save()', 'Save'),
|
||||
('doc.LoadFamily(path)', 'LoadFamily'),
|
||||
('mgr.SetParameterValue(p, 5)', 'SetParameterValue'),
|
||||
])
|
||||
def test_write_signature_names_the_pattern_it_found(snippet,
|
||||
expect_name_contains):
|
||||
hit = G.write_signature(snippet)
|
||||
assert hit is not None
|
||||
assert expect_name_contains in hit
|
||||
|
||||
|
||||
@pytest.mark.parametrize('snippet', [
|
||||
'print(doc.Title)',
|
||||
'q("OST_Walls")',
|
||||
'p(el, "Comments")',
|
||||
'result = [e.Id.IntegerValue for e in selected()]',
|
||||
'result = doc.GetElement(ElementId(123)).Name',
|
||||
'',
|
||||
None,
|
||||
])
|
||||
def test_write_signature_is_none_for_a_plain_read(snippet):
|
||||
assert G.write_signature(snippet) is None
|
||||
|
||||
|
||||
def test_exec_refusal_refuses_a_transaction_on_a_readonly_title():
|
||||
reason = G.exec_refusal(LIVE, None, [LIVE], tx_name='rename walls')
|
||||
assert reason
|
||||
assert 'REFUSED' in reason
|
||||
assert LIVE in reason
|
||||
assert 'rename walls' in reason
|
||||
|
||||
|
||||
def test_exec_refusal_refuses_a_write_looking_snippet_and_names_the_pattern():
|
||||
reason = G.exec_refusal(LIVE, None, [LIVE], code='prm.Set(5)')
|
||||
assert reason
|
||||
assert 'REFUSED' in reason
|
||||
assert 'Set' in reason
|
||||
|
||||
|
||||
def test_exec_refusal_allows_a_plain_read_on_a_readonly_title():
|
||||
assert G.exec_refusal(LIVE, None, [LIVE],
|
||||
code='print(doc.Title)') is None
|
||||
assert G.exec_refusal(LIVE, None, [LIVE], code=None,
|
||||
tx_name=None) is None
|
||||
|
||||
|
||||
def test_exec_refusal_is_none_for_a_title_not_on_the_readonly_list():
|
||||
reason = G.exec_refusal('something_else', None, [LIVE],
|
||||
tx_name='x', code='doc.Delete(id)')
|
||||
assert reason is None
|
||||
|
||||
|
||||
def test_exec_refusal_exempts_a_proven_sandbox_even_with_a_write_snippet():
|
||||
"""Same alibi as `refusal()`: a title match alone is not enough, because
|
||||
the snapshot carries the identical title on purpose."""
|
||||
assert G.exec_refusal(LIVE, SNAP_FILE, [LIVE], [SNAP_ROOT],
|
||||
tx_name='x') is None
|
||||
other = os.path.join('C:', os.sep, 'elsewhere', LIVE + G.MODEL_EXT)
|
||||
assert G.exec_refusal(LIVE, other, [LIVE], [SNAP_ROOT],
|
||||
code='doc.Delete(id)')
|
||||
|
||||
|
||||
def test_refuse_titles_and_readonly_titles_are_never_layered():
|
||||
"""A title on refuse_titles is refused by `refusal()` before anything in
|
||||
`exec_refusal()` runs -- see the AST test below for the wiring in
|
||||
`startup.py` that makes that literally true, not just true by
|
||||
convention. Here: `exec_refusal` on its own does not know about
|
||||
refuse_titles at all, so a title present on BOTH lists still gets
|
||||
stopped -- by whichever check the caller runs first."""
|
||||
assert G.refusal(LIVE, None, [LIVE]) is not None
|
||||
# exec_refusal, called in isolation, only ever looks at readonly_titles.
|
||||
assert G.exec_refusal(LIVE, None, [], tx_name='x') is None
|
||||
|
||||
|
||||
# ==================================================== the wiring in startup.py
|
||||
#
|
||||
# `startup.py` does `from pyrevit import routes` at module scope and cannot be
|
||||
# imported on host CPython (see tests/conftest.py's docstring), so this half is
|
||||
# AST over its source -- the same approach `test_bridge_query_channel.py` uses
|
||||
# on the same file. The DECISION is tested above, by calling it; what is left
|
||||
# to check here is only that every route actually asks.
|
||||
|
||||
import ast # noqa: E402
|
||||
|
||||
_EXT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
STARTUP = os.path.join(_EXT, 'startup.py')
|
||||
RVT_PY = os.path.join(os.path.dirname(_EXT), 'tools', 'rvt.py')
|
||||
|
||||
|
||||
def _source(path):
|
||||
with open(path, 'r', encoding='utf-8') as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def _routes(tree):
|
||||
"""{function name: route path} for every `@api.route(...)` handler."""
|
||||
found = {}
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.FunctionDef):
|
||||
continue
|
||||
for dec in node.decorator_list:
|
||||
if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute) \
|
||||
and dec.func.attr == 'route':
|
||||
path = dec.args[0].value if dec.args and \
|
||||
isinstance(dec.args[0], ast.Constant) else None
|
||||
found[node.name] = path
|
||||
return found
|
||||
|
||||
|
||||
def test_every_single_route_asks_the_guard_first():
|
||||
"""Not most of them. A route that skips the guard is a route an agent can
|
||||
read the live model through, and the whole point of putting this in code
|
||||
was to stop it depending on somebody remembering."""
|
||||
tree = ast.parse(_source(STARTUP))
|
||||
handlers = _routes(tree)
|
||||
assert handlers, 'no @api.route handlers found in startup.py at all'
|
||||
funcs = {n.name: n for n in ast.walk(tree)
|
||||
if isinstance(n, ast.FunctionDef)}
|
||||
missing = []
|
||||
for name in handlers:
|
||||
calls = [n for n in ast.walk(funcs[name])
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
|
||||
and n.func.id == '_guard']
|
||||
if not calls:
|
||||
missing.append('{0} ({1})'.format(name, handlers[name]))
|
||||
assert not missing, (
|
||||
'these routes never call _guard(), so they would serve the live '
|
||||
'model: {0}'.format(', '.join(missing)))
|
||||
|
||||
|
||||
def test_status_refuses_too_because_that_is_the_only_port_race_signal():
|
||||
"""A refused `/status` is what tells `tools/snapshot_cycle.py` "the live
|
||||
Revit is holding this port" apart from "the sandbox is not up yet". Left
|
||||
unguarded, `/status` would answer ok with the right title -- the snapshot
|
||||
carries the same title -- and the pipeline would sweep the live model."""
|
||||
tree = ast.parse(_source(STARTUP))
|
||||
handlers = _routes(tree)
|
||||
status = [name for name, path in handlers.items() if path == '/status']
|
||||
assert status, '/status route is gone from startup.py'
|
||||
funcs = {n.name: n for n in ast.walk(tree)
|
||||
if isinstance(n, ast.FunctionDef)}
|
||||
assert '_guard' in ast.dump(funcs[status[0]])
|
||||
|
||||
|
||||
def test_the_guard_has_no_off_switch():
|
||||
"""There is deliberately no environment variable that disables it. The
|
||||
escape hatch for a machine whose knowledge layers will not load is
|
||||
WILLTOOLS_BRIDGE_REFUSE_TITLES, which STATES the answer -- an off switch
|
||||
would be the hole this exists to close."""
|
||||
text = _source(STARTUP) + _source(
|
||||
os.path.join(_EXT, 'lib', 'vibe', 'bridgeguard.py'))
|
||||
for word in ('BRIDGE_GUARD_OFF', 'DISABLE_BRIDGE_GUARD',
|
||||
'WILLTOOLS_BRIDGE_GUARD'):
|
||||
assert word not in text, (
|
||||
'{0} looks like a way to switch the live-model guard off'
|
||||
.format(word))
|
||||
|
||||
|
||||
def test_a_missing_guard_module_refuses_rather_than_serving():
|
||||
"""A broken install is not a licence to run in the live model. The one
|
||||
fallback path in `_guard` has to be a refusal, not a `return None`."""
|
||||
tree = ast.parse(_source(STARTUP))
|
||||
funcs = {n.name: n for n in ast.walk(tree)
|
||||
if isinstance(n, ast.FunctionDef)}
|
||||
assert '_guard' in funcs
|
||||
handlers = [n for n in ast.walk(funcs['_guard'])
|
||||
if isinstance(n, ast.ExceptHandler)]
|
||||
assert handlers, '_guard no longer handles a failed import at all'
|
||||
first = handlers[0]
|
||||
returns = [n for n in ast.walk(first) if isinstance(n, ast.Return)]
|
||||
assert returns, 'the import-failure branch returns nothing'
|
||||
assert 'refused' in ast.dump(returns[0]), (
|
||||
'the import-failure branch does not return a refusal -- a bridge '
|
||||
'that cannot load its guard must not serve requests')
|
||||
|
||||
|
||||
def test_the_guard_is_imported_lazily_not_at_module_scope():
|
||||
"""`vibe/__init__.py` pulls in the Revit API. Importing it at the top of
|
||||
startup.py would tie the bridge's registration to it loading."""
|
||||
tree = ast.parse(_source(STARTUP))
|
||||
top = [n for n in tree.body if isinstance(n, (ast.Import, ast.ImportFrom))]
|
||||
names = []
|
||||
for node in top:
|
||||
if isinstance(node, ast.ImportFrom) and (node.module or '') == 'vibe':
|
||||
names.extend(a.name for a in node.names)
|
||||
assert 'bridgeguard' not in names
|
||||
|
||||
|
||||
def test_vibe_exec_calls_the_readonly_guard_after_the_live_model_guard():
|
||||
"""`_guard_exec` must run, and it must run AFTER `_guard` -- if it ran
|
||||
first or on its own, a title on refuse_titles could reach the readonly
|
||||
check instead of being refused outright. Checked by source position:
|
||||
`_guard(` has to appear before `_guard_exec(` in `vibe_exec`'s body."""
|
||||
tree = ast.parse(_source(STARTUP))
|
||||
funcs = {n.name: n for n in ast.walk(tree)
|
||||
if isinstance(n, ast.FunctionDef)}
|
||||
assert 'vibe_exec' in funcs
|
||||
assert '_guard_exec' in funcs, \
|
||||
'_guard_exec is gone -- /exec would no longer refuse writes on a ' \
|
||||
'readonly_titles document'
|
||||
calls = [n for n in ast.walk(funcs['vibe_exec'])
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
|
||||
and n.func.id in ('_guard', '_guard_exec')]
|
||||
names = [c.func.id for c in sorted(calls, key=lambda c: c.lineno)]
|
||||
assert '_guard' in names and '_guard_exec' in names
|
||||
assert names.index('_guard') < names.index('_guard_exec'), (
|
||||
'vibe_exec calls _guard_exec before (or without regard to) _guard, '
|
||||
'so a refuse_titles document could reach the readonly check instead '
|
||||
'of being refused outright')
|
||||
|
||||
|
||||
def test_only_exec_calls_the_readonly_guard():
|
||||
"""`/status`, `/collect`, `/params`, `/selection` are reads by
|
||||
construction -- no transaction, no arbitrary code -- so none of them
|
||||
should ever call `_guard_exec`; it would be dead weight at best and a
|
||||
sign the read/write split has blurred at worst."""
|
||||
tree = ast.parse(_source(STARTUP))
|
||||
handlers = _routes(tree)
|
||||
funcs = {n.name: n for n in ast.walk(tree)
|
||||
if isinstance(n, ast.FunctionDef)}
|
||||
for name, path in handlers.items():
|
||||
if path == '/exec':
|
||||
continue
|
||||
calls = [n for n in ast.walk(funcs[name])
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
|
||||
and n.func.id == '_guard_exec']
|
||||
assert not calls, '{0} ({1}) calls _guard_exec but is not /exec' \
|
||||
.format(name, path)
|
||||
|
||||
|
||||
def test_the_readonly_guard_reads_config_through_the_same_cached_path():
|
||||
"""`_guard_exec` must not open a NEW knowledge read of its own -- it has
|
||||
to go through `_refuse_config`, the same memoised function `_guard`
|
||||
already calls, or a readonly_titles document pays for a second
|
||||
`knowledge.load()` on every single `/exec` call."""
|
||||
tree = ast.parse(_source(STARTUP))
|
||||
funcs = {n.name: n for n in ast.walk(tree)
|
||||
if isinstance(n, ast.FunctionDef)}
|
||||
assert '_guard_exec' in funcs
|
||||
calls = [n for n in ast.walk(funcs['_guard_exec'])
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
|
||||
and n.func.id == '_refuse_config']
|
||||
assert calls, '_guard_exec does not call _refuse_config -- it must be ' \
|
||||
'reading configuration some other, uncached way'
|
||||
|
||||
|
||||
def test_the_readonly_guard_calls_exec_refusal():
|
||||
tree = ast.parse(_source(STARTUP))
|
||||
funcs = {n.name: n for n in ast.walk(tree)
|
||||
if isinstance(n, ast.FunctionDef)}
|
||||
assert '_guard_exec' in funcs
|
||||
assert 'exec_refusal' in ast.dump(funcs['_guard_exec'])
|
||||
|
||||
|
||||
def test_the_client_lets_the_pipeline_pick_the_port():
|
||||
"""pyRevit walks the port upward per running Revit, so a hard-coded 48884
|
||||
is "whichever Revit started first" rather than a session anybody chose."""
|
||||
source = _source(RVT_PY)
|
||||
assert 'WILLTOOLS_BRIDGE_PORT' in source
|
||||
tree = ast.parse(source)
|
||||
funcs = {n.name: n for n in ast.walk(tree)
|
||||
if isinstance(n, ast.FunctionDef)}
|
||||
assert 'base_url' in funcs, 'rvt.py lost its per-call base URL'
|
||||
# `_send` is the one function that opens the connection; `call` wraps it
|
||||
# with the correlation check (see tests/test_bridge_correlation.py). The
|
||||
# invariant is unchanged: whichever of them builds the URL must do it per
|
||||
# call, not off a module constant frozen at import.
|
||||
sender = '_send' if '_send' in funcs else 'call'
|
||||
assert 'base_url' in ast.dump(funcs[sender]), (
|
||||
'{0}() no longer builds its URL per call, so a port set after '
|
||||
'import would be ignored'.format(sender))
|
||||
@@ -0,0 +1,255 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""No button may carry hover text. This is the test that keeps it that way.
|
||||
|
||||
The owner, twice: hovering a button puts a wall of words on screen, and he
|
||||
does not want them. The words come from two places and pyRevit merges them,
|
||||
so trimming one and leaving the other looks fixed and is not:
|
||||
|
||||
* `bundle.yaml`'s `tooltip:` -- read by
|
||||
`pyrevit.extensions.genericcomps.GenericUIComponent._read_bundle_metadata`;
|
||||
* the script's module docstring -- read by
|
||||
`GenericUICommand._read_bundle_metadata_from_python_script`, but ONLY
|
||||
`if not self.meta`, i.e. only when the button has no `bundle.yaml` at all.
|
||||
|
||||
That `if not self.meta` is why the docstring half has to be guarded even
|
||||
though every button here does have a `bundle.yaml`: the day someone trims a
|
||||
`bundle.yaml` down to nothing, or a new button ships without one, every
|
||||
docstring in this extension becomes hover text again. Guard both, and the
|
||||
regression cannot come back through either door.
|
||||
|
||||
`bundle.yaml`'s `author:` is guarded for the same reason.
|
||||
`pyrevit.loader.uimaker._make_button_tooltip` appends an `Author(s):` block
|
||||
whenever the button declares one, so an author line is three more lines of
|
||||
hover text.
|
||||
|
||||
WHAT IS STILL LEFT ON SCREEN, AND WHY THIS TEST CANNOT REMOVE IT
|
||||
-----------------------------------------------------------------
|
||||
`_make_button_tooltip` unconditionally appends
|
||||
|
||||
Bundle Name:
|
||||
<Name> (pushbutton)
|
||||
|
||||
That text is pyRevit's, not ours -- there is no bundle setting that
|
||||
suppresses it. With `tooltip:` and `author:` gone, those two lines under the
|
||||
button's own title are the whole of the hover text, which is the floor this
|
||||
repository can reach without patching the pyRevit install.
|
||||
|
||||
Verified against the pyrevitlib shipped with pyRevit 5.0.1 at
|
||||
`%APPDATA%\\pyRevit-Master\\pyrevitlib\\pyrevit\\`.
|
||||
"""
|
||||
import ast
|
||||
import io
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe import yamlkit
|
||||
|
||||
|
||||
_EXT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# The ribbon, and the whole of what this suite checks.
|
||||
#
|
||||
# There was a second root here until 2026-08-30: `parked/`, a folder outside
|
||||
# every `*.tab` holding buttons taken off the ribbon but kept whole. The
|
||||
# 2026-08-30 restructure emptied it -- Job Recon and Takeoff came back onto
|
||||
# the ribbon inside `Setup.panel/Config.pulldown` -- and the folder was
|
||||
# deleted with them.
|
||||
#
|
||||
# The constant went with it rather than staying as a tolerated-empty path.
|
||||
# `os.walk` over a directory that does not exist raises nothing and yields
|
||||
# nothing, so a `PARKED` left pointing at thin air is a guard that passes by
|
||||
# checking zero buttons, which is precisely the failure
|
||||
# `test_the_walk_found_the_buttons` exists to make impossible. If parking
|
||||
# comes back, bring the constant back WITH a count of its own.
|
||||
TAB = os.path.join(_EXT, 'WillTools.tab')
|
||||
|
||||
# Anything longer than this in a lone string statement at the top of a script
|
||||
# is prose someone meant as documentation. Note the guard rejects an empty
|
||||
# docstring too -- see `_docstring_of`.
|
||||
_ADVICE = (
|
||||
"Documentation belongs in `#` comments under the coding cookie, where "
|
||||
"pyRevit cannot render it. Move the prose down and delete the string.")
|
||||
|
||||
|
||||
def _buttons(where):
|
||||
"""Every `*.pushbutton` folder under `where`, as (name, path) pairs.
|
||||
|
||||
The walk is recursive on purpose: buttons live at two depths, some
|
||||
directly on a panel and some inside a `*.pulldown` folder under one.
|
||||
(Before 2026-08-30 the nested ones were inside `*.stack` folders instead;
|
||||
the walk never cared which, and that is the point of walking.)
|
||||
"""
|
||||
found = []
|
||||
for root, dirs, _files in os.walk(where):
|
||||
for name in dirs:
|
||||
if name.endswith('.pushbutton'):
|
||||
found.append((name, os.path.join(root, name)))
|
||||
found.sort()
|
||||
return found
|
||||
|
||||
|
||||
BUTTONS = _buttons(TAB)
|
||||
# The ones that are NOT a direct child of a `*.panel` -- today, the five rows
|
||||
# behind Config and the two behind DesignHub. Counted separately below.
|
||||
NESTED = [b for b in BUTTONS
|
||||
if not os.path.basename(os.path.dirname(b[1])).endswith('.panel')]
|
||||
|
||||
|
||||
def _ids(pair):
|
||||
return pair[0]
|
||||
|
||||
|
||||
def test_the_walk_found_the_buttons():
|
||||
"""If the layout moves, this suite must fail loudly rather than pass by
|
||||
finding nothing to check.
|
||||
|
||||
Every test below is parametrized over `BUTTONS`. An empty `BUTTONS` is not
|
||||
a failure in pytest -- it is a clean run of zero tests, which looks exactly
|
||||
like a pass. This is the test that stops that.
|
||||
|
||||
Two counts, because one number cannot say both things.
|
||||
|
||||
The first is the whole ribbon: twelve buttons as of 2026-08-30, four large
|
||||
ones on Tools, five behind Config, two behind DesignHub and one behind
|
||||
Setup's other pulldown rows. Rename or move `WillTools.tab` and this goes
|
||||
to zero.
|
||||
|
||||
The second is the nested ones: seven of the twelve are a row inside a
|
||||
`*.pulldown` rather than a direct child of a `*.panel`. That count exists
|
||||
because the first cannot see the failure that actually happened before --
|
||||
a walk that stops recursing, or a container folder renamed to something
|
||||
the walk no longer descends into, still finds the five top-level buttons
|
||||
and looks healthy. Watch both and neither failure is quiet.
|
||||
"""
|
||||
assert len(BUTTONS) >= 12, (
|
||||
'expected at least 12 pushbuttons under {0}, found {1}. If the ribbon '
|
||||
'really shrank, lower this number; if the tab folder moved, fix TAB '
|
||||
'-- do not let a tooltip guard pass because it checked nothing.'
|
||||
.format(TAB, len(BUTTONS)))
|
||||
assert len(NESTED) >= 7, (
|
||||
'expected at least 7 of the {0} pushbuttons under {1} to sit inside a '
|
||||
'container (a `*.pulldown` today) rather than directly on a `*.panel`, '
|
||||
'found {2}. If the ribbon really was flattened, lower this number; if '
|
||||
'the walk stopped descending, fix `_buttons` -- a guard that only ever '
|
||||
'reaches the top level checks the buttons nobody was worried about.'
|
||||
.format(len(BUTTONS), TAB, len(NESTED)))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('button', BUTTONS, ids=_ids)
|
||||
def test_bundle_declares_no_tooltip(button):
|
||||
name, path = button
|
||||
meta_file = os.path.join(path, 'bundle.yaml')
|
||||
if not os.path.isfile(meta_file):
|
||||
return # no bundle.yaml is no tooltip
|
||||
meta = yamlkit.parse(io.open(meta_file, encoding='utf-8').read()) or {}
|
||||
tooltip = meta.get('tooltip')
|
||||
assert not tooltip, (
|
||||
'{0} has hover text again: bundle.yaml declares\n'
|
||||
' tooltip: {1!r}\n'
|
||||
'Every button on this ribbon is meant to hover with nothing on it '
|
||||
'(owner, 2026-08-30). Delete the `tooltip:` key from\n'
|
||||
' {2}\n'
|
||||
'{3}'.format(name, tooltip, meta_file, _ADVICE))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('button', BUTTONS, ids=_ids)
|
||||
def test_bundle_declares_no_author(button):
|
||||
name, path = button
|
||||
meta_file = os.path.join(path, 'bundle.yaml')
|
||||
if not os.path.isfile(meta_file):
|
||||
return
|
||||
meta = yamlkit.parse(io.open(meta_file, encoding='utf-8').read()) or {}
|
||||
author = meta.get('author') or meta.get('authors')
|
||||
assert not author, (
|
||||
'{0} has hover text again: bundle.yaml declares an author ({1!r}), '
|
||||
'and pyRevit prints an "Author(s):" block on hover for every button '
|
||||
'that has one. Delete the `author:` key from\n'
|
||||
' {2}'.format(name, author, meta_file))
|
||||
|
||||
|
||||
def _docstring_of(source):
|
||||
"""What pyRevit's own reader would get.
|
||||
|
||||
`coreutils.ScriptFileParser.get_docstring` is `ast.get_docstring` plus a
|
||||
truthiness check, so an empty or whitespace-only docstring reads as no
|
||||
docstring at all -- pyRevit renders nothing for it, no placeholder. This
|
||||
guard is stricter on purpose and rejects the empty string too: a `""" """`
|
||||
left at the top of a file is an invitation for the next agent to write a
|
||||
paragraph inside it, and that paragraph goes straight to the ribbon.
|
||||
"""
|
||||
return ast.get_docstring(ast.parse(source))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('button', BUTTONS, ids=_ids)
|
||||
def test_script_declares_no_author(button):
|
||||
"""`__author__` (or `__authors__`) in `script.py` is the same trap as the
|
||||
module docstring, one door further in.
|
||||
|
||||
`GenericUICommand._read_bundle_metadata_from_python_script` only reads a
|
||||
script's dunder metadata `if not self.meta` -- exactly like the
|
||||
docstring case above -- so on every button here, which all have a
|
||||
`bundle.yaml`, an `__author__` line sits inert today. It stops being
|
||||
inert the day a `bundle.yaml` is trimmed to nothing or a new button
|
||||
ships without one: then pyRevit reads `__author__` (or `__authors__`)
|
||||
straight off the script and prints the same "Author(s):" block that
|
||||
`test_bundle_declares_no_author` guards against on the bundle side.
|
||||
|
||||
pyRevit 5.0.1 additionally has a bug at `genericcomps.py:733` where
|
||||
`__author__` is read and then immediately overwritten by `__authors__`,
|
||||
so on that version specifically the line is discarded either way. That
|
||||
is a bug, not a guarantee, and a later pyRevit fixing it is exactly the
|
||||
kind of change that would make an unremoved `__author__` line start
|
||||
talking again with nobody touching this file. Guard the source, not the
|
||||
version-specific bug.
|
||||
"""
|
||||
name, path = button
|
||||
script = os.path.join(path, 'script.py')
|
||||
assert os.path.isfile(script), 'no script.py under {0}'.format(path)
|
||||
|
||||
source = io.open(script, encoding='utf-8').read()
|
||||
tree = ast.parse(source)
|
||||
for node in tree.body:
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
for target in node.targets:
|
||||
if getattr(target, 'id', None) in ('__author__', '__authors__'):
|
||||
assert False, (
|
||||
'{0} declares {1} in script.py again:\n'
|
||||
' {2}:{3}\n'
|
||||
'It reads as dead code today only because every button '
|
||||
'has a bundle.yaml -- pyRevit reads a script dunder for '
|
||||
'metadata only `if not self.meta`. Trim a bundle.yaml to '
|
||||
'nothing, or ship a button without one, and this becomes '
|
||||
'an "Author(s):" block on hover. Delete the {1} line '
|
||||
'from\n'
|
||||
' {2}'.format(name, target.id, script, node.lineno))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('button', BUTTONS, ids=_ids)
|
||||
def test_script_has_no_module_docstring(button):
|
||||
name, path = button
|
||||
# `script.py` is what pyRevit parses for metadata (`self.script_file`);
|
||||
# side modules in the same folder are never read for a docstring.
|
||||
script = os.path.join(path, 'script.py')
|
||||
assert os.path.isfile(script), 'no script.py under {0}'.format(path)
|
||||
|
||||
source = io.open(script, encoding='utf-8').read()
|
||||
body = ast.parse(source).body
|
||||
opener = body[0] if body else None
|
||||
value = getattr(opener, 'value', None)
|
||||
# `ast.Constant`, not the `ast.Str` alias -- the alias warns on 3.12 and
|
||||
# is gone on 3.14, and this suite runs on host CPython.
|
||||
is_string_stmt = (isinstance(opener, ast.Expr)
|
||||
and isinstance(value, ast.Constant)
|
||||
and isinstance(value.value, str))
|
||||
assert not is_string_stmt, (
|
||||
'{0} has a module docstring again:\n'
|
||||
' {1}:{2}\n'
|
||||
' {3!r}\n'
|
||||
"pyRevit renders the module docstring as the button's hover text "
|
||||
'whenever the bundle has no `tooltip:` -- and for a button with no '
|
||||
'`bundle.yaml` at all, it is the only thing it renders. The owner '
|
||||
'wants nothing on hover.\n'
|
||||
'{4}'.format(name, script, opener.lineno,
|
||||
_docstring_of(source), _ADVICE))
|
||||
@@ -0,0 +1,156 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The handling compression limit, from the window down to the job file.
|
||||
|
||||
`handling.compression_limit_ratio` multiplies f'c (f'ci at stripping) to give
|
||||
the compression allowable in psi. Unlike the tensile limit next to it, there
|
||||
is no second school of thought to weigh -- the owner closed the question,
|
||||
2026-09-01:
|
||||
|
||||
"0.6保持可调" ("0.6 stays adjustable")
|
||||
|
||||
-- so 0.6 remains the shipped default (ACI 318-19 Table 24.5.3.1 / Sec.
|
||||
24.5.4.1) and becomes a per-job field the same way `tension_limit_sqrt_fc`
|
||||
already is, in the same Project Config module, following the exact same
|
||||
plumbing: `configmodel.SETTINGS` -> `configform.MODULE_OF` -> `liftexport`
|
||||
writes it onto every stage -> panellift reads it into `Stage`.
|
||||
|
||||
Nothing here implements the higher 0.70*f'ci ACI permits at member ends --
|
||||
the owner ruled that out the same night ("先不用实现0.7...别搞这么复杂") --
|
||||
so there is exactly one ratio, and this file never tests a location split.
|
||||
|
||||
Mirrors `test_stress_limit_setting.py` structurally; see that file's own
|
||||
docstring for why each half is tested where it is.
|
||||
"""
|
||||
from vibe import configform, configmodel, knowledge, liftexport
|
||||
|
||||
from test_liftexport import FACE_AND_EDGE_LIFTERS, make_job, make_know
|
||||
|
||||
|
||||
KEY = 'handling.compression_limit_ratio'
|
||||
|
||||
|
||||
# ------------------------------------------------------- it is in the window
|
||||
|
||||
def test_the_compression_limit_is_a_field_in_lifting_and_handling():
|
||||
assert configform.MODULE_OF[KEY] == configform.LIFTING
|
||||
assert KEY not in configform.NOT_SHOWN
|
||||
|
||||
|
||||
def test_it_is_a_box_you_type_a_number_into():
|
||||
field = [f for f in configform.settings_fields() if f['key'] == KEY][0]
|
||||
assert field['kind'] == 'number'
|
||||
ok, value, _why = configform.parse(field, '0.7')
|
||||
assert ok and value == 0.7
|
||||
|
||||
|
||||
def test_the_row_note_names_the_default_and_the_citation():
|
||||
"""A box that says only 'Compression limit' asks a person to trust a
|
||||
number with no way to check it. The note carries what it multiplies,
|
||||
the default, and the ACI table it comes from."""
|
||||
field = [f for f in configform.settings_fields() if f['key'] == KEY][0]
|
||||
note = field['note']
|
||||
assert '0.6' in note
|
||||
assert "f'c" in note
|
||||
assert '24.5' in note # the ACI section, either citation
|
||||
|
||||
|
||||
def test_the_note_says_the_higher_member_end_value_is_not_implemented():
|
||||
"""The owner's ruling against implementing ACI's 0.70*f'ci at member
|
||||
ends has to be visible at the control, not only in a comment nobody
|
||||
editing the job sees."""
|
||||
field = [f for f in configform.settings_fields() if f['key'] == KEY][0]
|
||||
assert '0.70' in field['note']
|
||||
assert 'conservative' in field['note'] or 'flat' in field['note']
|
||||
|
||||
|
||||
def test_the_full_view_says_where_it_is_edited_now():
|
||||
assert configform.placement()[KEY] == 'Lifting and handling'
|
||||
|
||||
|
||||
def test_nothing_fell_through_the_move():
|
||||
"""The guard that makes the not-shown pool honest: every editable
|
||||
setting is in a module or has a written reason for not being. Failing
|
||||
this here would mean the new field landed with no home."""
|
||||
assert configform.unmapped() == []
|
||||
|
||||
|
||||
# ------------------------------------------------------------ what it refuses
|
||||
|
||||
def test_a_zero_or_negative_ratio_is_refused():
|
||||
"""The exact mirror of the tensile limit's own refusal: a zero
|
||||
allowable fails every piece in compression at any stress, and a
|
||||
negative one inverts the comparison so every piece passes."""
|
||||
assert configmodel.validate(KEY, 0)
|
||||
assert configmodel.validate(KEY, -0.6)
|
||||
assert 'greater than zero' in ' '.join(configmodel.validate(KEY, 0))
|
||||
|
||||
|
||||
def test_a_garbage_value_is_refused_not_silently_reverted():
|
||||
assert configmodel.validate(KEY, 'static_lift')
|
||||
assert configmodel.validate(KEY, None) == [] # blank means "inherit"
|
||||
|
||||
|
||||
def test_ordinary_values_are_accepted_without_argument():
|
||||
"""0.6 is the shipped default, not a doctrine -- the validator does not
|
||||
get to refuse a job that wants a different positive ratio."""
|
||||
assert configmodel.validate(KEY, 0.6) == []
|
||||
assert configmodel.validate(KEY, 0.5) == []
|
||||
assert configmodel.validate(KEY, 0.7) == []
|
||||
|
||||
|
||||
# ------------------------------------------------- it reaches the calculation
|
||||
|
||||
def test_the_default_is_still_point_six():
|
||||
"""Byte-identity when nobody sets it. Every job configured today has no
|
||||
override, and this is the number they have all been running."""
|
||||
assert knowledge.get(knowledge.DEFAULTS, KEY) == 0.6
|
||||
|
||||
|
||||
def test_the_job_layer_value_lands_on_every_stage_of_the_job_file():
|
||||
know = make_know()
|
||||
know['handling']['compression_limit_ratio'] = 0.7
|
||||
stages, _rows, _problems = liftexport.default_stages(
|
||||
make_job(FACE_AND_EDGE_LIFTERS), know)
|
||||
assert stages
|
||||
assert all(s['compression_limit_ratio'] == 0.7 for s in stages)
|
||||
|
||||
|
||||
def test_an_unset_ratio_writes_the_point_six_it_always_wrote():
|
||||
stages, _rows, _problems = liftexport.default_stages(
|
||||
make_job(FACE_AND_EDGE_LIFTERS), make_know())
|
||||
assert all(s['compression_limit_ratio'] == 0.6 for s in stages)
|
||||
assert all(s['compression_limit_source'] == 'job' for s in stages)
|
||||
|
||||
|
||||
def test_a_stage_that_states_its_own_ratio_is_marked_as_the_override():
|
||||
know = make_know()
|
||||
know['handling']['compression_limit_ratio'] = 0.6
|
||||
know['handling']['stages'][0]['compression_limit_ratio'] = 0.7
|
||||
stages, _rows, _problems = liftexport.default_stages(
|
||||
make_job(FACE_AND_EDGE_LIFTERS), know)
|
||||
assert stages[0]['compression_limit_ratio'] == 0.7
|
||||
assert stages[0]['compression_limit_source'] == 'stage'
|
||||
assert stages[1]['compression_limit_ratio'] == 0.6
|
||||
assert stages[1]['compression_limit_source'] == 'job'
|
||||
|
||||
|
||||
def test_the_provenance_words_are_the_ones_panellift_reads():
|
||||
"""Written on one side, refused on the other if they disagree -- see
|
||||
`panellift.model.COMPRESSION_LIMIT_SOURCES`."""
|
||||
stages, _rows, _problems = liftexport.default_stages(
|
||||
make_job(FACE_AND_EDGE_LIFTERS), make_know())
|
||||
assert set(s['compression_limit_source'] for s in stages) <= \
|
||||
set(['job', 'stage', 'default'])
|
||||
|
||||
|
||||
def test_tension_and_compression_are_independent_fields():
|
||||
"""Setting one must never move the other -- they are two separate
|
||||
dotted keys, two separate stage entries, and this is the regression a
|
||||
copy-paste of the tension plumbing could introduce silently."""
|
||||
know = make_know()
|
||||
know['handling']['tension_limit_sqrt_fc'] = 7.5
|
||||
know['handling']['compression_limit_ratio'] = 0.7
|
||||
stages, _rows, _problems = liftexport.default_stages(
|
||||
make_job(FACE_AND_EDGE_LIFTERS), know)
|
||||
assert all(s['tension_limit_sqrt_fc'] == 7.5 for s in stages)
|
||||
assert all(s['compression_limit_ratio'] == 0.7 for s in stages)
|
||||
@@ -0,0 +1,393 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for vibe.configdump.emit -- the hand-written YAML writer.
|
||||
|
||||
`configdump.build()` needs a live Revit `doc` (it reads `doc.Title`, walks
|
||||
assemblies through `vibe.ticketqc`, etc.) and is out of scope here. `emit()`
|
||||
itself takes a plain dict/list/scalar tree and returns YAML lines -- no doc,
|
||||
no Revit -- and that is what a project config file is built out of, so it is
|
||||
worth trusting on its own.
|
||||
|
||||
The real reader on the Revit side is `pyrevit.coreutils.yaml.load_as_dict`,
|
||||
which is not available off a Revit host, and this repo deliberately keeps
|
||||
`panellift` (and by extension this test suite) free of external
|
||||
dependencies -- see panellift/PLAN.md. So `_read_emitted` below is a small,
|
||||
purpose-built reader that understands exactly the grammar `emit()` produces
|
||||
(2-space indented mappings, `- ` block-sequence items, `[a, b]` flow lists,
|
||||
`'quoted'` scalars) and nothing more general than that. It is not a YAML
|
||||
parser; it is `emit()`'s inverse, kept next to the writer it tests.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe import configdump
|
||||
|
||||
|
||||
def _read_emitted(text):
|
||||
"""Parse text produced by configdump.emit() back into Python.
|
||||
|
||||
Deliberately minimal -- see the module docstring. Handles everything
|
||||
emit() can write: nested mappings, empty {}/[], flow lists ('[a, b]'),
|
||||
block sequences of scalars/dicts/lists, and every scalar type _scalar()
|
||||
produces (null/true/false, int, float, quoted and bare strings).
|
||||
"""
|
||||
lines = [line for line in text.split('\n') if line.strip() != '']
|
||||
tokens = [((len(ln) - len(ln.lstrip(' '))) // 2, ln.strip())
|
||||
for ln in lines]
|
||||
pos = [0]
|
||||
|
||||
def parse_scalar(tok):
|
||||
if tok == 'null':
|
||||
return None
|
||||
if tok == 'true':
|
||||
return True
|
||||
if tok == 'false':
|
||||
return False
|
||||
if len(tok) >= 2 and tok[0] == "'" and tok[-1] == "'":
|
||||
return tok[1:-1].replace("''", "'")
|
||||
try:
|
||||
return int(tok)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return float(tok)
|
||||
except ValueError:
|
||||
pass
|
||||
return tok
|
||||
|
||||
def split_flow(inner):
|
||||
items, current, in_quote = [], '', False
|
||||
for ch in inner:
|
||||
if ch == "'":
|
||||
in_quote = not in_quote
|
||||
current += ch
|
||||
elif ch == ',' and not in_quote:
|
||||
items.append(current)
|
||||
current = ''
|
||||
else:
|
||||
current += ch
|
||||
if current.strip():
|
||||
items.append(current)
|
||||
return items
|
||||
|
||||
def parse_flow_list(tok):
|
||||
inner = tok[1:-1].strip()
|
||||
return [parse_scalar(item.strip())
|
||||
for item in split_flow(inner)] if inner else []
|
||||
|
||||
def assign(container, key_raw, rest_raw, child_indent):
|
||||
key = parse_scalar(key_raw.strip())
|
||||
rest = rest_raw.strip()
|
||||
if rest == '':
|
||||
container[key] = (parse_block(child_indent)
|
||||
if pos[0] < len(tokens)
|
||||
and tokens[pos[0]][0] == child_indent else {})
|
||||
elif rest == '{}':
|
||||
container[key] = {}
|
||||
elif rest == '[]':
|
||||
container[key] = []
|
||||
elif rest.startswith('['):
|
||||
container[key] = parse_flow_list(rest)
|
||||
else:
|
||||
container[key] = parse_scalar(rest)
|
||||
|
||||
def parse_dict(indent):
|
||||
result = {}
|
||||
while pos[0] < len(tokens) and tokens[pos[0]][0] == indent:
|
||||
content = tokens[pos[0]][1]
|
||||
if content.startswith('-'):
|
||||
break
|
||||
key_raw, _, rest_raw = content.partition(':')
|
||||
pos[0] += 1
|
||||
assign(result, key_raw, rest_raw, indent + 1)
|
||||
return result
|
||||
|
||||
def parse_list(indent):
|
||||
result = []
|
||||
while (pos[0] < len(tokens) and tokens[pos[0]][0] == indent
|
||||
and tokens[pos[0]][1].startswith('-')):
|
||||
content = tokens[pos[0]][1]
|
||||
pos[0] += 1
|
||||
body = content[1:].strip()
|
||||
if body == '':
|
||||
result.append(parse_block(indent + 1))
|
||||
elif ':' in body:
|
||||
key_raw, _, rest_raw = body.partition(':')
|
||||
item = {}
|
||||
assign(item, key_raw, rest_raw, indent + 1)
|
||||
item.update(parse_dict(indent + 1)) # rest of this item
|
||||
result.append(item)
|
||||
else:
|
||||
result.append(parse_scalar(body))
|
||||
return result
|
||||
|
||||
def parse_block(indent):
|
||||
if pos[0] >= len(tokens) or tokens[pos[0]][0] != indent:
|
||||
return {}
|
||||
return (parse_list(indent) if tokens[pos[0]][1].startswith('-')
|
||||
else parse_dict(indent))
|
||||
|
||||
return parse_block(0)
|
||||
|
||||
|
||||
# A representative slice of a real knowledge tree: nested mappings, a flat
|
||||
# list, a non-flat list of dicts (like handling.stages), empty {} and [],
|
||||
# and every scalar type _scalar() special-cases.
|
||||
SAMPLE = {
|
||||
'nomenclature': {
|
||||
'design_number_re': r'^([A-Za-z]+)\s*(\d+)',
|
||||
'ignore_sheet_prefixes': ['zvoid', 'zzvoid', 'xxx'],
|
||||
'design_folder': '',
|
||||
},
|
||||
'strands': {'area_in2': {'0.375': 0.085, '0.5': 0.153}},
|
||||
'handling': {
|
||||
'rig_mode': 'rolling',
|
||||
'open_report': True,
|
||||
'transfer_length_in': None,
|
||||
'dynamic_factor': 1.5,
|
||||
'stages': [
|
||||
{'name': 'stripping', 'pci_case': 'stripping', 'multiplier': None,
|
||||
'groups': ['face'], 'rig_mode': None},
|
||||
{'name': 'erection', 'exclude_top_rows': 1,
|
||||
'groups': ['face', 'edge_top']},
|
||||
],
|
||||
'lifter_groups': {},
|
||||
},
|
||||
'flags': {'on': True, 'off': False, 'unset': None},
|
||||
'empty_dict': {},
|
||||
'empty_list': [],
|
||||
'catalogue': {'inserts': {}},
|
||||
}
|
||||
|
||||
|
||||
def test_emit_round_trips_a_representative_knowledge_tree():
|
||||
text = '\n'.join(configdump.emit(SAMPLE)) + '\n'
|
||||
assert _read_emitted(text) == SAMPLE
|
||||
|
||||
|
||||
def test_emit_round_trips_the_real_defaults():
|
||||
# The actual production tree, not a hand-picked sample -- if a future
|
||||
# DEFAULTS edit adds a shape emit() cannot write back correctly, this is
|
||||
# the test that notices.
|
||||
from vibe import knowledge
|
||||
text = '\n'.join(configdump.emit(knowledge.DEFAULTS)) + '\n'
|
||||
assert _read_emitted(text) == knowledge.DEFAULTS
|
||||
|
||||
|
||||
def test_emit_quotes_a_numeric_looking_string_so_it_reads_back_as_a_string():
|
||||
# A part number like '4.10' must not come back as the float 4.1.
|
||||
text = '\n'.join(configdump.emit({'part': '4.10'}))
|
||||
assert text == "part: '4.10'"
|
||||
assert _read_emitted(text + '\n') == {'part': '4.10'}
|
||||
|
||||
|
||||
def test_emit_flat_list_stays_on_one_line():
|
||||
lines = configdump.emit({'a': ['x', 'y', 'z']})
|
||||
assert lines == ['a: [x, y, z]']
|
||||
|
||||
|
||||
def test_emit_empty_containers():
|
||||
lines = configdump.emit({'d': {}, 'l': []})
|
||||
assert lines == ['d: {}', 'l: []']
|
||||
|
||||
|
||||
def test_the_group_tolerance_note_carries_its_diagram():
|
||||
"""The one setting whose meaning lives in a picture: without the diagram,
|
||||
'along the piece' reads as across the width, and the number then guards
|
||||
exactly the wrong direction."""
|
||||
notes = dict(configdump.DECISION_NOTES)
|
||||
assert 'group_tolerance_ft' in notes
|
||||
text = notes['group_tolerance_ft']
|
||||
assert 'x=14.92' in text # the diagram's axis labels
|
||||
assert 'gap <=' in text # and the two branches it decides
|
||||
assert 'gap >' in text
|
||||
|
||||
|
||||
# ================================================== an unreadable layer, in
|
||||
# the dump's own re-read of the files
|
||||
#
|
||||
# `configdump.layer_values()` reads every layer file itself, separately from
|
||||
# `knowledge.load()`, so the dump can say which layer set which value. Its
|
||||
# old shape called `knowledge._merge_file(holder, path)` with NO `layer`
|
||||
# token, so `_note_layer_unreadable` returned immediately and a file that
|
||||
# IS there and will not parse came back as `data = {}` -- indistinguishable
|
||||
# from a layer that is there and genuinely sets nothing. Those are opposite
|
||||
# facts (one is the 255KB-plant-file-silently-voided incident this whole
|
||||
# marker mechanism exists for; the other is nothing going wrong at all), and
|
||||
# the dump is the file a human reads first, so it must not render them the
|
||||
# same way.
|
||||
#
|
||||
# Fixtures below follow `test_knowledge.py`'s `fake_designspec` /
|
||||
# `yaml_on_host` pattern: `vibe.designspec` faked with real paths underneath
|
||||
# it, and the pyRevit YAML reader faked HONESTLY -- strict about duplicate
|
||||
# keys the way YamlDotNet is -- so a "duplicate key" fixture is a real
|
||||
# duplicate key, not a monkeypatched exception standing in for one.
|
||||
|
||||
@pytest.fixture
|
||||
def fake_designspec(monkeypatch, tmp_path):
|
||||
"""`vibe.designspec` with the Revit half faked and the paths REAL.
|
||||
|
||||
Same shape as `test_knowledge.py`'s fixture of the same name -- kept
|
||||
local rather than imported, per that module's own note on why fixtures
|
||||
for this tree do not live in a shared conftest.
|
||||
"""
|
||||
from vibe import plantpath
|
||||
|
||||
job_dir = tmp_path / 'job'
|
||||
job_dir.mkdir()
|
||||
plant_root = tmp_path / 'plants'
|
||||
plant_root.mkdir()
|
||||
monkeypatch.setenv(plantpath.PLANT_ENV, str(plant_root))
|
||||
|
||||
module = types.ModuleType('vibe.designspec')
|
||||
module.PLANT_ENV = plantpath.PLANT_ENV
|
||||
module.plant_knowledge_path = plantpath.plant_knowledge_path
|
||||
module.knowledge_path = lambda doc: str(job_dir / 'knowledge.yaml')
|
||||
module.active_config_path = lambda doc: module._config
|
||||
module._config = None
|
||||
module._job_dir = job_dir
|
||||
module._plant_root = plant_root
|
||||
monkeypatch.setitem(sys.modules, 'vibe.designspec', module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def yaml_on_host(monkeypatch):
|
||||
"""`pyrevit.coreutils.yaml` for host CPython -- as UNFORGIVING as the
|
||||
real one, including refusing a mapping key stated twice. See
|
||||
`pyrevit_yaml_fake.py` and `test_knowledge.py`'s fixture of the same
|
||||
name for why a kinder double would prove nothing here."""
|
||||
from pyrevit_yaml_fake import (install_pyrevit_yaml,
|
||||
strings_only_yaml_reader)
|
||||
install_pyrevit_yaml(monkeypatch, strings_only_yaml_reader())
|
||||
|
||||
|
||||
def _write(path, text):
|
||||
path.write_text(text, encoding='utf-8')
|
||||
return str(path)
|
||||
|
||||
|
||||
_DUPLICATE = (u'nomenclature:\n'
|
||||
u' design_folder: NORTHGATE PRODUCT\n'
|
||||
u' rules:\n'
|
||||
u' cap_label_mismatch_is_view_specific: true\n'
|
||||
u' something_else: 1\n'
|
||||
u' cap_label_mismatch_is_view_specific: false\n')
|
||||
|
||||
|
||||
def test_a_layer_with_a_duplicate_key_is_named_unreadable_with_its_message(
|
||||
fake_designspec, yaml_on_host):
|
||||
"""The core fix: `layer_problems()` finds the file, names the layer, and
|
||||
quotes the parser's own message -- not a generic complaint."""
|
||||
folder = fake_designspec._plant_root / 'northgate'
|
||||
folder.mkdir()
|
||||
path = _write(folder / 'knowledge.yaml', _DUPLICATE)
|
||||
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
||||
u'job:\n plant: northgate\n')
|
||||
|
||||
problems = configdump.layer_problems(None)
|
||||
assert len(problems) == 1
|
||||
label, found_path, message = problems[0]
|
||||
assert label == 'plant'
|
||||
assert found_path == path
|
||||
assert 'Duplicate key' in message
|
||||
|
||||
|
||||
def test_an_unreadable_layers_data_is_marker_free_but_layer_values_shape_unchanged(
|
||||
fake_designspec, yaml_on_host):
|
||||
"""`layer_values()` keeps returning (label, path, exists, data) -- every
|
||||
existing caller unpacks a 4-tuple -- and `data` never leaks the
|
||||
`_plant_unreadable` bookkeeping key as if it were a setting the file
|
||||
made."""
|
||||
folder = fake_designspec._plant_root / 'northgate'
|
||||
folder.mkdir()
|
||||
_write(folder / 'knowledge.yaml', _DUPLICATE)
|
||||
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
||||
u'job:\n plant: northgate\n')
|
||||
|
||||
rows = configdump.layer_values(None)
|
||||
plant_rows = [row for row in rows if row[0] == 'plant']
|
||||
assert len(plant_rows) == 1
|
||||
label, path, exists, data = plant_rows[0]
|
||||
assert exists is True
|
||||
assert data == {}
|
||||
assert '_plant_unreadable' not in data
|
||||
|
||||
|
||||
def test_an_empty_layer_file_is_not_reported_as_a_problem(
|
||||
fake_designspec, yaml_on_host):
|
||||
"""The opposite fixture: a plant file that IS there and genuinely sets
|
||||
nothing. `layer_problems()` must stay silent about it -- this is the
|
||||
'sets nothing' case the dump must still be able to say, unconfused with
|
||||
the unreadable one above."""
|
||||
folder = fake_designspec._plant_root / 'northgate'
|
||||
folder.mkdir()
|
||||
_write(folder / 'knowledge.yaml', u'')
|
||||
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
||||
u'job:\n plant: northgate\n')
|
||||
|
||||
assert configdump.layer_problems(None) == []
|
||||
rows = configdump.layer_values(None)
|
||||
plant_rows = [row for row in rows if row[0] == 'plant']
|
||||
assert len(plant_rows) == 1
|
||||
label, path, exists, data = plant_rows[0]
|
||||
assert exists is True
|
||||
assert data == {}
|
||||
|
||||
|
||||
def test_layer_problems_is_empty_when_every_layer_reads_clean(
|
||||
fake_designspec, yaml_on_host):
|
||||
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
||||
u'scope:\n declared_by: Will\n')
|
||||
assert configdump.layer_problems(None) == []
|
||||
|
||||
|
||||
def test_unreadable_layer_banner_names_the_path_and_quotes_the_message():
|
||||
banner = configdump.unreadable_layer_banner(
|
||||
[('plant', 'C:/plants/northgate/knowledge.yaml',
|
||||
'Duplicate key: cap_label at line 812')])
|
||||
assert 'plant' in banner
|
||||
assert 'C:/plants/northgate/knowledge.yaml' in banner
|
||||
assert 'Duplicate key: cap_label at line 812' in banner
|
||||
assert 'COULD NOT BE READ' in banner
|
||||
# visually distinct from the ordinary "layer sets nothing" case -- not
|
||||
# merely present, but explicit that this is not that
|
||||
assert 'EXACTLY like a layer that agrees with the defaults' in banner
|
||||
|
||||
|
||||
def test_unreadable_layer_banner_is_empty_string_with_no_problems():
|
||||
assert configdump.unreadable_layer_banner([]) == u''
|
||||
|
||||
|
||||
def test_build_marks_an_unreadable_layer_in_its_own_header_line(
|
||||
fake_designspec, yaml_on_host):
|
||||
"""The per-layer summary line `build()` writes into its header, built by
|
||||
`_layer_status_suffix()`, must not read the same for an unreadable layer
|
||||
as for one that is simply absent or that read clean -- all three are
|
||||
different facts. Exercised through the real `_layer_rows()` +
|
||||
`_layer_status_suffix()` pair `build()` itself uses, not a re-implemented
|
||||
copy of the logic."""
|
||||
folder = fake_designspec._plant_root / 'northgate'
|
||||
folder.mkdir()
|
||||
_write(folder / 'knowledge.yaml', _DUPLICATE)
|
||||
_write(fake_designspec._job_dir / 'knowledge.yaml',
|
||||
u'job:\n plant: northgate\n')
|
||||
|
||||
rows = configdump._layer_rows(None)
|
||||
plant_row = [row for row in rows if row[0] == 'plant'][0]
|
||||
_label, _path, exists, _data, unreadable = plant_row
|
||||
suffix = configdump._layer_status_suffix(exists, unreadable)
|
||||
assert '!! UNREADABLE !!' in suffix
|
||||
assert '-- not present' not in suffix
|
||||
|
||||
|
||||
def test_layer_status_suffix_the_three_facts_never_collapse():
|
||||
assert configdump._layer_status_suffix(True, None) == u''
|
||||
assert configdump._layer_status_suffix(False, None) == u' -- not present'
|
||||
assert configdump._layer_status_suffix(True, 'boom') == \
|
||||
u' !! UNREADABLE !!'
|
||||
# a layer cannot be both absent and unreadable, but if that ever became
|
||||
# possible the loud fact must win, not the quiet one
|
||||
assert configdump._layer_status_suffix(False, 'boom') == \
|
||||
u' !! UNREADABLE !!'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,875 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for vibe.configmodel -- the Revit-free half of the config editor.
|
||||
|
||||
Everything the Project Config button does except talking to Revit and drawing
|
||||
a dialog lives in `configmodel`: which settings exist, what layer each value
|
||||
came from, whether a value is legal, and the splice that writes one key into a
|
||||
YAML file without disturbing the thousand lines of comments around it. All of
|
||||
that is testable here, on host CPython, with no model and no licence -- which
|
||||
is the point of keeping it out of the button script.
|
||||
|
||||
The layer fixtures below are the shape `configdump.layer_values()` returns:
|
||||
(label, path, exists, data), in merge order.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from vibe import configmodel
|
||||
|
||||
|
||||
def layers(defaults=None, catalogue=None, plant=None, job=None, config=None,
|
||||
missing=()):
|
||||
rows = [('defaults', None, True, defaults or {}),
|
||||
('catalogue', 'C:/repo/catalogue.yaml', 'catalogue' not in missing,
|
||||
catalogue or {}),
|
||||
('plant', 'C:/plant/knowledge.yaml', 'plant' not in missing,
|
||||
plant or {}),
|
||||
('job', 'C:/job/knowledge.yaml', 'job' not in missing, job or {})]
|
||||
if config is not None:
|
||||
rows.append(('config', 'C:/job/project-config.yaml', True, config))
|
||||
return rows
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the inventory
|
||||
|
||||
def test_every_setting_is_either_editable_or_says_why_not():
|
||||
for row in configmodel.SETTINGS:
|
||||
assert row['edit'] in ('job', 'ro')
|
||||
if row['edit'] == 'ro':
|
||||
assert row['why'], '{0} is read-only with no reason'.format(
|
||||
row['key'])
|
||||
|
||||
|
||||
def test_the_plant_layer_is_not_editable_from_here():
|
||||
"""The ruling: plant edits do not happen in this button, at all."""
|
||||
keys = [row['key'] for row in configmodel.editable()]
|
||||
for plant_only in ('handling.lifter_groups', 'lifters.capacity_lb',
|
||||
'lifters.catalogue_alias', 'handling.python_exe'):
|
||||
assert plant_only not in keys
|
||||
|
||||
|
||||
def test_parameters_are_shown_even_though_they_are_not_edited():
|
||||
assert 'parameters' in [name for name, _note in configmodel.SUBTREES]
|
||||
|
||||
|
||||
def test_the_lifter_factors_of_safety_are_shown_not_edited():
|
||||
# The paths are `catalogue.safety_factors.*`, not `rigging.*`. `rigging`
|
||||
# is the shape of panellift's JOB FILE, which liftexport generates -- it
|
||||
# was never a knowledge path, and while these rows named it they pointed
|
||||
# at a key no layer declared. Corrected 2026-08-30 after a knowledge/code
|
||||
# drift audit found it.
|
||||
#
|
||||
# AND BOTH ARE NULL as of 2026-08-31, so the `why` has to say null ON
|
||||
# PURPOSE or the screen reads as an unfilled setting somebody should go
|
||||
# and fill. The factors are per part now, inside the catalogue entries;
|
||||
# this pair is only a fallback for hardware the catalogue does not cover.
|
||||
for key in ('catalogue.safety_factors.tension_fos',
|
||||
'catalogue.safety_factors.shear_fos'):
|
||||
row = configmodel.setting(key)
|
||||
assert row['edit'] == 'ro'
|
||||
assert 'null on purpose' in row['why'].lower(), row['why']
|
||||
assert 'fallback' in row['why'].lower(), row['why']
|
||||
|
||||
|
||||
def test_the_factors_of_safety_are_not_offered_under_a_rigging_path():
|
||||
# Regression: naming a key nothing declares makes the screen report it as
|
||||
# unset forever, and no edit could ever reach it.
|
||||
for gone in ('rigging.tension_fos', 'rigging.shear_fos'):
|
||||
assert configmodel.setting(gone) is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------- provenance
|
||||
|
||||
def test_resolve_reports_the_winning_layer_and_the_whole_chain():
|
||||
res = configmodel.resolve(
|
||||
layers(defaults={'handling': {'rig_mode': 'rolling'}},
|
||||
plant={'handling': {'rig_mode': 'static'}}),
|
||||
'handling.rig_mode')
|
||||
assert res['value'] == 'static'
|
||||
assert res['layer'] == 'plant'
|
||||
assert [label for label, _p, _v in res['chain']] == ['defaults', 'plant']
|
||||
assert res['overrides'] == [('defaults', 'rolling')]
|
||||
|
||||
|
||||
def test_a_layer_that_restates_a_value_is_not_an_override():
|
||||
res = configmodel.resolve(
|
||||
layers(defaults={'handling': {'finish': 'smooth'}},
|
||||
job={'handling': {'finish': 'smooth'}}), 'handling.finish')
|
||||
assert res['layer'] == 'job'
|
||||
assert res['overrides'] == []
|
||||
|
||||
|
||||
def test_a_setting_no_layer_mentions_says_so_rather_than_going_blank():
|
||||
res = configmodel.resolve(layers(), 'handling.rig_mode')
|
||||
assert res['known'] is False
|
||||
assert configmodel.chip(res) == '?'
|
||||
text = '\n'.join(configmodel.chain_lines(res))
|
||||
assert 'NOT SET IN ANY LAYER' in text
|
||||
|
||||
|
||||
def test_a_missing_layer_file_is_named_not_silently_skipped():
|
||||
res = configmodel.resolve(
|
||||
layers(defaults={'handling': {'finish': 'smooth'}}, missing=('plant',)),
|
||||
'handling.finish')
|
||||
assert res['missing'] == ['plant']
|
||||
assert 'file not present' in '\n'.join(configmodel.chain_lines(res))
|
||||
|
||||
|
||||
def test_the_config_layer_wins_and_the_chain_says_the_edit_will_not_reach():
|
||||
res = configmodel.resolve(
|
||||
layers(plant={'handling': {'rig_mode': 'static'}},
|
||||
config={'handling': {'rig_mode': 'rolling'}}),
|
||||
'handling.rig_mode')
|
||||
assert res['layer'] == 'config'
|
||||
text = '\n'.join(configmodel.chain_lines(res))
|
||||
assert 'will NOT reach this model' in text
|
||||
|
||||
|
||||
def test_a_read_only_setting_shows_its_reason_instead_of_an_edit_target():
|
||||
res = configmodel.resolve(layers(), 'handling.python_exe')
|
||||
text = '\n'.join(configmodel.chain_lines(res))
|
||||
assert 'NOT EDITABLE HERE' in text
|
||||
assert 'Editing target' not in text
|
||||
|
||||
|
||||
def test_fast_lifter_box_is_never_shown_as_a_boolean():
|
||||
assert configmodel.display_value('handling.fast_lifter_box', True) == 'fast'
|
||||
assert configmodel.display_value('handling.fast_lifter_box',
|
||||
False) == 'precise'
|
||||
assert configmodel.to_stored('handling.fast_lifter_box', 'fast') is True
|
||||
|
||||
|
||||
def test_no_value_ever_formats_as_an_empty_string():
|
||||
for value in (None, '', [], {}, ' '):
|
||||
assert configmodel.format_value(value).strip()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- lists
|
||||
|
||||
def test_a_list_edit_says_it_replaces_and_names_what_was_replaced():
|
||||
res = configmodel.resolve(
|
||||
layers(defaults={'families': {'lifter_include': ['LIFT', 'LPA',
|
||||
'ANCHOR']}},
|
||||
plant={'families': {'lifter_include': ['LIFT', 'ANCHOR',
|
||||
'A1', 'X1']}}),
|
||||
'families.lifter_include')
|
||||
text = '\n'.join(configmodel.replace_warning(res))
|
||||
assert 'LISTS REPLACE' in text
|
||||
assert 'already REPLACED defaults' in text
|
||||
assert 'LPA' in text
|
||||
assert configmodel.start_from(res) == ['LIFT', 'ANCHOR', 'A1', 'X1']
|
||||
|
||||
|
||||
def test_start_from_is_empty_when_nothing_is_in_force():
|
||||
assert configmodel.start_from(configmodel.resolve(
|
||||
layers(), 'families.lifter_include')) == []
|
||||
|
||||
|
||||
# -------------------------------------------------------------- entering
|
||||
|
||||
@pytest.mark.parametrize('text,expected', [
|
||||
('3.5', 3.5), ('', None), ('null', None), (' 2 ', 2.0)])
|
||||
def test_numbers_parse_and_blank_means_null(text, expected):
|
||||
ok, value, _why = configmodel.parse_input('float', text)
|
||||
assert ok and value == expected
|
||||
|
||||
|
||||
def test_a_typo_in_a_choice_is_refused_not_guessed():
|
||||
ok, _value, why = configmodel.parse_input(
|
||||
'choice', 'static_lift', configmodel.RIG_MODES)
|
||||
assert not ok and 'static_lift' in why
|
||||
|
||||
|
||||
def test_a_list_is_entered_as_text_and_split_on_commas_and_lines():
|
||||
ok, value, _why = configmodel.parse_input('list', 'LIFT, ANCHOR\n A1 ,')
|
||||
assert ok and value == ['LIFT', 'ANCHOR', 'A1']
|
||||
|
||||
|
||||
# ------------------------------------------------------------- validation
|
||||
|
||||
@pytest.mark.parametrize('key,value,ok', [
|
||||
('handling.rig_mode', 'static', True),
|
||||
('handling.rig_mode', 'static_lift', False),
|
||||
('handling.group_tolerance_ft', 0.4, True),
|
||||
('handling.group_tolerance_ft', 0.0, False),
|
||||
('handling.group_tolerance_ft', None, False),
|
||||
('handling.sling_angle_deg', 60.0, True),
|
||||
('handling.sling_angle_deg', 91.0, False),
|
||||
('handling.prestress_loss_pct', 8.0, True),
|
||||
('handling.prestress_loss_pct', 100.0, False),
|
||||
('handling.product_type', 'flat', True),
|
||||
('handling.product_type', 'flatt', False),
|
||||
('handling.dunnage.fraction', 0.21, True),
|
||||
('handling.dunnage.fraction', 0.7, False),
|
||||
('families.lifter_include', [], False),
|
||||
('families.lifter_include', ['LIFT'], True),
|
||||
])
|
||||
def test_validation(key, value, ok):
|
||||
assert bool(configmodel.validate(key, value)) is (not ok)
|
||||
|
||||
|
||||
def test_fraction_mode_needs_a_fraction_that_is_already_set():
|
||||
problems = configmodel.validate('handling.dunnage.mode', 'fraction',
|
||||
{'handling': {'dunnage': {}}})
|
||||
assert problems and 'fraction must be set' in problems[0]
|
||||
assert not configmodel.validate(
|
||||
'handling.dunnage.mode', 'fraction',
|
||||
{'handling': {'dunnage': {'fraction': 0.2}}})
|
||||
|
||||
|
||||
def test_a_stage_with_no_multiplier_needs_a_pci_case():
|
||||
stages = [{'name': 'stripping', 'multiplier': None, 'strength': 'fci'}]
|
||||
problems = configmodel.validate('handling.stages', stages)
|
||||
assert any('pci_case' in p for p in problems)
|
||||
|
||||
|
||||
def test_a_stage_naming_an_unknown_insert_group_is_refused():
|
||||
know = {'handling': {'lifter_groups': {'face': {}}}}
|
||||
stages = [{'name': 'strip', 'pci_case': 'stripping', 'strength': 'fci',
|
||||
'groups': ['edge_top']}]
|
||||
problems = configmodel.validate('handling.stages', stages, know)
|
||||
assert any('edge_top' in p for p in problems)
|
||||
|
||||
|
||||
def test_no_stage_enabled_is_refused():
|
||||
stages = [{'name': 'strip', 'pci_case': 'stripping', 'strength': 'fci',
|
||||
'enabled': False}]
|
||||
assert any('at least one stage enabled' in p
|
||||
for p in configmodel.validate('handling.stages', stages))
|
||||
|
||||
|
||||
def test_a_wide_weight_tolerance_warns_but_does_not_refuse():
|
||||
assert not configmodel.validate('handling.weight_tolerance', 0.08)
|
||||
assert configmodel.warnings('handling.weight_tolerance', 0.08)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the splice
|
||||
|
||||
JOB = u"""# A job knowledge file, comments and all.
|
||||
|
||||
rails:
|
||||
# this job's left rail is not fixed
|
||||
left_rail_is_fixed: false
|
||||
|
||||
handling:
|
||||
# Where the reports go. Written by a person, kept by the splice.
|
||||
report_dir: X:/somewhere/lifting
|
||||
stages:
|
||||
- name: stripping
|
||||
pci_case: stripping
|
||||
- name: erection
|
||||
pci_case: yard
|
||||
|
||||
# a trailing comment about nothing
|
||||
"""
|
||||
|
||||
|
||||
def _parse(text):
|
||||
from vibe import yamlkit
|
||||
return yamlkit.parse(text)
|
||||
|
||||
|
||||
def test_an_existing_scalar_is_replaced_and_the_comments_survive():
|
||||
out = configmodel.splice(JOB, 'handling.report_dir', 'Y:/new')
|
||||
assert _parse(out)['handling']['report_dir'] == 'Y:/new'
|
||||
assert "this job's left rail is not fixed" in out
|
||||
assert 'Where the reports go' in out
|
||||
assert 'a trailing comment about nothing' in out
|
||||
assert not configmodel.verify(JOB, out, 'handling.report_dir', 'Y:/new')
|
||||
|
||||
|
||||
def test_a_new_key_lands_inside_the_parent_that_already_exists():
|
||||
out = configmodel.splice(JOB, 'handling.rig_mode', 'static')
|
||||
tree = _parse(out)
|
||||
assert tree['handling']['rig_mode'] == 'static'
|
||||
assert tree['handling']['report_dir'] == 'X:/somewhere/lifting'
|
||||
assert len(tree['handling']['stages']) == 2
|
||||
assert not configmodel.verify(JOB, out, 'handling.rig_mode', 'static')
|
||||
|
||||
|
||||
def test_a_new_parent_is_appended_whole():
|
||||
out = configmodel.splice(JOB, 'families.lifter_include', ['LIFT', 'A1'])
|
||||
assert _parse(out)['families']['lifter_include'] == ['LIFT', 'A1']
|
||||
assert not configmodel.verify(JOB, out, 'families.lifter_include',
|
||||
['LIFT', 'A1'])
|
||||
|
||||
|
||||
def test_a_new_key_three_deep_creates_the_nesting():
|
||||
out = configmodel.splice(JOB, 'handling.dunnage.mode', 'fraction')
|
||||
assert _parse(out)['handling']['dunnage']['mode'] == 'fraction'
|
||||
assert not configmodel.verify(JOB, out, 'handling.dunnage.mode',
|
||||
'fraction')
|
||||
|
||||
|
||||
def test_replacing_a_block_replaces_the_whole_block():
|
||||
stages = [{'name': 'strip', 'pci_case': 'stripping', 'rig_mode': 'static'}]
|
||||
out = configmodel.splice(JOB, 'handling.stages', stages)
|
||||
tree = _parse(out)
|
||||
assert len(tree['handling']['stages']) == 1
|
||||
assert tree['handling']['stages'][0]['rig_mode'] == 'static'
|
||||
assert tree['handling']['report_dir'] == 'X:/somewhere/lifting'
|
||||
assert not configmodel.verify(JOB, out, 'handling.stages', stages)
|
||||
|
||||
|
||||
def test_the_note_is_written_above_the_value_and_not_stacked_on_re_edit():
|
||||
res = configmodel.resolve(
|
||||
layers(defaults={'handling': {'rig_mode': 'rolling'}}),
|
||||
'handling.rig_mode')
|
||||
note = configmodel.note_lines('handling.rig_mode', res, 'this yard strips '
|
||||
'on fixed slings', when='2026-08-30')
|
||||
once = configmodel.splice(JOB, 'handling.rig_mode', 'static', note)
|
||||
assert ' # override: defaults says rolling' in once
|
||||
assert 'fixed slings' in once
|
||||
twice = configmodel.splice(once, 'handling.rig_mode', 'rolling', note)
|
||||
assert twice.count('# override: defaults says rolling') == 1
|
||||
|
||||
|
||||
def test_verify_catches_a_splice_that_changed_something_else():
|
||||
good = configmodel.splice(JOB, 'handling.report_dir', 'Y:/new')
|
||||
damaged = good.replace(' - name: erection\n', '')
|
||||
assert configmodel.verify(JOB, damaged, 'handling.report_dir', 'Y:/new')
|
||||
|
||||
|
||||
def test_verify_catches_an_edit_that_did_not_take():
|
||||
assert configmodel.verify(JOB, JOB, 'handling.report_dir', 'Y:/new')
|
||||
|
||||
|
||||
def test_the_write_is_refused_when_the_two_readers_disagree():
|
||||
assert configmodel.reader_disagreement(
|
||||
{'handling': {'rig_mode': 'static'}},
|
||||
{'handling': {'rig_mode': 'rolling'}}, 'handling.rig_mode')
|
||||
assert configmodel.reader_disagreement(
|
||||
{}, {'handling': {'rig_mode': 'rolling'}}, 'handling.rig_mode')
|
||||
# pyRevit's reader hands back strings; a string 8.0 and a float 8.0 are
|
||||
# the same value written two ways, not a disagreement.
|
||||
assert not configmodel.reader_disagreement(
|
||||
{'handling': {'prestress_loss_pct': '8.0'}},
|
||||
{'handling': {'prestress_loss_pct': 8.0}},
|
||||
'handling.prestress_loss_pct')
|
||||
|
||||
|
||||
# --------------------------------------------------- run-time options
|
||||
|
||||
def test_run_options_default_on_and_have_no_layers():
|
||||
assert configmodel.default_run_options()['expensive_geometry'] is True
|
||||
assert 'expensive_geometry' not in [row['key']
|
||||
for row in configmodel.SETTINGS]
|
||||
|
||||
|
||||
def test_run_options_round_trip_and_keep_a_key_from_a_newer_build():
|
||||
text = configmodel.render_run_options({'expensive_geometry': False,
|
||||
'something_new': 3})
|
||||
out = configmodel.read_run_options(text)
|
||||
assert out['expensive_geometry'] is False
|
||||
assert out['something_new'] == 3
|
||||
assert 'NOT knowledge' in text
|
||||
|
||||
|
||||
def test_missing_run_options_file_means_every_default():
|
||||
assert configmodel.read_run_options(None) == \
|
||||
configmodel.default_run_options()
|
||||
|
||||
|
||||
def test_a_replaced_key_is_never_left_as_a_second_copy():
|
||||
"""The bug this caught: nesting level compared against a column count.
|
||||
|
||||
`report_dir` sits two spaces in at nesting level 1. Comparing those two
|
||||
numbers directly says "not found", and the splice then appends a SECOND
|
||||
`report_dir:` below the first -- a file that reads back correctly through
|
||||
a last-wins parser and is wrong for a person.
|
||||
"""
|
||||
out = configmodel.splice(JOB, 'handling.report_dir', 'Y:/new')
|
||||
assert out.count('report_dir:') == 1
|
||||
again = configmodel.splice(out, 'handling.report_dir', 'Z:/newer')
|
||||
assert again.count('report_dir:') == 1
|
||||
|
||||
|
||||
def test_run_options_round_trip_through_a_real_folder(tmp_path):
|
||||
root = str(tmp_path)
|
||||
assert configmodel.load_run_options(root) == \
|
||||
configmodel.default_run_options()
|
||||
configmodel.save_run_options(root, {'expensive_geometry': False})
|
||||
assert configmodel.load_run_options(root)['expensive_geometry'] is False
|
||||
assert configmodel.run_options_path(root).endswith('run-options.yaml')
|
||||
|
||||
|
||||
# ------------------------------------- the position search, as a run option
|
||||
|
||||
def test_the_position_search_is_a_run_option_and_defaults_off():
|
||||
"""OFF, by the owner's ruling of 2026-08-30 ("searching insert location
|
||||
还是非常慢"). He re-checks pieces far more often than he asks where the
|
||||
inserts should have gone, and the search is 93% of the run -- 12.6 s of
|
||||
a 13.6 s median piece. The report always says when positions were not
|
||||
computed, so OFF is visible, never silent; switching it on is one toggle
|
||||
in ProjectConfig for the piece that wants the answer.
|
||||
|
||||
(This inverts the original default. The reasoning both ways is in
|
||||
RUN_OPTIONS' own comment; the owner's usage decided it.)
|
||||
"""
|
||||
row = next((o for o in configmodel.RUN_OPTIONS
|
||||
if o['key'] == 'recommend_positions'), None)
|
||||
assert row is not None
|
||||
assert row['default'] is False
|
||||
|
||||
def test_the_position_search_option_round_trips(tmp_path):
|
||||
root = str(tmp_path)
|
||||
configmodel.save_run_options(root, {'recommend_positions': False})
|
||||
assert configmodel.load_run_options(root)['recommend_positions'] is False
|
||||
text = configmodel.render_run_options({'recommend_positions': False})
|
||||
assert 'recommend_positions' in text
|
||||
|
||||
|
||||
def test_the_position_search_note_says_a_batch_never_runs_it():
|
||||
"""The option governs the single-piece check only, and somebody reading
|
||||
the file has to be told that rather than discovering it."""
|
||||
row = configmodel.run_option('recommend_positions')
|
||||
assert row is not None
|
||||
assert 'BATCH' in row['note']
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# checks.muted -- per-job check muting, typed on the config screen
|
||||
# ==================================================================
|
||||
#
|
||||
# Owner, 2026-08-30: "让我可以注释掉一些check。比如说这个项目里面3.3我就想注释
|
||||
# 掉。因为所有的panel都不会有这个,一直看着NOT_RUN我也糟心。"
|
||||
#
|
||||
# The feature removes rows from a checklist somebody signs, so every guard
|
||||
# around it is a refusal, not a warning.
|
||||
|
||||
|
||||
def test_a_map_is_typed_one_entry_per_line():
|
||||
ok, value, why = configmodel.parse_input('map', '3.3 = no piece has one')
|
||||
assert ok and why is None
|
||||
assert value == {'3.3': 'no piece has one'}
|
||||
|
||||
|
||||
def test_a_map_accepts_a_colon_and_splits_on_the_first_one_only():
|
||||
"""The value is a sentence, and a sentence contains colons."""
|
||||
ok, value, _why = configmodel.parse_input(
|
||||
'map', '3.3: reason: this job has no release requirement')
|
||||
assert ok
|
||||
assert value == {'3.3': 'reason: this job has no release requirement'}
|
||||
|
||||
|
||||
def test_a_map_reads_several_lines():
|
||||
ok, value, _why = configmodel.parse_input(
|
||||
'map', '3.3 = never applies\n5.3 = decided by hand here\n')
|
||||
assert ok
|
||||
assert value == {'3.3': 'never applies', '5.3': 'decided by hand here'}
|
||||
|
||||
|
||||
def test_a_map_entry_with_no_reason_is_refused_at_the_keyboard():
|
||||
ok, _value, why = configmodel.parse_input('map', '3.3')
|
||||
assert not ok
|
||||
assert 'value is REQUIRED' in why
|
||||
|
||||
|
||||
def test_a_map_entry_with_an_empty_reason_is_refused():
|
||||
ok, _value, why = configmodel.parse_input('map', '3.3 =')
|
||||
assert not ok
|
||||
assert 'no value' in why
|
||||
|
||||
|
||||
def test_a_blank_map_clears_it():
|
||||
ok, value, _why = configmodel.parse_input('map', '')
|
||||
assert ok and value == {}
|
||||
|
||||
|
||||
def test_muting_with_a_reason_validates():
|
||||
assert configmodel.validate(
|
||||
'checks.muted', {'3.3': 'no piece on this job has one'}) == []
|
||||
|
||||
|
||||
def test_muting_with_no_reason_is_refused():
|
||||
"""A row that vanished for a reason nobody wrote down reads as a bug."""
|
||||
out = configmodel.validate('checks.muted', {'3.3': ''})
|
||||
assert out
|
||||
assert 'reason is' in out[0] or 'REQUIRED' in out[0]
|
||||
|
||||
|
||||
def test_muting_a_number_that_is_not_a_checklist_item_is_refused():
|
||||
out = configmodel.validate('checks.muted', {'9.9': 'typo'})
|
||||
assert out
|
||||
assert 'not a ticket-checklist item' in out[0]
|
||||
|
||||
|
||||
def test_muting_must_be_a_map_not_a_list():
|
||||
"""A list would not deep-merge across the layers, and carries no reason."""
|
||||
out = configmodel.validate('checks.muted', ['3.3'])
|
||||
assert out
|
||||
assert 'map' in out[0]
|
||||
|
||||
|
||||
def test_no_muting_at_all_is_fine():
|
||||
assert configmodel.validate('checks.muted', {}) == []
|
||||
assert configmodel.validate('checks.muted', None) == []
|
||||
|
||||
|
||||
def test_muting_is_editable_on_the_config_screen():
|
||||
"""It is a job decision with a reason, so it is made where the note is
|
||||
written -- not typed into a YAML file with no provenance."""
|
||||
row = configmodel.setting('checks.muted')
|
||||
assert row is not None
|
||||
assert row['edit'] == 'job'
|
||||
assert row['kind'] == 'map'
|
||||
assert row in configmodel.editable()
|
||||
|
||||
|
||||
def test_muting_ships_empty():
|
||||
"""Nothing is muted by default, and empty is the only honest default."""
|
||||
from vibe import knowledge
|
||||
assert knowledge.DEFAULTS['checks']['muted'] == {}
|
||||
|
||||
|
||||
# ------------------------------------------------- maps the layers MERGE
|
||||
#
|
||||
# `knowledge._deep_update` recurses into a dict and assigns anything else, so a
|
||||
# LIST replaces and a MAP merges. `resolve` answers for the first shape. Asking
|
||||
# it about the second is what made the config window show one mute and hide
|
||||
# another: with the plant muting 3.3 and the job muting 5.4 it answers
|
||||
# `{'5.4': ...}`, and a screen built on that answer lets somebody "remove" a
|
||||
# plant mute the merge puts straight back.
|
||||
|
||||
def _muted(plant=None, job=None, defaults=None):
|
||||
def wrap(value):
|
||||
return {'checks': {'muted': value}} if value is not None else {}
|
||||
return layers(defaults=wrap(defaults), plant=wrap(plant), job=wrap(job))
|
||||
|
||||
|
||||
def test_a_merged_map_keeps_every_layers_entries():
|
||||
merged = configmodel.merged_map(
|
||||
_muted(plant={'3.3': 'the plant never has one'},
|
||||
job={'5.4': 'no mesh on this job'}), 'checks.muted')
|
||||
assert sorted(merged) == ['3.3', '5.4']
|
||||
assert merged['3.3']['layer'] == 'plant'
|
||||
assert merged['5.4']['layer'] == 'job'
|
||||
|
||||
|
||||
def test_resolve_alone_would_have_hidden_the_plant_entry():
|
||||
"""The bug, pinned. `resolve` is right about a key that REPLACES and
|
||||
wrong about one that merges, and the difference is invisible until two
|
||||
layers have an opinion."""
|
||||
given = _muted(plant={'3.3': 'plant'}, job={'5.4': 'job'})
|
||||
assert configmodel.resolve(given, 'checks.muted')['value'] == \
|
||||
{'5.4': 'job'}
|
||||
assert sorted(configmodel.merged_map(given, 'checks.muted')) == \
|
||||
['3.3', '5.4']
|
||||
|
||||
|
||||
def test_a_later_layer_naming_the_same_entry_beats_it_and_says_what_it_beat():
|
||||
merged = configmodel.merged_map(
|
||||
_muted(plant={'3.3': 'plant reason'}, job={'3.3': 'job reason'}),
|
||||
'checks.muted')
|
||||
assert merged['3.3']['value'] == 'job reason'
|
||||
assert merged['3.3']['layer'] == 'job'
|
||||
assert merged['3.3']['overrides'] == [('plant', 'plant reason')]
|
||||
|
||||
|
||||
def test_a_layer_that_sets_the_key_to_something_that_is_not_a_map_replaces():
|
||||
"""Mirrors `_deep_update`'s sharp edge rather than smoothing it: a list
|
||||
there really does wipe the map above it, and a merger that pretended
|
||||
otherwise would describe a file the run does not read."""
|
||||
merged = configmodel.merged_map(
|
||||
_muted(plant={'3.3': 'plant'}, job=['5.4']), 'checks.muted')
|
||||
assert merged == {}
|
||||
|
||||
|
||||
def test_layer_map_is_this_files_own_entries_and_not_the_merge():
|
||||
"""The splice baseline. Starting from the MERGE would copy the plant's
|
||||
entries into the job file, where they would stop tracking the plant for
|
||||
good -- silently, which is the only way this project ever gets hurt."""
|
||||
given = _muted(plant={'3.3': 'plant'}, job={'5.4': 'job'})
|
||||
assert configmodel.layer_map(given, 'checks.muted', 'job') == \
|
||||
{'5.4': 'job'}
|
||||
assert configmodel.layer_map(given, 'checks.muted', 'plant') == \
|
||||
{'3.3': 'plant'}
|
||||
|
||||
|
||||
def test_layer_map_is_empty_when_that_layer_never_mentions_the_key():
|
||||
"""Empty is correct, not missing: `splice` creates a key that is not
|
||||
there, and an edit has to start from nothing rather than from someone
|
||||
else's map."""
|
||||
assert configmodel.layer_map(_muted(plant={'3.3': 'plant'}),
|
||||
'checks.muted', 'job') == {}
|
||||
|
||||
|
||||
def test_the_chip_for_a_merged_map_never_names_one_layer_as_the_owner():
|
||||
two = configmodel.merged_map(_muted(plant={'3.3': 'a'}, job={'5.4': 'b'}),
|
||||
'checks.muted')
|
||||
one = configmodel.merged_map(_muted(plant={'3.3': 'a'}), 'checks.muted')
|
||||
assert configmodel.map_chip(two) == '+'
|
||||
assert configmodel.map_layers(two) == ['plant', 'job']
|
||||
assert configmodel.map_chip(one) == 'P'
|
||||
assert configmodel.map_chip({}) == '?'
|
||||
|
||||
|
||||
def test_a_merged_map_formats_with_the_layer_beside_each_entry():
|
||||
merged = configmodel.merged_map(_muted(plant={'3.3': 'a'},
|
||||
job={'5.4': 'b'}), 'checks.muted')
|
||||
assert configmodel.format_merged_map(merged) == \
|
||||
'2 entries: 3.3 (plant), 5.4 (job)'
|
||||
assert configmodel.format_merged_map({}) == '(nothing)'
|
||||
|
||||
|
||||
def test_only_the_keys_declared_as_merging_are_treated_that_way():
|
||||
assert configmodel.merges('checks.muted') is True
|
||||
assert configmodel.merges('families.lifter_include') is False
|
||||
|
||||
|
||||
# ------------------------------------------------ the note over a merged map
|
||||
|
||||
def test_the_note_over_a_merged_map_never_calls_it_an_override():
|
||||
"""A job entry ADDS to what the plant muted; it does not displace it.
|
||||
"# override: plant says 2 keys" would be a sentence that is simply
|
||||
untrue, in the one place a reader has left six months later."""
|
||||
res = configmodel.resolve(_muted(plant={'3.3': 'a'}), 'checks.muted')
|
||||
body = '\n'.join(configmodel.note_lines('checks.muted', res, 'because',
|
||||
when='2026-08-30'))
|
||||
assert 'override:' not in body
|
||||
assert 'merged:' in body
|
||||
assert 'plant sets entries here too' in body
|
||||
|
||||
|
||||
def test_a_merged_map_note_is_not_stacked_on_re_edit():
|
||||
text = "checks:\n muted:\n '3.3': a\n"
|
||||
res = configmodel.resolve(_muted(job={'3.3': 'a'}), 'checks.muted')
|
||||
note = configmodel.note_lines('checks.muted', res, 'first',
|
||||
when='2026-08-30')
|
||||
once = configmodel.splice(text, 'checks.muted', {'3.3': 'b'}, note)
|
||||
twice = configmodel.splice(once, 'checks.muted', {'3.3': 'c'},
|
||||
configmodel.note_lines('checks.muted', res,
|
||||
'second',
|
||||
when='2026-08-30'))
|
||||
assert twice.count('# merged:') == once.count('# merged:')
|
||||
assert 'first' not in twice
|
||||
|
||||
|
||||
def test_the_continuation_note_line_is_recognised_and_not_stacked():
|
||||
"""`# (defaults said X)` was not in `_NOTE`, so the upward walk stopped on
|
||||
it and every re-edit of a key more than one layer had touched stacked
|
||||
another block of notes on the last -- leaving the OLDEST, the wrong one,
|
||||
readable at the top."""
|
||||
given = layers(defaults={'handling': {'rig_mode': 'rolling'}},
|
||||
plant={'handling': {'rig_mode': 'static'}})
|
||||
res = configmodel.resolve(given, 'handling.rig_mode')
|
||||
assert res['overrides'] == [('defaults', 'rolling')]
|
||||
text = 'handling:\n rig_mode: static\n'
|
||||
once = configmodel.splice(text, 'handling.rig_mode', 'rolling',
|
||||
configmodel.note_lines('handling.rig_mode', res,
|
||||
'first',
|
||||
when='2026-08-30'))
|
||||
twice = configmodel.splice(once, 'handling.rig_mode', 'static',
|
||||
configmodel.note_lines('handling.rig_mode', res,
|
||||
'second',
|
||||
when='2026-08-30'))
|
||||
assert twice.count('(defaults said rolling)') == 1
|
||||
assert 'first' not in twice
|
||||
|
||||
|
||||
def test_a_merged_map_splices_and_verifies_like_any_other_key():
|
||||
text = "job:\n name: sample_job\n\nchecks:\n muted:\n '3.3': a\n"
|
||||
after = configmodel.splice(text, 'checks.muted',
|
||||
{'3.3': 'a', '5.4': 'b'}, ['# note'])
|
||||
assert configmodel.verify(text, after, 'checks.muted',
|
||||
{'3.3': 'a', '5.4': 'b'}) == []
|
||||
assert 'sample_job' in after
|
||||
|
||||
|
||||
# ------------------------------------- one reader seeing less than the other
|
||||
|
||||
WRAPPED = """job:
|
||||
name: sample_job
|
||||
|
||||
details:
|
||||
marks_seen:
|
||||
parts: [P76, P89,
|
||||
SP29, SP30]
|
||||
|
||||
snapshot:
|
||||
detached_dir: null
|
||||
"""
|
||||
|
||||
|
||||
def test_a_reader_that_stops_early_is_reported_as_that_and_not_as_one_key():
|
||||
"""THE DEFECT. The owner set `snapshot.detached_dir` from the window and
|
||||
was told "the two YAML readers disagree about snapshot.detached_dir. This
|
||||
file has to be edited by hand." Both halves were true and neither was
|
||||
useful: nothing is wrong with that key.
|
||||
|
||||
What had happened is that the editor's reader STOPPED READING at a `[...]`
|
||||
list wrapped over two lines, and `snapshot` -- three sections further down
|
||||
-- was simply not in its tree. Every key past the cut would have produced
|
||||
the same message and sent him to the same wrong place.
|
||||
"""
|
||||
theirs = {'job': {'name': 'sample_job'},
|
||||
'details': {'marks_seen': {'parts': ['P76']}},
|
||||
'snapshot': {'detached_dir': None}}
|
||||
ours = {'job': {'name': 'sample_job'}}
|
||||
body = '\n'.join(configmodel.reader_coverage(theirs, ours, WRAPPED))
|
||||
assert 'cannot see all of this file' in body
|
||||
assert 'snapshot' in body
|
||||
assert 'NOTHING HAS BEEN WRITTEN' in body
|
||||
|
||||
|
||||
def test_the_coverage_check_names_the_wrapped_list_as_the_likely_cause():
|
||||
body = '\n'.join(configmodel.reader_coverage(
|
||||
{'snapshot': {'detached_dir': None}}, {}, WRAPPED))
|
||||
assert 'more than one line' in body
|
||||
assert 'line 6' in body # `parts: [P76, P89,`
|
||||
|
||||
|
||||
def test_a_wrapped_flow_list_is_found_and_a_closed_one_is_not():
|
||||
assert configmodel.wrapped_flow_lines(
|
||||
'a:\n parts: [P76, P89,\n SP29]\n') == [2]
|
||||
assert configmodel.wrapped_flow_lines('a:\n parts: [P76, P89]\n') == []
|
||||
# A comment is not a finding: nothing reads it.
|
||||
assert configmodel.wrapped_flow_lines('# parts: [P76,\n') == []
|
||||
|
||||
|
||||
def test_two_readers_that_see_the_same_file_report_nothing():
|
||||
same = {'snapshot': {'detached_dir': None}, 'job': {'name': 'sample_job'}}
|
||||
assert configmodel.reader_coverage(same, dict(same), WRAPPED) == []
|
||||
|
||||
|
||||
def test_the_coverage_check_says_the_cause_is_unknown_rather_than_guessing():
|
||||
"""It scans for ONE shape. A file that stops being readable for another
|
||||
reason must not be told it has a wrapped list in it."""
|
||||
body = '\n'.join(configmodel.reader_coverage(
|
||||
{'snapshot': {'detached_dir': None}}, {}, 'job:\n name: x\n'))
|
||||
assert 'has to be found by hand' in body
|
||||
assert 'more than one line' not in body
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# `checks.not_applicable` / `checks.only_applies_to` -- refused at the door
|
||||
# ============================================================================
|
||||
#
|
||||
# Belt and braces with the resolver, exactly as `checks.muted` is: a
|
||||
# knowledge file may be edited by hand and never come through this screen, so
|
||||
# the run-time side refuses the same shapes and prints the refusal on the
|
||||
# report. Both halves have to exist, because either one alone lets somebody
|
||||
# believe a safety check is off while it is on.
|
||||
|
||||
|
||||
def _entry(**over):
|
||||
got = {'applies': False,
|
||||
'not_applicable_because': 'no piece here has one',
|
||||
'not_applicable_since': '2026-08-31'}
|
||||
got.update(over)
|
||||
return got
|
||||
|
||||
|
||||
def test_a_good_declaration_is_accepted():
|
||||
assert configmodel.validate(
|
||||
'checks.not_applicable', {'3.3': {'any': _entry()}}) == []
|
||||
|
||||
|
||||
def test_a_bare_list_is_refused_because_it_cannot_merge():
|
||||
said = configmodel.validate('checks.not_applicable', ['3.3'])
|
||||
assert said and 'DELETE' in said[0]
|
||||
|
||||
|
||||
def test_a_number_that_is_not_a_checklist_item_is_refused():
|
||||
said = configmodel.validate('checks.not_applicable',
|
||||
{'9.9': {'any': _entry()}})
|
||||
assert any('not a ticket-checklist item' in s for s in said)
|
||||
|
||||
|
||||
def test_a_reasonless_or_dateless_declaration_is_refused():
|
||||
assert configmodel.validate(
|
||||
'checks.not_applicable',
|
||||
{'3.3': {'any': _entry(not_applicable_because='')}})
|
||||
assert configmodel.validate(
|
||||
'checks.not_applicable',
|
||||
{'3.3': {'any': _entry(not_applicable_since='soon')}})
|
||||
|
||||
|
||||
def test_a_condition_naming_no_declared_panel_type_is_refused():
|
||||
"""It would match nothing, ever, and matching nothing is exactly what it
|
||||
would look like it did."""
|
||||
know = {'panel_types': {'types': {'insulated': {}, 'solid': {}}}}
|
||||
said = configmodel.validate('checks.not_applicable',
|
||||
{'3.3': {'sandwich': _entry()}}, know)
|
||||
assert any('no declared panel type' in s for s in said)
|
||||
assert configmodel.validate('checks.not_applicable',
|
||||
{'3.3': {'insulated': _entry()}}, know) == []
|
||||
# `any` is always legal: it is the whole-plant axis, not a type.
|
||||
assert configmodel.validate('checks.not_applicable',
|
||||
{'3.3': {'any': _entry()}}, know) == []
|
||||
|
||||
|
||||
def test_scoping_a_row_to_any_is_refused_at_the_door_too():
|
||||
said = configmodel.validate('checks.only_applies_to', {'3.3': {'any': {
|
||||
'scoped': True, 'scoped_because': 'x', 'scoped_since': '2026-08-31'}}})
|
||||
assert any('not a scope' in s for s in said)
|
||||
|
||||
|
||||
def test_a_scope_needs_its_own_reason_and_date():
|
||||
said = configmodel.validate('checks.only_applies_to', {'3.3': {
|
||||
'insulated': {'scoped': True}}})
|
||||
assert said and 'REQUIRED' in said[0]
|
||||
|
||||
|
||||
# --------------------------------- which FILE the plant/public layer reads
|
||||
|
||||
def test_knowledge_source_keys_are_the_two_the_owner_named():
|
||||
keys = [key for key, _label in configmodel.KNOWLEDGE_SOURCE_KEYS]
|
||||
assert keys == ['knowledge_sources.plant_file',
|
||||
'knowledge_sources.public_file']
|
||||
|
||||
|
||||
def test_a_missing_file_is_refused_never_written_quietly(tmp_path):
|
||||
"""CLAUDE.md's rule for `_config_missing` / `_plant_missing`, carried to
|
||||
the per-job pointer this window writes: a chosen file that is not there
|
||||
must be a loud, named refusal."""
|
||||
gone = str(tmp_path / 'nope.yaml')
|
||||
for key in ('knowledge_sources.plant_file', 'knowledge_sources.public_file'):
|
||||
said = configmodel.validate(key, gone)
|
||||
assert said, key
|
||||
assert repr(gone) in said[0]
|
||||
assert 'does not exist' in said[0]
|
||||
|
||||
|
||||
def test_a_real_file_is_accepted(tmp_path):
|
||||
real = tmp_path / 'plant.yaml'
|
||||
real.write_text(u'lifters: {}\n', encoding='utf-8')
|
||||
for key in ('knowledge_sources.plant_file', 'knowledge_sources.public_file'):
|
||||
assert configmodel.validate(key, str(real)) == []
|
||||
|
||||
|
||||
def test_a_blank_value_is_never_refused_it_means_go_back_to_default():
|
||||
for key in ('knowledge_sources.plant_file', 'knowledge_sources.public_file'):
|
||||
assert configmodel.validate(key, None) == []
|
||||
assert configmodel.validate(key, '') == []
|
||||
|
||||
|
||||
def test_knowledge_source_problems_is_silent_when_never_set():
|
||||
"""No layer mentions the key -- every install before this feature
|
||||
existed. Not a problem."""
|
||||
assert configmodel.knowledge_source_problems(layers()) == []
|
||||
|
||||
|
||||
def test_knowledge_source_problems_is_silent_when_explicitly_cleared():
|
||||
"""`known=True` with a `None` value is a decision made in the window --
|
||||
distinct from `known=False`, and neither is a problem."""
|
||||
job = {'knowledge_sources': {'plant_file': None}}
|
||||
problems = configmodel.knowledge_source_problems(layers(job=job))
|
||||
assert problems == []
|
||||
|
||||
|
||||
def test_knowledge_source_problems_names_a_pointer_that_is_gone():
|
||||
job = {'knowledge_sources': {'plant_file': 'C:/nowhere/knowledge.yaml'}}
|
||||
problems = configmodel.knowledge_source_problems(layers(job=job))
|
||||
assert len(problems) == 1
|
||||
key, label, path = problems[0]
|
||||
assert key == 'knowledge_sources.plant_file'
|
||||
assert label == 'Plant knowledge file'
|
||||
assert path == 'C:/nowhere/knowledge.yaml'
|
||||
|
||||
|
||||
def test_knowledge_source_problems_is_silent_when_the_file_is_really_there(
|
||||
tmp_path):
|
||||
real = tmp_path / 'catalogue.yaml'
|
||||
real.write_text(u'inserts: {}\n', encoding='utf-8')
|
||||
job = {'knowledge_sources': {'public_file': str(real)}}
|
||||
assert configmodel.knowledge_source_problems(layers(job=job)) == []
|
||||
@@ -0,0 +1,447 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The full view of the configuration: label-first, and what opens itself.
|
||||
|
||||
`vibe.configpage` is the SECONDARY surface of the Project Config button. The
|
||||
button itself is a four-module window of controls (`vibe.configform`); this is
|
||||
the "Full view" behind it -- every setting, every layer, and the provenance of
|
||||
every value -- printed to the output panel and written whole to a file beside
|
||||
the model. It has no Revit in it: the button passes in the layers it read, and
|
||||
everything below is that contract exercised on host CPython.
|
||||
|
||||
Two of these tests are regressions for defects the owner hit on one press on
|
||||
2026-08-30, and they are assertions rather than comments because both read as
|
||||
cosmetic and neither was:
|
||||
|
||||
* `test_no_row_is_named_by_its_dotted_key` -- the screen offered
|
||||
`handling.report_dir` as the name of an option when the row had carried
|
||||
the label "Report folder" all along.
|
||||
* `test_*_opens_itself` -- the fold contract: if nothing below the headline
|
||||
is open, nothing below needs the reader. A section that opens has to say
|
||||
why in a sentence.
|
||||
|
||||
And one is the seam with the window: `test_every_row_says_where_it_is_changed`
|
||||
keeps the two surfaces from becoming two answers to the same question.
|
||||
"""
|
||||
from vibe import configmodel
|
||||
from vibe import configpage
|
||||
from vibe import foldreport as fold
|
||||
|
||||
|
||||
# --------------------------------------------------------------- the fixture
|
||||
|
||||
def _tree(pairs):
|
||||
out = {}
|
||||
for key, value in pairs:
|
||||
node = out
|
||||
parts = key.split('.')
|
||||
for part in parts[:-1]:
|
||||
node = node.setdefault(part, {})
|
||||
node[parts[-1]] = value
|
||||
return out
|
||||
|
||||
|
||||
def _sample(row):
|
||||
"""A type-appropriate value for one SETTINGS row."""
|
||||
kind = row['kind']
|
||||
if kind == 'choice':
|
||||
return (row['choices'] or ('fast',))[0]
|
||||
if kind == 'bool':
|
||||
return True
|
||||
if kind in ('float', 'int'):
|
||||
return 2 if kind == 'int' else 2.5
|
||||
if kind == 'list':
|
||||
return ['ANCHOR_FACE_4T']
|
||||
if kind == 'map':
|
||||
return {'3.3': 'no piece on this job has one'}
|
||||
if kind == 'stages':
|
||||
return [{'name': 'stripping', 'pci_case': 'stripping',
|
||||
'strength': 'fci'}]
|
||||
return 'something'
|
||||
|
||||
|
||||
def _full():
|
||||
return _tree([(row['key'], _sample(row)) for row in configmodel.SETTINGS])
|
||||
|
||||
|
||||
def layers(defaults=None, plant=None, job=None, config=None,
|
||||
plant_exists=True):
|
||||
"""[(label, path, exists, data)] the way `configdump.layer_values` gives
|
||||
it, with the project config layer added only when there is one."""
|
||||
out = [('defaults', None, True, defaults if defaults is not None
|
||||
else _full()),
|
||||
('catalogue', 'C:/tool/catalogue.yaml', True, {}),
|
||||
('plant', 'C:/plant/knowledge.yaml', plant_exists, plant or {}),
|
||||
('job', 'C:/job/knowledge.yaml', True, job or {})]
|
||||
if config is not None:
|
||||
out.append(('config', 'C:/job/sample_job-config.yaml', True, config))
|
||||
return out
|
||||
|
||||
|
||||
def rendered(**over):
|
||||
kwargs = {'title': 'sample_job', 'layers': layers(),
|
||||
'job_path': 'C:/job/knowledge.yaml',
|
||||
'env': [('WILLTOOLS_KNOWLEDGE', '(not set)')],
|
||||
'run_options': {}, 'run_options_path': 'C:/job/run-options.yaml',
|
||||
'locations': {'report_dir': 'C:/job/reports'},
|
||||
'report_dir': 'C:/job/reports',
|
||||
'subtrees': [('parameters', 'the office template',
|
||||
[('member.control_number', 'Control Number',
|
||||
'plant')])],
|
||||
'muted': {}}
|
||||
kwargs.update(over)
|
||||
return configpage.render(**kwargs)
|
||||
|
||||
|
||||
def by_key(sections):
|
||||
return dict((section.key, section) for section in sections)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- the rows
|
||||
|
||||
def test_rows_keep_a_stable_position():
|
||||
rows = configpage.editable_rows(layers())
|
||||
assert [row['n'] for row in rows] == list(range(1, len(rows) + 1))
|
||||
|
||||
|
||||
def test_every_editable_setting_is_on_the_page_exactly_once():
|
||||
rows = configpage.editable_rows(layers())
|
||||
keys = [row['key'] for row in rows]
|
||||
assert keys == [row['key'] for row in configmodel.editable()]
|
||||
assert len(set(keys)) == len(keys)
|
||||
|
||||
|
||||
def test_no_row_is_named_by_its_dotted_key():
|
||||
"""The defect: the button offered `handling.report_dir` as an option name.
|
||||
|
||||
Every SETTINGS row carries a human label and this is what makes sure the
|
||||
page uses it. The key is still on the row -- it is what somebody grepping
|
||||
the YAML needs -- but it is never the name.
|
||||
"""
|
||||
for row in configpage.editable_rows(layers()):
|
||||
assert row['label'], row['key']
|
||||
assert row['label'] != row['key']
|
||||
assert '.' not in row['label'] or ' ' in row['label']
|
||||
|
||||
|
||||
def test_rows_group_under_the_human_section_names():
|
||||
rows = configpage.editable_rows(layers())
|
||||
groups = configpage.group_rows(rows)
|
||||
names = [name for name, _rows in groups]
|
||||
assert names == [name for name in configmodel.sections()
|
||||
if any(row['section'] == name for row in rows)]
|
||||
assert 'Output' in names
|
||||
# A section name is prose, not a path fragment.
|
||||
for name in names:
|
||||
assert '.' not in name
|
||||
|
||||
|
||||
def test_grouping_preserves_the_settings_order():
|
||||
rows = configpage.editable_rows(layers())
|
||||
seen = []
|
||||
for _name, group in configpage.group_rows(rows):
|
||||
seen.extend([row['n'] for row in group])
|
||||
assert seen == sorted(seen)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- value_text()
|
||||
|
||||
def test_an_unknown_value_is_a_sentence_and_never_a_blank():
|
||||
res = configmodel.resolve(layers(defaults={}), 'handling.rig_mode')
|
||||
assert not res['known']
|
||||
assert configpage.value_text('handling.rig_mode', res) == \
|
||||
configpage.NOT_SET
|
||||
assert configpage.value_text('handling.rig_mode', res).strip()
|
||||
|
||||
|
||||
def test_fast_precise_is_shown_the_way_the_config_file_writes_it():
|
||||
tree = _full()
|
||||
tree['handling']['fast_lifter_box'] = False
|
||||
res = configmodel.resolve(layers(defaults=tree),
|
||||
'handling.fast_lifter_box')
|
||||
assert configpage.value_text('handling.fast_lifter_box', res) == 'precise'
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the headline
|
||||
|
||||
def test_the_headline_is_first_and_is_never_folded():
|
||||
sections = rendered()
|
||||
assert sections[0].headline is True
|
||||
assert sections[0].is_open is True
|
||||
assert all(not section.headline for section in sections[1:])
|
||||
|
||||
|
||||
def test_the_headline_is_label_first_with_the_key_as_small_print():
|
||||
sections = rendered()
|
||||
tables = [block for block in sections[0].blocks if block[0] == fold.TABLE]
|
||||
assert tables
|
||||
columns = tables[0][1]
|
||||
assert columns[0] == 'setting'
|
||||
assert columns[-1] == 'key'
|
||||
first = tables[0][2][0]
|
||||
assert first[0] != first[-1] # the label is not the key
|
||||
assert first[-1].startswith('`') # the key is reference small print
|
||||
|
||||
|
||||
def test_every_row_says_where_it_is_changed():
|
||||
"""The column that makes this page the other half of the four-module
|
||||
window instead of a second, competing surface."""
|
||||
sections = rendered(placement={'handling.rig_mode': 'Lifting and handling'})
|
||||
table = [b for b in sections[0].blocks if b[0] == fold.TABLE][0]
|
||||
assert table[1][-2] == 'change it in'
|
||||
wheres = set(row[-2] for row in table[2])
|
||||
assert 'Lifting and handling' in wheres or 'the file' in wheres
|
||||
for row in table[2]:
|
||||
assert row[-2], 'a blank here reads as "nowhere"'
|
||||
|
||||
|
||||
def test_a_setting_no_box_carries_says_it_is_changed_in_the_file():
|
||||
sections = rendered(placement={})
|
||||
table = [b for b in sections[0].blocks if b[0] == fold.TABLE][0]
|
||||
assert set(row[-2] for row in table[2]) == set(['the file'])
|
||||
|
||||
|
||||
def test_without_a_placement_the_column_is_simply_empty():
|
||||
"""The page still renders for a caller that has no window to point at."""
|
||||
sections = rendered()
|
||||
table = [b for b in sections[0].blocks if b[0] == fold.TABLE][0]
|
||||
assert set(row[-2] for row in table[2]) == set([''])
|
||||
|
||||
|
||||
def test_the_headline_says_where_the_edit_goes():
|
||||
text = '\n'.join(block[1] for block in rendered()[0].blocks
|
||||
if block[0] == fold.MD)
|
||||
assert 'C:/job/knowledge.yaml' in text
|
||||
assert 'job layer' in text
|
||||
|
||||
|
||||
def test_a_build_note_reaches_the_top_of_the_page():
|
||||
text = '\n'.join(block[1] for block in
|
||||
rendered(notes=['restart for the full view'])[0].blocks
|
||||
if block[0] == fold.MD)
|
||||
assert 'restart for the full view' in text
|
||||
|
||||
|
||||
# -------------------------------------------------- what opens itself, and why
|
||||
|
||||
def test_nothing_opens_when_nothing_is_wrong():
|
||||
for section in rendered()[1:]:
|
||||
assert section.problem is None, section.key
|
||||
assert section.is_open is False
|
||||
|
||||
|
||||
def test_a_setting_no_layer_sets_opens_its_own_section():
|
||||
tree = _full()
|
||||
del tree['handling']['sling_angle_deg']
|
||||
sections = by_key(rendered(layers=layers(defaults=tree)))
|
||||
assert 'unknown' in sections
|
||||
assert sections['unknown'].is_open
|
||||
assert 'nothing in any layer decides' in sections['unknown'].problem
|
||||
|
||||
|
||||
def test_a_missing_layer_file_opens_the_layers_section():
|
||||
sections = by_key(rendered(layers=layers(plant_exists=False)))
|
||||
assert sections['layers'].is_open
|
||||
assert 'plant' in sections['layers'].problem
|
||||
|
||||
|
||||
def test_a_live_config_file_opens_its_own_section_and_names_what_it_decides():
|
||||
given = layers(config={'handling': {'rig_mode': 'rolling'}})
|
||||
sections = by_key(rendered(layers=given, rows=None,
|
||||
config_path='C:/job/sample_job-config.yaml'))
|
||||
assert sections['config'].is_open
|
||||
assert 'NOT' in sections['config'].problem
|
||||
body = '\n'.join(block[1] for block in sections['config'].blocks
|
||||
if block[0] == fold.MD)
|
||||
assert 'sample_job-config.yaml' in body
|
||||
|
||||
|
||||
def test_a_missing_config_file_says_so_rather_than_falling_back_quietly():
|
||||
sections = by_key(rendered(config_path='C:/gone/config.yaml',
|
||||
config_missing=True))
|
||||
assert 'MISSING' in sections['config'].problem
|
||||
|
||||
|
||||
def test_the_config_chip_marks_the_rows_the_file_decides():
|
||||
given = layers(config={'handling': {'rig_mode': 'rolling'}})
|
||||
rows = configpage.editable_rows(given)
|
||||
marked = configpage.config_rows(rows)
|
||||
assert [row['key'] for row in marked] == ['handling.rig_mode']
|
||||
assert marked[0]['chip'] == '!'
|
||||
|
||||
|
||||
def test_muted_items_open_their_section_and_are_listed_with_the_reason():
|
||||
sections = by_key(rendered(muted={'3.3': 'no piece on this job has one'}))
|
||||
assert sections['muted'].is_open
|
||||
assert '1 checklist item' in sections['muted'].problem
|
||||
rows = [block for block in sections['muted'].blocks
|
||||
if block[0] == fold.TABLE][0][2]
|
||||
assert rows[0][0] == '3.3'
|
||||
assert 'no piece' in rows[0][1]
|
||||
|
||||
|
||||
def test_an_unmapped_parameter_tree_opens_and_says_the_checks_go_quiet():
|
||||
sections = by_key(rendered(subtrees=[('parameters', 'the template', [])]))
|
||||
assert sections['params'].is_open
|
||||
assert 'do not run' in sections['params'].problem
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the read-only
|
||||
|
||||
def test_every_read_only_setting_carries_the_sentence_saying_why():
|
||||
section = by_key(rendered())['readonly']
|
||||
body = '\n'.join(block[1] for block in section.blocks
|
||||
if block[0] == fold.MD)
|
||||
for row in configmodel.read_only():
|
||||
assert row['label'] in body
|
||||
assert (row['why'] or '')[:40] in body
|
||||
|
||||
|
||||
def test_the_read_only_table_is_label_first_with_the_key_alongside():
|
||||
section = by_key(rendered())['readonly']
|
||||
table = [block for block in section.blocks if block[0] == fold.TABLE][0]
|
||||
assert table[1][0] == 'setting'
|
||||
assert table[1][-1] == 'key'
|
||||
for row in table[2]:
|
||||
assert row[0] != row[-1]
|
||||
|
||||
|
||||
# ------------------------------------------------------- one setting up close
|
||||
|
||||
# --------------------------------------------------------- the render itself
|
||||
|
||||
def test_the_whole_page_renders_as_text_with_nothing_folded():
|
||||
body = fold.text(rendered(), show_all=True)
|
||||
assert 'Configuration of sample_job' in body
|
||||
for section in rendered()[1:]:
|
||||
assert section.title.replace('—', '--') in body
|
||||
|
||||
|
||||
def test_the_folded_half_renders_as_one_line_of_html():
|
||||
"""`print_html` is a `print(..., end='')` into the panel's stream and every
|
||||
newline in it starts a new output entry, which would cut a div in half."""
|
||||
body = fold.html(rendered()[1:])
|
||||
assert body
|
||||
assert '\n' not in body
|
||||
|
||||
|
||||
def test_a_pipe_in_a_typed_reason_cannot_split_a_table_row():
|
||||
sections = by_key(rendered(muted={'3.3': 'a | b'}))
|
||||
rows = [block for block in sections['muted'].blocks
|
||||
if block[0] == fold.TABLE][0][2]
|
||||
assert '|' not in rows[0][1]
|
||||
assert '|' in rows[0][1]
|
||||
|
||||
|
||||
def test_the_page_names_the_file_it_was_also_written_to():
|
||||
body = fold.text(rendered(full_path='C:/job/config/last-page.txt'))
|
||||
assert 'last-page.txt' in body
|
||||
|
||||
|
||||
def test_every_section_has_a_summary_so_folded_is_not_gone():
|
||||
for section in rendered()[1:]:
|
||||
assert section.summary or section.problem, section.key
|
||||
|
||||
|
||||
# ------------------------------------------- the muted section, and its seam
|
||||
|
||||
def _mute_rows(*rows):
|
||||
"""`configform.mute_table` output, spelled out rather than imported.
|
||||
|
||||
The page takes these as DATA and never builds them: they need the ticket
|
||||
checklist table, which `configpage` has no business loading. Writing the
|
||||
shape here is what keeps that contract visible.
|
||||
"""
|
||||
out = []
|
||||
for number, name, reason, layer, editable in rows:
|
||||
out.append({'number': number, 'name': name, 'grade': 'A',
|
||||
'known': not name.startswith('('), 'muted': True,
|
||||
'reason': reason, 'layer': layer, 'editable': editable,
|
||||
'why_not': None if editable else 'set by ' + layer,
|
||||
'falls_back': None})
|
||||
return out
|
||||
|
||||
|
||||
def test_the_muted_section_says_where_the_control_is():
|
||||
"""The defect this is for: he went looking for the mute control twice and
|
||||
did not find it, and this page -- the one surface that lists what is
|
||||
muted -- said nothing at all about where muting is done."""
|
||||
section = by_key(rendered(muted={'3.3': 'no piece has one'}))['muted']
|
||||
body = '\n'.join(block[1] for block in section.blocks
|
||||
if block[0] == fold.MD)
|
||||
assert 'Ticket QC' in body
|
||||
assert 'speaker' in body
|
||||
|
||||
|
||||
def test_the_muted_section_says_the_check_still_runs():
|
||||
"""Muting is a volume control, not a switch. A page that showed a mute
|
||||
without saying that would leave the reader believing a check was off."""
|
||||
section = by_key(rendered(muted={'3.3': 'no piece has one'}))['muted']
|
||||
body = '\n'.join(block[1] for block in section.blocks
|
||||
if block[0] == fold.MD)
|
||||
assert 'does not stop a check' in body
|
||||
assert 'never quietened' in body.lower()
|
||||
assert 'still RUNS' in section.problem or 'still' in section.problem
|
||||
|
||||
|
||||
def test_a_mute_this_window_cannot_remove_is_named_with_its_layer():
|
||||
rows = _mute_rows(('3.3', 'release ratio', 'the plant never has one',
|
||||
'plant', False))
|
||||
section = by_key(rendered(muted={'3.3': 'the plant never has one'},
|
||||
muted_rows=rows))['muted']
|
||||
table = [block for block in section.blocks if block[0] == fold.TABLE][0]
|
||||
assert table[1] == ['item', 'checklist row',
|
||||
'why this job never has one', 'set by']
|
||||
assert table[2][0][3] == 'plant'
|
||||
body = '\n'.join(block[1] for block in section.blocks
|
||||
if block[0] == fold.MD)
|
||||
assert 'not the job layer' in body
|
||||
assert 'MERGE' in body
|
||||
|
||||
|
||||
def test_a_muted_number_that_is_no_checklist_item_opens_the_section():
|
||||
"""It mutes nothing, and nothing is what it looks like it did."""
|
||||
rows = _mute_rows(('9.9', '(not a checklist item -- this mutes nothing)',
|
||||
'typed by hand', 'job', True))
|
||||
section = by_key(rendered(muted={'9.9': 'typed by hand'},
|
||||
muted_rows=rows))['muted']
|
||||
assert section.is_open
|
||||
assert 'mute NOTHING' in section.problem
|
||||
|
||||
|
||||
def test_the_muted_section_still_renders_from_the_flat_map_alone():
|
||||
"""Every older caller hands over `muted` and no rows. The section must
|
||||
fall back rather than lose the list."""
|
||||
section = by_key(rendered(muted={'3.3': 'no piece has one'}))['muted']
|
||||
table = [block for block in section.blocks if block[0] == fold.TABLE][0]
|
||||
assert table[1] == ['item', 'why this job never has one']
|
||||
assert table[2][0][0] == '3.3'
|
||||
|
||||
|
||||
# ------------------------------------------------ the chip on a merged map
|
||||
|
||||
def test_a_key_the_layers_merge_is_never_chipped_to_one_layer():
|
||||
"""`resolve` names the last layer that set an entry as though it owned
|
||||
all of them. `J` on a map the plant also writes is the same lie as a
|
||||
silent override, one level down."""
|
||||
given = layers(plant={'checks': {'muted': {'3.3': 'plant'}}},
|
||||
job={'checks': {'muted': {'5.4': 'job'}}})
|
||||
row = [r for r in configpage.editable_rows(given)
|
||||
if r['key'] == 'checks.muted'][0]
|
||||
assert row['chip'] == '+'
|
||||
assert row['res']['layer'] == 'plant + job'
|
||||
assert '3.3 (plant)' in row['value']
|
||||
assert '5.4 (job)' in row['value']
|
||||
|
||||
|
||||
def test_the_legend_explains_the_merged_chip():
|
||||
"""A chip nobody can look up is a chip that reads as a bug."""
|
||||
assert '`+`' in configpage.LEGEND
|
||||
assert 'MERGE' in configpage.LEGEND
|
||||
|
||||
|
||||
def test_a_merged_map_no_layer_sets_still_says_not_set():
|
||||
given = layers(defaults={}, plant={}, job={})
|
||||
row = [r for r in configpage.editable_rows(given)
|
||||
if r['key'] == 'checks.muted'][0]
|
||||
assert row['chip'] == '?'
|
||||
assert row['value'] == configpage.NOT_SET
|
||||
@@ -0,0 +1,509 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for `tools/deckcheck.py`.
|
||||
|
||||
Loaded by path, like `test_knowledge_drift.py` and `test_snapshot_defer.py`:
|
||||
this is host CPython code sitting in a repository whose extension is
|
||||
IronPython 2.7, and `conftest.py` in this folder registers the bare `vibe`
|
||||
stub every one of these imports relies on.
|
||||
|
||||
The regression this exists to catch, spelled out as a test rather than left
|
||||
as a paragraph: incident 1 in `tools/deckcheck.py`'s own docstring --
|
||||
`test_regression_catalogue_value_changed_after_export_is_named_stale` builds
|
||||
a deck frozen against an OLD catalogue rating, changes the catalogue the way
|
||||
the 2026-09-01 P76 correction did, and asserts the STALE finding names the
|
||||
mark and the field rather than passing silently.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))))
|
||||
TOOLS = os.path.join(ROOT, 'tools')
|
||||
if TOOLS not in sys.path:
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
from vibe import knowledge # noqa: E402 (registers the vibe stub)
|
||||
from vibe import plantpath # noqa: E402
|
||||
|
||||
deckcheck = pytest.importorskip('deckcheck')
|
||||
D = deckcheck
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
def _write_yaml(path, data):
|
||||
if not os.path.isdir(os.path.dirname(path)):
|
||||
os.makedirs(os.path.dirname(path))
|
||||
with io.open(path, 'w', encoding='utf-8') as fh:
|
||||
yaml.safe_dump(data, fh)
|
||||
|
||||
|
||||
def _write_json(path, data):
|
||||
if not os.path.isdir(os.path.dirname(path)):
|
||||
os.makedirs(os.path.dirname(path))
|
||||
with io.open(path, 'w', encoding='utf-8') as fh:
|
||||
json.dump(data, fh)
|
||||
|
||||
|
||||
def _lifter(mark='M1', family='TESTLIFT', **extra):
|
||||
row = {'mark': mark, 'family': family, 'x': 0.0, 'y': 0.0,
|
||||
'pinned': False, 'pinned_y': False, 'on_edge': False,
|
||||
'capacity_lb': None, 'shear_capacity_lb': None,
|
||||
'basis_fc_psi': None, 'min_edge_distance_in': None,
|
||||
'tension_fos': None, 'shear_fos': None}
|
||||
row.update(extra)
|
||||
return row
|
||||
|
||||
|
||||
def _deck(mark='WP1', lifters=None, stages=None):
|
||||
return {
|
||||
'schema': 'panellift/1',
|
||||
'panel': {'mark': mark, 'lifters': lifters or []},
|
||||
'stages': stages or [],
|
||||
'source': {},
|
||||
}
|
||||
|
||||
|
||||
class Repo(object):
|
||||
"""A fixture knowledge tree: catalogue + plant + job layers on disk,
|
||||
plus the job_root deckcheck should be told (or made to guess) about.
|
||||
|
||||
`plant_env` points WILLTOOLS_KNOWLEDGE at `<tmp>/plantroot/knowledge.yaml`
|
||||
(a FILE, exactly as the real machine this was built on has it set --
|
||||
`plantpath.plant_root()` reads that as "its directory is the root"), so
|
||||
`plantpath.plant_knowledge_path('testplant')` resolves to
|
||||
`<tmp>/plantroot/testplant/knowledge.yaml` with no other wiring needed.
|
||||
"""
|
||||
|
||||
def __init__(self, tmp_path, monkeypatch, plant='testplant'):
|
||||
self.tmp = tmp_path
|
||||
self.job_root = str(tmp_path / 'job')
|
||||
self.jobs_dir = os.path.join(self.job_root, 'sweep', 'jobs')
|
||||
os.makedirs(self.jobs_dir)
|
||||
self.catalogue_path = str(tmp_path / 'catalogue.yaml')
|
||||
self.plant_path = str(tmp_path / 'plantroot' / plant / 'knowledge.yaml')
|
||||
self.job_path = os.path.join(self.job_root, 'knowledge.yaml')
|
||||
self.plant = plant
|
||||
|
||||
monkeypatch.setenv('WILLTOOLS_KNOWLEDGE',
|
||||
str(tmp_path / 'plantroot' / 'knowledge.yaml'))
|
||||
monkeypatch.setattr(D.knowledge, 'catalogue_path',
|
||||
lambda: self.catalogue_path)
|
||||
|
||||
_write_yaml(self.catalogue_path, {'catalogue': {'inserts': {}}})
|
||||
_write_yaml(self.job_path, {'job': {'plant': plant}})
|
||||
_write_yaml(self.plant_path, {})
|
||||
|
||||
def set_catalogue(self, inserts):
|
||||
# catalogue.yaml wraps its data in a top-level `catalogue:` key --
|
||||
# `knowledge.get(know, 'catalogue.inserts', ...)` is what every
|
||||
# lookup reads, so the fixture file has to nest the same way the
|
||||
# real one does (see knowledge/catalogue.yaml itself, `catalogue:`
|
||||
# at top level, `inserts:` inside it).
|
||||
_write_yaml(self.catalogue_path, {'catalogue': {'inserts': inserts}})
|
||||
|
||||
def set_plant(self, data):
|
||||
_write_yaml(self.plant_path, data)
|
||||
|
||||
def set_job(self, data):
|
||||
data = dict(data)
|
||||
data['job'] = {'plant': self.plant}
|
||||
_write_yaml(self.job_path, data)
|
||||
|
||||
def deck_path(self, name):
|
||||
return os.path.join(self.jobs_dir, name)
|
||||
|
||||
def build(self):
|
||||
return D.build_knowledge(self.job_root)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path, monkeypatch):
|
||||
return Repo(tmp_path, monkeypatch)
|
||||
|
||||
|
||||
# --------------------------------------------------------- build_knowledge
|
||||
|
||||
def test_layers_merge_defaults_catalogue_plant_job_in_that_order(repo):
|
||||
repo.set_catalogue({'inserts': {}}) # catalogue: no override
|
||||
repo.set_plant({'handling': {'tension_limit_sqrt_fc': 6.0}})
|
||||
repo.set_job({'handling': {'tension_limit_sqrt_fc': 9.0}}) # job wins
|
||||
know, layers = repo.build()
|
||||
assert knowledge.get(know, 'handling.tension_limit_sqrt_fc') == 9.0
|
||||
assert layers.plant_id == 'testplant'
|
||||
assert layers.plant_status == 'merged'
|
||||
assert layers.job_status == 'merged'
|
||||
assert layers.catalogue_status == 'merged'
|
||||
|
||||
|
||||
def test_plant_layer_alone_is_read_when_job_states_none(repo):
|
||||
repo.set_plant({'handling': {'tension_limit_sqrt_fc': 6.0}})
|
||||
_write_yaml(repo.job_path, {}) # no job.plant at all
|
||||
know, layers = repo.build()
|
||||
assert layers.plant_id is None
|
||||
# Falls back to the MACHINE-WIDE file (<plant_root>/knowledge.yaml), not
|
||||
# the per-plant one this fixture wrote -- which was never created, so
|
||||
# this is the ordinary "no plant file yet" case and stays silent.
|
||||
assert layers.plant_status == 'absent'
|
||||
assert knowledge.get(know, 'handling.tension_limit_sqrt_fc') == 5.0 # DEFAULTS
|
||||
|
||||
|
||||
def test_named_plant_with_no_folder_is_missing_not_silently_skipped(repo):
|
||||
_write_yaml(repo.job_path, {'job': {'plant': 'nobody_configured_this'}})
|
||||
know, layers = repo.build()
|
||||
assert layers.plant_status == 'missing'
|
||||
assert layers.plant_id == 'nobody_configured_this'
|
||||
|
||||
|
||||
def test_unreadable_yaml_layer_is_recorded_and_skipped_whole(repo, tmp_path):
|
||||
bad = tmp_path / 'bad.yaml'
|
||||
bad.write_text('a: [1, 2\n') # malformed
|
||||
status = D._merge_layer({}, str(bad))
|
||||
assert status == 'unreadable'
|
||||
|
||||
|
||||
def test_absent_layer_is_silent(repo, tmp_path):
|
||||
status = D._merge_layer({}, str(tmp_path / 'does-not-exist.yaml'))
|
||||
assert status == 'absent'
|
||||
|
||||
|
||||
# ------------------------------------------------------------- _guess_job_root
|
||||
|
||||
def test_guess_job_root_finds_the_folder_holding_knowledge_yaml(repo):
|
||||
guessed = D._guess_job_root(repo.jobs_dir)
|
||||
assert os.path.normcase(guessed) == os.path.normcase(repo.job_root)
|
||||
|
||||
|
||||
# -------------------------------------------------------------- _num_eq / _verdict
|
||||
|
||||
def test_num_eq_treats_both_none_as_equal():
|
||||
assert D._num_eq(None, None) is True
|
||||
|
||||
|
||||
def test_num_eq_one_none_is_not_equal():
|
||||
assert D._num_eq(None, 5) is False
|
||||
assert D._num_eq(5, None) is False
|
||||
|
||||
|
||||
def test_verdict_both_none_is_current():
|
||||
assert D._verdict(None, None) == D.CURRENT
|
||||
|
||||
|
||||
def test_verdict_current_none_frozen_value_is_unjudgeable():
|
||||
assert D._verdict(None, 7690.0) == D.UNJUDGEABLE
|
||||
|
||||
|
||||
def test_verdict_frozen_none_current_value_is_stale():
|
||||
assert D._verdict(16000.0, None) == D.STALE
|
||||
|
||||
|
||||
def test_verdict_matching_numbers_is_current():
|
||||
assert D._verdict(16000.0, 16000.0) == D.CURRENT
|
||||
|
||||
|
||||
def test_verdict_mismatched_numbers_is_stale():
|
||||
assert D._verdict(16000.0, 7690.0) == D.STALE
|
||||
|
||||
|
||||
# ---------------------------------------------------- check_capacity: current
|
||||
|
||||
def test_capacity_current_for_a_plain_ungated_family(repo):
|
||||
repo.set_catalogue({'PLAIN01': {'working_load_lb': 8000,
|
||||
'shear_load_lb': 8000}})
|
||||
repo.set_plant({'lifters': {'catalogue_alias': {'PLAINLIFT': 'PLAIN01'}}})
|
||||
know, _ = repo.build()
|
||||
status, detail = D.check_capacity(know, 'PLAINLIFT', 'tension', 8000.0, [])
|
||||
assert status == D.CURRENT
|
||||
assert detail['current'] == 8000.0
|
||||
|
||||
|
||||
# ----------------------------------------------- check_capacity: the regression
|
||||
|
||||
def test_regression_catalogue_value_changed_after_export_is_named_stale(repo):
|
||||
"""Incident 1, reproduced: a deck was frozen against a published rating
|
||||
that the catalogue later corrected. The mismatch must be named --
|
||||
mark, field, frozen and current -- not merged away or missed.
|
||||
"""
|
||||
repo.set_catalogue({'P76ALIAS': {'working_load_lb': 7690}})
|
||||
repo.set_plant({'lifters': {'catalogue_alias': {'LIFTFAM_P76': 'P76ALIAS'}}})
|
||||
know_old, _ = repo.build()
|
||||
status_old, detail_old = D.check_capacity(know_old, 'LIFTFAM_P76',
|
||||
'tension', 7690.0, [])
|
||||
assert status_old == D.CURRENT # the deck agreed with knowledge AT EXPORT
|
||||
|
||||
# The correction: 7690 -> 16000, same shape as the real P76 fix.
|
||||
repo.set_catalogue({'P76ALIAS': {'working_load_lb': 16000}})
|
||||
know_new, _ = repo.build()
|
||||
status_new, detail_new = D.check_capacity(know_new, 'LIFTFAM_P76',
|
||||
'tension', 7690.0, [])
|
||||
assert status_new == D.STALE
|
||||
assert detail_new['frozen'] == 7690.0
|
||||
assert detail_new['current'] == 16000.0
|
||||
|
||||
# And end-to-end through check_deck(), which is what names the mark.
|
||||
deck_path = repo.deck_path('WP900.job.json')
|
||||
_write_json(deck_path, _deck('WP900', lifters=[
|
||||
_lifter('L1', 'LIFTFAM_P76', capacity_lb=7690.0)]))
|
||||
findings = D.check_deck(deck_path, know_new)
|
||||
hit = [f for f in findings if f['field'] == 'capacity_lb']
|
||||
assert len(hit) == 1
|
||||
assert hit[0]['status'] == D.STALE
|
||||
assert hit[0]['mark'] == 'WP900'
|
||||
assert hit[0]['detail']['frozen'] == 7690.0
|
||||
assert hit[0]['detail']['current'] == 16000.0
|
||||
|
||||
|
||||
# --------------------------------------------------------- check_capacity: gated
|
||||
|
||||
def test_capacity_conditional_when_tension_bar_gate_unresolvable_offline(repo):
|
||||
repo.set_catalogue({'GATED01': {
|
||||
'working_load_lb': 16000, 'shear_load_lb': 4010,
|
||||
'requires_tension_bar_for_rating': True,
|
||||
'tension_without_tension_bar_lb': 7690,
|
||||
}})
|
||||
repo.set_plant({'lifters': {
|
||||
'catalogue_alias': {'GATEDFAM_P76': 'GATED01'},
|
||||
'tension_bar_families': ['SR5602'],
|
||||
'parts_shipped_with_tension_bar': ['P76'],
|
||||
}})
|
||||
know, _ = repo.build()
|
||||
bar_families = knowledge.get(know, 'lifters.tension_bar_families', [])
|
||||
|
||||
# frozen matches the WITHOUT-bar figure -- the case that must NOT read
|
||||
# as a quiet 'current' match, because the true answer depends on
|
||||
# assembly context this call was never given.
|
||||
status, detail = D.check_capacity(know, 'GATEDFAM_P76', 'tension',
|
||||
7690.0, bar_families)
|
||||
assert status == D.CONDITIONAL
|
||||
assert detail['without_bar'] == 7690.0
|
||||
assert detail['with_bar'] == 16000.0
|
||||
assert detail['frozen_matches'] == ['without_bar']
|
||||
|
||||
# frozen matches the WITH-bar figure -- still conditional, never
|
||||
# silently 'current' either, and frozen_matches names the other side.
|
||||
status2, detail2 = D.check_capacity(know, 'GATEDFAM_P76', 'tension',
|
||||
16000.0, bar_families)
|
||||
assert status2 == D.CONDITIONAL
|
||||
assert detail2['frozen_matches'] == ['with_bar']
|
||||
|
||||
|
||||
def test_capacity_shear_is_never_gated(repo):
|
||||
# The gate in `knowledge.lifter_capacity` only applies to kind='tension'
|
||||
# -- shear must resolve deterministically even for a gated family.
|
||||
repo.set_catalogue({'GATED01': {
|
||||
'working_load_lb': 16000, 'shear_load_lb': 4010,
|
||||
'requires_tension_bar_for_rating': True,
|
||||
'tension_without_tension_bar_lb': 7690,
|
||||
}})
|
||||
repo.set_plant({'lifters': {
|
||||
'catalogue_alias': {'GATEDFAM_P76': 'GATED01'},
|
||||
'tension_bar_families': ['SR5602'],
|
||||
'parts_shipped_with_tension_bar': ['P76'],
|
||||
}})
|
||||
know, _ = repo.build()
|
||||
status, detail = D.check_capacity(know, 'GATEDFAM_P76', 'shear', 4010.0, [])
|
||||
assert status == D.CURRENT
|
||||
assert detail['current'] == 4010.0
|
||||
|
||||
|
||||
def test_capacity_unjudgeable_when_no_catalogue_alias(repo):
|
||||
know, _ = repo.build()
|
||||
status, detail = D.check_capacity(know, 'NOBODY_KNOWS_THIS', 'tension',
|
||||
7690.0, [])
|
||||
assert status == D.UNJUDGEABLE
|
||||
assert 'reason' not in detail or detail.get('current') is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------- stage checks
|
||||
|
||||
def test_stage_tension_limit_current_when_job_wide_matches(repo):
|
||||
repo.set_plant({'handling': {'tension_limit_sqrt_fc': 7.5}})
|
||||
know, _ = repo.build()
|
||||
stage = {'name': 'stripping', 'pci_case': 'stripping',
|
||||
'tension_limit_sqrt_fc': 7.5, 'tension_limit_source': 'job'}
|
||||
status, detail = D.check_stage_tension_limit(
|
||||
know, stage, knowledge.get(know, 'handling.tension_limit_sqrt_fc', 5.0))
|
||||
assert status == D.CURRENT
|
||||
|
||||
|
||||
def test_stage_tension_limit_stale_when_job_wide_moved(repo):
|
||||
repo.set_plant({'handling': {'tension_limit_sqrt_fc': 5.0}})
|
||||
know, _ = repo.build()
|
||||
stage = {'name': 'stripping', 'pci_case': 'stripping',
|
||||
'tension_limit_sqrt_fc': 7.5, 'tension_limit_source': 'job'}
|
||||
status, detail = D.check_stage_tension_limit(
|
||||
know, stage, knowledge.get(know, 'handling.tension_limit_sqrt_fc', 5.0))
|
||||
assert status == D.STALE
|
||||
assert detail['frozen'] == 7.5
|
||||
assert detail['current'] == 5.0
|
||||
|
||||
|
||||
def test_stage_tension_limit_unjudgeable_when_no_current_spec_matches(repo):
|
||||
repo.set_plant({'handling': {'stages': []}}) # the stage was removed
|
||||
know, _ = repo.build()
|
||||
stage = {'name': 'transportation', 'pci_case': 'transportation',
|
||||
'tension_limit_sqrt_fc': 9.0, 'tension_limit_source': 'stage'}
|
||||
status, detail = D.check_stage_tension_limit(know, stage, 5.0)
|
||||
assert status == D.UNJUDGEABLE
|
||||
|
||||
|
||||
# --------------------------------------------------------------- check_deck
|
||||
|
||||
def test_check_deck_skips_fields_the_deck_never_recorded(repo):
|
||||
repo.set_catalogue({'PLAIN01': {'working_load_lb': 8000}})
|
||||
repo.set_plant({'lifters': {'catalogue_alias': {'PLAINLIFT': 'PLAIN01'}}})
|
||||
know, _ = repo.build()
|
||||
# A minimal, panellift/1-shaped lifter with only capacity_lb recorded --
|
||||
# no basis_fc_psi, no fos keys. Those fields must not appear as findings
|
||||
# at all: absent is not the same as a mismatch.
|
||||
deck_path = repo.deck_path('WP1.job.json')
|
||||
_write_json(deck_path, _deck('WP1', lifters=[
|
||||
{'mark': 'L1', 'family': 'PLAINLIFT', 'capacity_lb': 8000.0}]))
|
||||
findings = D.check_deck(deck_path, know)
|
||||
fields = set(f['field'] for f in findings)
|
||||
assert fields == {'capacity_lb'}
|
||||
|
||||
|
||||
def test_check_deck_lifter_with_no_family_is_unjudgeable(repo):
|
||||
know, _ = repo.build()
|
||||
deck_path = repo.deck_path('WP2.job.json')
|
||||
_write_json(deck_path, _deck('WP2', lifters=[
|
||||
{'mark': 'L1', 'family': None, 'capacity_lb': 100.0}]))
|
||||
findings = D.check_deck(deck_path, know)
|
||||
assert len(findings) == 1
|
||||
assert findings[0]['status'] == D.UNJUDGEABLE
|
||||
assert findings[0]['field'] == 'family'
|
||||
|
||||
|
||||
def test_deck_status_is_the_worst_finding():
|
||||
findings = [
|
||||
{'status': D.CURRENT}, {'status': D.UNJUDGEABLE},
|
||||
{'status': D.CONDITIONAL},
|
||||
]
|
||||
assert D.deck_status(findings) == D.CONDITIONAL
|
||||
findings.append({'status': D.STALE})
|
||||
assert D.deck_status(findings) == D.STALE
|
||||
assert D.deck_status([]) == D.CURRENT
|
||||
|
||||
|
||||
# -------------------------------------------------------------- _deck_lines
|
||||
|
||||
def test_deck_lines_group_identical_findings_across_lifters(repo):
|
||||
findings = [
|
||||
{'deck': 'x', 'mark': 'WP1', 'lifter_mark': 'L1', 'family': 'F',
|
||||
'field': 'capacity_lb', 'status': D.STALE,
|
||||
'detail': {'frozen': 7690.0, 'current': 16000.0}},
|
||||
{'deck': 'x', 'mark': 'WP1', 'lifter_mark': 'L2', 'family': 'F',
|
||||
'field': 'capacity_lb', 'status': D.STALE,
|
||||
'detail': {'frozen': 7690.0, 'current': 16000.0}},
|
||||
]
|
||||
lines = D._deck_lines('WP1', findings)
|
||||
assert len(lines) == 1
|
||||
assert '2 lifters' in lines[0]
|
||||
assert 'WP1' in lines[0]
|
||||
|
||||
|
||||
def test_deck_lines_keep_genuinely_different_values_separate():
|
||||
findings = [
|
||||
{'deck': 'x', 'mark': 'WP1', 'lifter_mark': 'L1', 'family': 'F',
|
||||
'field': 'capacity_lb', 'status': D.STALE,
|
||||
'detail': {'frozen': 7690.0, 'current': 16000.0}},
|
||||
{'deck': 'x', 'mark': 'WP1', 'lifter_mark': 'L2', 'family': 'F',
|
||||
'field': 'capacity_lb', 'status': D.STALE,
|
||||
'detail': {'frozen': 8000.0, 'current': 9000.0}},
|
||||
]
|
||||
lines = D._deck_lines('WP1', findings)
|
||||
assert len(lines) == 2
|
||||
|
||||
|
||||
def test_deck_lines_omit_current_findings():
|
||||
findings = [{'deck': 'x', 'mark': 'WP1', 'lifter_mark': 'L1',
|
||||
'family': 'F', 'field': 'capacity_lb', 'status': D.CURRENT,
|
||||
'detail': {'current': 1.0, 'frozen': 1.0}}]
|
||||
assert D._deck_lines('WP1', findings) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- CLI
|
||||
|
||||
def test_main_exits_1_when_a_deck_is_stale(repo, capsys):
|
||||
repo.set_catalogue({'P76ALIAS': {'working_load_lb': 16000}})
|
||||
repo.set_plant({'lifters': {'catalogue_alias': {'LIFTFAM_P76': 'P76ALIAS'}}})
|
||||
_write_json(repo.deck_path('WP1.job.json'), _deck('WP1', lifters=[
|
||||
_lifter('L1', 'LIFTFAM_P76', capacity_lb=7690.0)]))
|
||||
rc = D.main([repo.jobs_dir])
|
||||
out = capsys.readouterr().out
|
||||
assert rc == 1
|
||||
assert 'STALE' in out
|
||||
assert 'WP1' in out
|
||||
assert 'capacity_lb' in out
|
||||
|
||||
|
||||
def test_main_exits_0_when_everything_is_current(repo, capsys):
|
||||
repo.set_catalogue({'PLAIN01': {'working_load_lb': 8000}})
|
||||
repo.set_plant({'lifters': {'catalogue_alias': {'PLAINLIFT': 'PLAIN01'}}})
|
||||
_write_json(repo.deck_path('WP1.job.json'), _deck('WP1', lifters=[
|
||||
_lifter('L1', 'PLAINLIFT', capacity_lb=8000.0)]))
|
||||
rc = D.main([repo.jobs_dir])
|
||||
out = capsys.readouterr().out
|
||||
assert rc == 0
|
||||
assert '1 deck(s) checked, 1 current, 0 stale, 0 conditional, 0 unjudgeable' in out
|
||||
|
||||
|
||||
def test_main_exits_1_on_conditional_only(repo, capsys):
|
||||
repo.set_catalogue({'GATED01': {
|
||||
'working_load_lb': 16000, 'shear_load_lb': 4010,
|
||||
'requires_tension_bar_for_rating': True,
|
||||
'tension_without_tension_bar_lb': 7690,
|
||||
}})
|
||||
repo.set_plant({'lifters': {
|
||||
'catalogue_alias': {'GATEDFAM_P76': 'GATED01'},
|
||||
'tension_bar_families': ['SR5602'],
|
||||
'parts_shipped_with_tension_bar': ['P76'],
|
||||
}})
|
||||
_write_json(repo.deck_path('WP1.job.json'), _deck('WP1', lifters=[
|
||||
_lifter('L1', 'GATEDFAM_P76', capacity_lb=7690.0,
|
||||
shear_capacity_lb=4010.0)]))
|
||||
rc = D.main([repo.jobs_dir])
|
||||
out = capsys.readouterr().out
|
||||
assert rc == 1
|
||||
assert 'CONDITIONAL' in out
|
||||
|
||||
|
||||
def test_main_exits_2_when_jobs_dir_missing(tmp_path):
|
||||
rc = D.main([str(tmp_path / 'nope')])
|
||||
assert rc == 2
|
||||
|
||||
|
||||
def test_main_exits_2_when_jobs_dir_has_no_decks(repo):
|
||||
rc = D.main([repo.jobs_dir])
|
||||
assert rc == 2
|
||||
|
||||
|
||||
def test_main_prints_the_model_caveat_once(repo, capsys):
|
||||
repo.set_catalogue({'PLAIN01': {'working_load_lb': 8000}})
|
||||
repo.set_plant({'lifters': {'catalogue_alias': {'PLAINLIFT': 'PLAIN01'}}})
|
||||
_write_json(repo.deck_path('WP1.job.json'), _deck('WP1', lifters=[
|
||||
_lifter('L1', 'PLAINLIFT', capacity_lb=8000.0)]))
|
||||
D.main([repo.jobs_dir])
|
||||
out = capsys.readouterr().out
|
||||
assert out.count('NOT against the live') == 1 or 'MODEL' in out
|
||||
|
||||
|
||||
def test_main_writes_json_output(repo, tmp_path):
|
||||
repo.set_catalogue({'PLAIN01': {'working_load_lb': 8000}})
|
||||
repo.set_plant({'lifters': {'catalogue_alias': {'PLAINLIFT': 'PLAIN01'}}})
|
||||
_write_json(repo.deck_path('WP1.job.json'), _deck('WP1', lifters=[
|
||||
_lifter('L1', 'PLAINLIFT', capacity_lb=8000.0)]))
|
||||
out_path = str(tmp_path / 'out.json')
|
||||
D.main([repo.jobs_dir, '--json', out_path])
|
||||
with io.open(out_path, encoding='utf-8') as fh:
|
||||
payload = json.load(fh)
|
||||
assert payload['decks_checked'] == 1
|
||||
assert payload['current'] == 1
|
||||
@@ -0,0 +1,192 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Plant extraction rule packs.
|
||||
|
||||
The property every test here circles is the one the module rests on: a pack
|
||||
may re-describe a note or move it to a field a person reads, and NOTHING it
|
||||
can say makes a note disappear. Everything else -- which regex fires, what
|
||||
sentence comes back -- is detail beside that.
|
||||
|
||||
The seven notes used throughout are the real ones, verbatim, from `WPA1.0`
|
||||
(sample_job, 8" horizontal wall) read on 2026-09-01: the run that
|
||||
prompted this module, where all seven landed in `unresolved` under one
|
||||
blanket reason.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from vibe import extractrules
|
||||
from vibe.extractrules import mc
|
||||
|
||||
|
||||
# The seven, in sheet order. Whitespace-normalised the way
|
||||
# `ticketqc.unresolved_in_view` normalises before anything sees them.
|
||||
WPA1_NOTES = [
|
||||
'*USE MC STANDARD REINFORCEMENT FOR CUT DOWN PANELS AND AROUND ALL OPENINGS',
|
||||
'STRIPPING & BLOCKING DETAIL',
|
||||
'USE FOR BACK OR EDGE PICK (UNO)',
|
||||
'STRAND PATTERN',
|
||||
'STRAND',
|
||||
"#5 BAR X CONT ONLY NEEDED WHEN LAST PAIR OF STRANDS ARE MORE THAN 1'-0 "
|
||||
'FROM EDGE OF WALL.',
|
||||
"STRIPPING & BLOCKING DETAIL < 25'",
|
||||
]
|
||||
|
||||
|
||||
def entries(texts, reason='no extraction rule matched this note'):
|
||||
return [{'text': t, 'reason': reason} for t in texts]
|
||||
|
||||
|
||||
def know_with(packs):
|
||||
"""A knowledge stand-in and a getter over it, so these tests never need
|
||||
the real layered loader (which wants a Revit doc)."""
|
||||
store = {'extract.rule_packs': packs}
|
||||
return store, lambda know, key, default=None: know.get(key, default)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- the gate
|
||||
|
||||
def test_no_declaration_loads_no_pack_and_changes_nothing():
|
||||
"""The honest default: told nothing about an office, recognise nothing."""
|
||||
know, get = know_with([])
|
||||
packs, problems = extractrules.load(know, get)
|
||||
assert packs == []
|
||||
assert problems == []
|
||||
|
||||
given = entries(WPA1_NOTES)
|
||||
notes, unresolved = extractrules.apply_to(given, packs)
|
||||
assert notes == []
|
||||
assert unresolved == given
|
||||
|
||||
|
||||
def test_declared_pack_loads():
|
||||
know, get = know_with(['mc'])
|
||||
packs, problems = extractrules.load(know, get)
|
||||
assert problems == []
|
||||
assert [p.NAME for p in packs] == ['mc']
|
||||
|
||||
|
||||
def test_a_bare_string_is_accepted_as_one_name():
|
||||
know, get = know_with('mc')
|
||||
packs, problems = extractrules.load(know, get)
|
||||
assert problems == []
|
||||
assert [p.NAME for p in packs] == ['mc']
|
||||
|
||||
|
||||
def test_unknown_pack_is_refused_loudly_and_nothing_loads():
|
||||
"""All or nothing. A half-loaded rule set classifies some notes and not
|
||||
others, and the reader cannot tell which sentences to trust."""
|
||||
know, get = know_with(['mc', 'no-such-plant'])
|
||||
packs, problems = extractrules.load(know, get)
|
||||
assert packs == []
|
||||
assert len(problems) == 1
|
||||
assert 'no-such-plant' in problems[0]
|
||||
assert 'mc' in problems[0] # says what IS available
|
||||
|
||||
|
||||
def test_empty_and_duplicate_names_are_refused():
|
||||
know, get = know_with(['mc', 'mc'])
|
||||
_, problems = extractrules.load(know, get)
|
||||
assert problems and 'twice' in problems[0]
|
||||
|
||||
know, get = know_with([''])
|
||||
_, problems = extractrules.load(know, get)
|
||||
assert problems and 'empty' in problems[0]
|
||||
|
||||
|
||||
def test_knowledge_default_is_empty():
|
||||
from vibe import knowledge
|
||||
assert knowledge.DEFAULTS['extract']['rule_packs'] == []
|
||||
|
||||
|
||||
# ------------------------------------------------- the load-bearing property
|
||||
|
||||
def test_no_note_is_ever_dropped():
|
||||
"""The whole module in one assertion."""
|
||||
know, get = know_with(['mc'])
|
||||
packs, _ = extractrules.load(know, get)
|
||||
given = entries(WPA1_NOTES)
|
||||
notes, unresolved = extractrules.apply_to(given, packs)
|
||||
|
||||
assert len(notes) + len(unresolved) == len(given)
|
||||
assert (sorted(n['text'] for n in notes + unresolved)
|
||||
== sorted(WPA1_NOTES))
|
||||
|
||||
|
||||
def test_every_placement_names_the_rule_that_made_it():
|
||||
"""An unattributable classification is one nobody can argue with."""
|
||||
know, get = know_with(['mc'])
|
||||
packs, _ = extractrules.load(know, get)
|
||||
notes, unresolved = extractrules.apply_to(entries(WPA1_NOTES), packs)
|
||||
for entry in notes:
|
||||
assert entry['rule'].startswith('mc/')
|
||||
for entry in unresolved:
|
||||
if 'rule' in entry:
|
||||
assert entry['rule'].startswith('mc/')
|
||||
|
||||
|
||||
def test_a_placement_must_declare_a_known_kind_and_a_rule():
|
||||
with pytest.raises(ValueError):
|
||||
extractrules.Placement('vanish', 'mc/x', 'because')
|
||||
with pytest.raises(ValueError):
|
||||
extractrules.Placement(extractrules.NOTE, '', 'because')
|
||||
|
||||
|
||||
# ------------------------------------------------------- the mc pack itself
|
||||
|
||||
def test_the_seven_notes_land_where_they_should():
|
||||
know, get = know_with(['mc'])
|
||||
packs, _ = extractrules.load(know, get)
|
||||
notes, unresolved = extractrules.apply_to(entries(WPA1_NOTES), packs)
|
||||
|
||||
assert sorted(n['text'] for n in notes) == sorted([
|
||||
'*USE MC STANDARD REINFORCEMENT FOR CUT DOWN PANELS AND AROUND ALL '
|
||||
'OPENINGS',
|
||||
'USE FOR BACK OR EDGE PICK (UNO)',
|
||||
"#5 BAR X CONT ONLY NEEDED WHEN LAST PAIR OF STRANDS ARE MORE THAN "
|
||||
"1'-0 FROM EDGE OF WALL.",
|
||||
"STRIPPING & BLOCKING DETAIL < 25'",
|
||||
])
|
||||
assert sorted(u['text'] for u in unresolved) == sorted([
|
||||
'STRIPPING & BLOCKING DETAIL',
|
||||
'STRAND PATTERN',
|
||||
'STRAND',
|
||||
])
|
||||
|
||||
|
||||
def test_a_conditional_detail_title_is_a_note_and_the_bare_one_is_not():
|
||||
"""The asymmetry this pack exists to get right: the condition is the part
|
||||
that could change what gets built, so it is never called furniture."""
|
||||
bare = mc.classify('STRIPPING & BLOCKING DETAIL')
|
||||
cond = mc.classify("STRIPPING & BLOCKING DETAIL < 25'")
|
||||
assert bare.kind == extractrules.FURNITURE
|
||||
assert cond.kind == extractrules.NOTE
|
||||
assert bare.rule != cond.rule
|
||||
|
||||
|
||||
def test_furniture_keeps_its_place_in_unresolved():
|
||||
"""`furniture` is not an ignore list -- the line is still written out,
|
||||
only the sentence beside it changes."""
|
||||
know, get = know_with(['mc'])
|
||||
packs, _ = extractrules.load(know, get)
|
||||
given = entries(['STRAND PATTERN'])
|
||||
notes, unresolved = extractrules.apply_to(given, packs)
|
||||
assert notes == []
|
||||
assert len(unresolved) == 1
|
||||
assert unresolved[0]['text'] == 'STRAND PATTERN'
|
||||
assert unresolved[0]['reason'] != given[0]['reason'] # better sentence
|
||||
assert 'legend' in unresolved[0]['reason']
|
||||
|
||||
|
||||
def test_the_pack_recognises_nothing_it_was_not_shown():
|
||||
"""A rule with no observation behind it does not belong in the pack, and
|
||||
a sentence from another producer's sheet must fall straight through."""
|
||||
for text in ('SEE ARCHITECTURAL DRAWINGS',
|
||||
'ALL DIMENSIONS ARE NOMINAL',
|
||||
'USE 6X6 -D3.0/D3.0 W.W.F.',
|
||||
'',
|
||||
'STRANDS'):
|
||||
assert mc.classify(text) is None
|
||||
|
||||
|
||||
def test_classify_is_case_and_padding_tolerant():
|
||||
assert mc.classify(' strand pattern ') is not None
|
||||
assert mc.classify('Strand Pattern').kind == extractrules.FURNITURE
|
||||
@@ -0,0 +1,315 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Reports that fold: what opens, what stays shut, and what is never lost.
|
||||
|
||||
The rule under test is the owner's, 2026-08-30: everything below the answer
|
||||
starts folded, and a section opens itself only when it has a problem in it.
|
||||
The value of that is a contract -- "if nothing below the headline is open,
|
||||
nothing needs me" -- and the contract is worth more than any single section
|
||||
being visible. So the tests are mostly about the two ways it could be broken:
|
||||
a section that has a problem and does not open, and a section that folds a
|
||||
sentence out of existence rather than folding it away.
|
||||
|
||||
The HTML half is written for pyRevit's output panel, which is an IE-engine
|
||||
`System.Windows.Forms.WebBrowser`. `<details>` does not exist there and fails
|
||||
SILENTLY -- the "collapsed" content simply renders. That is the trap this
|
||||
module exists to avoid, so it is asserted directly.
|
||||
"""
|
||||
# pylint: disable=import-error,invalid-name
|
||||
import re
|
||||
|
||||
from vibe import foldreport as fold
|
||||
|
||||
|
||||
def _section(**kw):
|
||||
kw.setdefault('key', 's1')
|
||||
kw.setdefault('title', 'Some detail')
|
||||
return fold.Section(**kw)
|
||||
|
||||
|
||||
# --------------------------------------------------------- what opens itself
|
||||
|
||||
def test_a_routine_section_is_folded():
|
||||
assert _section(summary='3 things').is_open is False
|
||||
|
||||
|
||||
def test_a_section_with_a_problem_opens_itself():
|
||||
assert _section(problem='2 checks did not run').is_open is True
|
||||
|
||||
|
||||
def test_the_answer_is_never_folded():
|
||||
assert _section(headline=True).is_open is True
|
||||
|
||||
|
||||
def test_a_problem_is_a_sentence_not_a_flag():
|
||||
"""A section that opens has to say why it opened, or the reader has to
|
||||
read the whole of it to find out."""
|
||||
section = _section(problem='the index is 4 day(s) old')
|
||||
assert 'day(s) old' in fold.blocks([section])[0][1]
|
||||
|
||||
|
||||
def test_the_contract_is_readable_off_the_section_list():
|
||||
sections = [_section(key='a'), _section(key='b', problem='something')]
|
||||
assert [s.key for s in fold.sections_with_problems(sections)] == ['b']
|
||||
assert fold.problem_count(sections) == 1
|
||||
|
||||
|
||||
# ------------------------------------------------------- folded is not gone
|
||||
|
||||
def test_a_folded_section_still_names_itself_and_what_it_holds():
|
||||
section = _section(title='The donor pool', summary='690 looked at, 204 kept',
|
||||
blocks=[('md', 'a long table nobody reads')])
|
||||
line = fold.blocks([section])[0][1]
|
||||
assert 'The donor pool' in line
|
||||
assert '690 looked at, 204 kept' in line
|
||||
assert 'folded' in line
|
||||
assert 'nobody reads' not in line # folded away, not printed
|
||||
|
||||
|
||||
def test_show_all_prints_every_section_in_full():
|
||||
section = _section(blocks=[('md', 'the detail')])
|
||||
text = fold.text([section], show_all=True)
|
||||
assert 'the detail' in text
|
||||
|
||||
|
||||
def test_the_headline_is_printed_without_a_fold_header():
|
||||
head = _section(key='headline', title='Answer', headline=True,
|
||||
blocks=[('md', '# The answer')])
|
||||
got = fold.blocks([head])
|
||||
assert got == [('md', '# The answer')]
|
||||
|
||||
|
||||
# ------------------------------------------------------------- as plain text
|
||||
|
||||
def test_entities_become_ascii_and_the_words_are_left_alone():
|
||||
got = fold.md_text('This is **bold** — and `code`.')
|
||||
assert 'This is bold -- and code.' in got
|
||||
assert '—' not in got
|
||||
|
||||
|
||||
def test_a_quote_block_is_marked_so_a_caveat_cannot_look_like_prose():
|
||||
assert fold.md_text('> a warning').lstrip().startswith('!')
|
||||
|
||||
|
||||
def test_a_path_is_never_broken_across_two_lines():
|
||||
"""A wrapped path is not a wrapped sentence, it is an unusable value --
|
||||
the reader copies these into a shell or into Select by ID."""
|
||||
path = 'C:\\a\\very\\long\\folder\\name\\that\\exceeds\\the\\margin\\x.txt'
|
||||
got = fold.md_text('written to `' + path + '`')
|
||||
assert path in got
|
||||
|
||||
|
||||
def test_a_table_never_truncates_a_mark_however_wide_the_row_gets():
|
||||
"""A silently cut mark is a wrong answer that looks like a right one."""
|
||||
got = fold.table_text(['donor', 'what'],
|
||||
[['PNL803', 'x' * 120], ['PNL804', 'y']])
|
||||
assert 'PNL803' in got and 'PNL804' in got
|
||||
assert 'x' * 120 in got
|
||||
|
||||
|
||||
def test_an_empty_table_says_nothing_rather_than_printing_a_header():
|
||||
assert 'nothing' in fold.table_text(['a'], [])
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- as HTML
|
||||
|
||||
def test_the_html_never_uses_details_which_this_panel_cannot_render():
|
||||
"""pyRevit's output is an IE-engine WebBrowser. `<details>` renders as an
|
||||
inline span there, so a 'collapsed' section would simply be visible --
|
||||
the failure is silent, which is why it is asserted rather than trusted."""
|
||||
got = fold.html([_section(blocks=[('md', 'x')])])
|
||||
assert '<details' not in got and '<summary' not in got
|
||||
assert 'addEventListener' not in got and 'querySelector' not in got
|
||||
|
||||
|
||||
def test_a_folded_section_is_hidden_and_a_problem_section_is_not():
|
||||
folded = fold.html([_section(key='a', blocks=[('md', 'x')])])
|
||||
opened = fold.html([_section(key='b', problem='wrong',
|
||||
blocks=[('md', 'x')])])
|
||||
assert 'display:none' in folded
|
||||
assert 'display:block' in opened
|
||||
|
||||
|
||||
def test_a_problem_section_needs_no_javascript_to_be_read():
|
||||
"""If the toggle is dead in the panel, everything that matters is still
|
||||
on the page. That is the whole fallback."""
|
||||
got = fold.html([_section(problem='wrong', blocks=[('md', 'the detail')])])
|
||||
assert 'the detail' in got
|
||||
assert 'display:none' not in got
|
||||
|
||||
|
||||
def test_the_toggle_only_uses_dom_that_exists_in_ie7():
|
||||
assert 'getElementById' in fold.SCRIPT
|
||||
assert 'style.display' in fold.SCRIPT
|
||||
assert 'classList' not in fold.SCRIPT
|
||||
|
||||
|
||||
def test_the_header_is_always_in_the_html_even_when_folded():
|
||||
got = fold.html([_section(title='The donor pool', summary='690 looked at',
|
||||
blocks=[('md', 'x')])])
|
||||
assert 'The donor pool' in got
|
||||
assert '690 looked at' in got
|
||||
|
||||
|
||||
def test_markup_in_a_cell_cannot_break_the_row():
|
||||
got = fold.table_html(['a'], [['<script>bad()</script>']])
|
||||
assert '<script>' not in got
|
||||
assert '<script>' in got
|
||||
|
||||
|
||||
def test_the_entities_the_reports_write_on_purpose_survive_escaping():
|
||||
"""The reports write `—` and `†` as ASCII source, because
|
||||
IronPython 2.7 makes a non-ASCII literal a byte string. Escaping would
|
||||
turn those into visible text, so they are restored by name."""
|
||||
assert '—' in fold.md_html('a **b** — c')
|
||||
assert '†' in fold.table_html(['a'], [['PNL1†']])
|
||||
|
||||
|
||||
def test_an_ampersand_in_the_text_is_still_escaped():
|
||||
assert '&' in fold.md_html('Smith & Sons')
|
||||
|
||||
|
||||
def test_a_header_carries_its_entities_too():
|
||||
"""Title, summary and problem go through the same escape-then-restore as
|
||||
md blocks and table cells. They did not until 2026-08-30, and
|
||||
`takeoffreport` wrote `—` into a dozen headers on that assumption --
|
||||
every one of which reached the panel as literal source text."""
|
||||
got = fold.html([_section(key='a', title='Other quantities',
|
||||
summary='4 kinds — none checked'),
|
||||
_section(key='b', title='Bars — unread',
|
||||
problem='2 marks — no length')])
|
||||
assert '&mdash;' not in got
|
||||
assert 'Bars — unread' in got
|
||||
assert '4 kinds — none checked' in got
|
||||
assert '2 marks — no length' in got
|
||||
|
||||
|
||||
def test_an_ampersand_in_a_header_is_still_escaped():
|
||||
"""The restore is by name, so a bare ampersand in a title stays escaped
|
||||
and cannot break the header markup."""
|
||||
got = fold.html([_section(key='a', title='Smith & Sons',
|
||||
summary='Jones & Co')])
|
||||
assert '&' in got
|
||||
assert 'Smith & Sons' not in got
|
||||
|
||||
|
||||
def test_the_panel_html_carries_no_newline_at_all():
|
||||
"""pyRevit's `print_html` is a `print(..., end='')` into the panel's
|
||||
stream, and a newline in that stream starts a NEW output entry -- which
|
||||
would cut a <div> in half and leave a fold's body outside its container."""
|
||||
got = fold.html([_section(key='a', blocks=[('md', 'x'), ('md', 'y'),
|
||||
('table', ['c'], [['1']])]),
|
||||
_section(key='b', problem='wrong', blocks=[('md', 'z')])])
|
||||
assert chr(10) not in got
|
||||
|
||||
|
||||
# ------------- the text report may not drop a character of what it prints
|
||||
#
|
||||
# `last-ticketqc.txt` and every other `text(sections, show_all=True)` file is
|
||||
# documented as the complete record -- "folded is not gone". A live run on
|
||||
# 2026-08-31 wrote the ticket one with every CJK character of the job file's
|
||||
# mute sentences REMOVED, ASCII fragments and the gaps between them
|
||||
# surviving, while the same sentences rendered correctly in the panel and in
|
||||
# `qc-run.json`.
|
||||
#
|
||||
# The mechanism is engine-specific and CANNOT be reproduced on the host
|
||||
# interpreter this suite runs on: `textwrap` munges whitespace with
|
||||
# `text.translate(<256-entry table>)` for anything it takes for a `str`, and
|
||||
# IronPython 2.7's `str` holds .NET UTF-16 text, so a character above U+00FF
|
||||
# has no entry in that table. `ticketpage` was cured first; this renderer had
|
||||
# the identical call, plus a `str()` in `_cell` that `md_text` does not have.
|
||||
#
|
||||
# So two kinds of test: that non-ASCII survives (true on any engine, and the
|
||||
# promise itself) and a tombstone pinning the SHAPE that keeps it true on the
|
||||
# engine that ships.
|
||||
|
||||
_CJK = u'\u5f00\u53e3\u672a\u5efa\u6a21' # 开口未建模
|
||||
|
||||
|
||||
def test_a_non_ascii_line_survives_md_text():
|
||||
assert _CJK in fold.md_text(_CJK + ' -- muted by the owner')
|
||||
|
||||
|
||||
def test_a_non_ascii_line_survives_being_wrapped_onto_several_lines():
|
||||
"""The wrap is where the characters were lost, so the long case is the
|
||||
one that matters: a short line might never reach `textwrap` at all."""
|
||||
got = fold.md_text((_CJK + ' muted ') * 12, width=40)
|
||||
assert got.count(_CJK) == 12
|
||||
assert len(got.split(chr(10))) > 3 # it really did wrap
|
||||
|
||||
|
||||
def test_a_non_ascii_heading_and_folded_header_survive_too():
|
||||
assert _CJK in fold.md_text('## ' + _CJK)
|
||||
assert _CJK in fold.md_text('- **' + _CJK + '** _(folded)_')
|
||||
|
||||
|
||||
def test_a_non_ascii_table_cell_survives():
|
||||
"""`_cell` is the text path's other coercion boundary -- `md_text` has
|
||||
none of its own."""
|
||||
got = fold.table_text(['mark', 'why'], [['PNL101', _CJK]])
|
||||
assert _CJK in got
|
||||
|
||||
|
||||
def test_a_non_ascii_section_survives_the_whole_text_report():
|
||||
"""End to end, the way the buttons write the file: every block, folded
|
||||
and open, through one join."""
|
||||
got = fold.text([_section(key='a', title=_CJK + ' section',
|
||||
problem=_CJK + ' problem',
|
||||
blocks=[('md', _CJK + ' body'),
|
||||
('table', ['c'], [[_CJK]])]),
|
||||
_section(key='b', title='folded ' + _CJK,
|
||||
summary=_CJK + ' summary')],
|
||||
show_all=False)
|
||||
assert got.count(_CJK) >= 2
|
||||
# and it is TEXT, so the caller's one encode('utf-8') is all it takes
|
||||
assert got.encode('utf-8').decode('utf-8') == got
|
||||
|
||||
|
||||
def test_the_text_path_never_hands_a_line_to_textwraps_translate():
|
||||
"""A TOMBSTONE, and the only kind of test that can hold here: the defect
|
||||
lives on an engine this suite does not run on, so what is pinned is the
|
||||
shape that avoids it -- whitespace flattened before the wrap, `textwrap`
|
||||
told not to do it again, and no bare `str()` on anything printed."""
|
||||
import inspect
|
||||
|
||||
def code_of(fn):
|
||||
"""The function without its docstring: these docstrings TALK about
|
||||
`str()`, and a prose mention is not a call."""
|
||||
return inspect.getsource(fn).replace(fn.__doc__ or chr(10) * 2, '')
|
||||
|
||||
src = code_of(fold._fill)
|
||||
assert 'replace_whitespace=False' in src
|
||||
assert 'expand_tabs=False' in src
|
||||
assert '_text(body)' in src
|
||||
assert 'textwrap' not in code_of(fold.md_text) # it goes through _fill
|
||||
for name in ('_cell', '_flat', 'md_text', 'text', '_open_header',
|
||||
'_folded_header'):
|
||||
assert 'str(' not in code_of(getattr(fold, name)), name
|
||||
|
||||
|
||||
# ------------------------------------------------------ demo-safe: no CJK
|
||||
#
|
||||
# Owner, 2026-09-01: no Chinese anywhere in a report or a UI surface, even
|
||||
# when quoting him -- he is demoing this to colleagues before the weekend.
|
||||
# This module is content-agnostic -- the CJK survival tests above are about
|
||||
# a JOB'S OWN notes surviving intact, which is a different, still-valid
|
||||
# guarantee -- but a WillTools-authored report bundle, the kind this module
|
||||
# actually renders for the ticket-QC and reinforcing buttons, must render
|
||||
# with none. This fixture is the shape those callers build: an answer
|
||||
# headline plus foldable detail sections with tables.
|
||||
_ENGLISH_SECTIONS = [
|
||||
_section(key='answer', title='Result', headline=True,
|
||||
blocks=[('md', 'OK -- every check ran.')]),
|
||||
_section(key='findings', title='Findings', summary='2 items',
|
||||
problem='2 checks need a look',
|
||||
blocks=[('md', 'Everything of this kind goes out as WARN.'),
|
||||
('table', ['check', 'status'],
|
||||
[['4.5', 'WARN'], ['6.4', 'OK']])]),
|
||||
]
|
||||
|
||||
|
||||
def test_a_willtools_report_bundle_renders_with_no_chinese_text_or_html():
|
||||
text_out = fold.text(_ENGLISH_SECTIONS, show_all=True)
|
||||
html_out = fold.html(_ENGLISH_SECTIONS)
|
||||
cjk = re.compile(u'[一-鿿]')
|
||||
assert not cjk.search(text_out), text_out
|
||||
assert not cjk.search(html_out), html_out
|
||||
@@ -0,0 +1,243 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Rows 5.13, 5.14 and 5.15 once the hatch gather exists.
|
||||
|
||||
WHAT CHANGED ON 2026-08-31. These three rows shipped with their arithmetic
|
||||
built and their inputs deliberately un-gathered: `ticketqc.read_ticket` set
|
||||
no `solid_zone_pattern`, `recess_patterns`, `recess_regions` or
|
||||
`legend_patterns`, so all three stood down naming the gather. That was the
|
||||
right call while nobody had run the reads. The reads have now been taken on
|
||||
the live model, id by id, and `ticketqc._read_insulation_hatch` is the path
|
||||
they exercised -- so the rows run.
|
||||
|
||||
NO PLANT SPELLING IN THIS FILE. The real filled-region type names carry a
|
||||
plant initialism and live in the plant layer; the placeholders below have
|
||||
the same SHAPE and none of the identity, which is all the mechanism needs
|
||||
(CLAUDE.md ss3).
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import conftest # noqa: F401
|
||||
|
||||
# THE INERT REVIT STUB, AND IT IS PUT BACK. `ticketqc` imports
|
||||
# `Autodesk.Revit.DB` at MODULE scope, so `conftest`'s package trick cannot
|
||||
# reach it -- `test_ticketchecks_host.py` explains the whole shape and this
|
||||
# is the same borrowing. It is removed again immediately, because
|
||||
# `sys.modules` is process-wide and `test_reinforceoffline.py` asserts that
|
||||
# no `Autodesk*` module is loaded.
|
||||
_ADDED = []
|
||||
if 'Autodesk.Revit.DB' not in sys.modules:
|
||||
import types
|
||||
_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',
|
||||
'StorageType', 'ElementId', 'XYZ', 'Transform',
|
||||
'Options', 'Solid', 'BoundingBoxXYZ'):
|
||||
setattr(_db, _name, type(str(_name), (object,), {}))
|
||||
_autodesk.Revit = _revit
|
||||
_revit.DB = _db
|
||||
sys.modules['Autodesk'] = _autodesk
|
||||
sys.modules['Autodesk.Revit'] = _revit
|
||||
sys.modules['Autodesk.Revit.DB'] = _db
|
||||
_ADDED = ['Autodesk.Revit.DB', 'Autodesk.Revit', 'Autodesk']
|
||||
try:
|
||||
from vibe import ticketpage as TP # noqa: E402
|
||||
from vibe import ticketchecks as TC # noqa: E402
|
||||
finally:
|
||||
for _name in _ADDED:
|
||||
sys.modules.pop(_name, None)
|
||||
|
||||
RECESS_A = 'RECESS 2IN NF'
|
||||
RECESS_B = 'RECESS 3IN NF'
|
||||
|
||||
|
||||
def _know(**over):
|
||||
"""A plant armed the way the real one now is: a solid-zone family, a
|
||||
legend it reads its reference off, two recess types, and a plant-side
|
||||
copy of the legend for the piece whose legend could not be read."""
|
||||
know = {
|
||||
'drawings': {'insulation_layout': 'Elevation Top 1'},
|
||||
'insulation': {
|
||||
'solid_zone': {'family': 'SOLID_ZONE_RECTANGULAR',
|
||||
'family_verified': True,
|
||||
'view_filter': 'SOLID ZONE'},
|
||||
'legend': {
|
||||
'view_name': 'TICKET NOTE - INS. LEGEND',
|
||||
'name_tokens': ['LEGEND'],
|
||||
'swatch_types': {'SOLID ZONE SWATCH': 'solid_zone',
|
||||
RECESS_A: 'recess_2in_nf',
|
||||
RECESS_B: 'recess_3in_nf'},
|
||||
'expected_patterns': {'solid_zone': 'ANSI35',
|
||||
'recess_2in_nf': 'TRIANG',
|
||||
'recess_3in_nf': 'TUCKPOINT'},
|
||||
},
|
||||
'recess': {'region_types': [RECESS_A, RECESS_B],
|
||||
'modelled_path_verified': True,
|
||||
'modelled_path_unverified_because': None},
|
||||
},
|
||||
}
|
||||
know.update(over)
|
||||
return know
|
||||
|
||||
|
||||
def _ticket(**over):
|
||||
t = {'mark': 'PNL101', 'sheets': [object()],
|
||||
'solid_zones': [{'id': 1, 'member': True, 'lifter_id': 9}],
|
||||
'lifters': [{'family': 'F'}],
|
||||
'legend_patterns': {'solid_zone': 'ANSI35',
|
||||
'recess_2in_nf': 'TRIANG',
|
||||
'recess_3in_nf': 'TUCKPOINT'},
|
||||
'recess_regions': {},
|
||||
'recess_patterns': {RECESS_A: 'TRIANG', RECESS_B: 'TUCKPOINT'},
|
||||
'solid_zone_pattern': 'TRIANG',
|
||||
'insulation_hatch_notes': []}
|
||||
t.update(over)
|
||||
return t
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- 5.13
|
||||
|
||||
def test_513_runs_and_fails_on_the_measured_job():
|
||||
"""THE MEASURED ANSWER. The solid zones draw the pattern the view
|
||||
template's `SOLID ZONE` filter sets, and the legend's own swatch for a
|
||||
solid zone is a different pattern element entirely. The row FAILS, and
|
||||
both patterns and both provenances are in the sentence -- "the hatch is
|
||||
wrong" is not actionable, "it draws X and your own legend shows Y" is.
|
||||
"""
|
||||
t = _ticket()
|
||||
found = TC.check_solid_zone_hatch(t, {}, _know(), None)
|
||||
assert len(found) == 1
|
||||
assert found[0].severity == 'FAIL'
|
||||
assert found[0].code == 'SOLID-ZONE-HATCH'
|
||||
assert 'TRIANG' in found[0].message and 'ANSI35' in found[0].message
|
||||
assert 'legend' in found[0].message
|
||||
assert t['_solid_zone_hatch_silence'] is None
|
||||
|
||||
|
||||
def test_513_passes_when_the_filter_sets_the_legends_own_pattern():
|
||||
found = TC.check_solid_zone_hatch(
|
||||
_ticket(solid_zone_pattern='ANSI35'), {}, _know(), None)
|
||||
assert found[0].severity == 'OK'
|
||||
|
||||
|
||||
def test_513_stands_down_carrying_the_gathers_own_sentences():
|
||||
"""A row that stood down saying only "it was not gathered" would throw
|
||||
away the reason the gather recorded, and the reader would have to
|
||||
reproduce the read by hand to find out why."""
|
||||
t = _ticket(solid_zone_pattern=None,
|
||||
insulation_hatch_notes=[
|
||||
'the insulation layout view is not placed on this ticket'])
|
||||
found = TC.check_solid_zone_hatch(t, {}, _know(), None)
|
||||
assert found == []
|
||||
said = t['_solid_zone_hatch_silence']
|
||||
assert 'what the gather recorded' in said
|
||||
assert 'not placed on this ticket' in said
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- 5.14
|
||||
|
||||
def test_514_stands_down_and_refuses_to_imply_the_pieces_were_judged():
|
||||
"""ZERO PLACED, JOB-WIDE. The only recess regions that exist in this
|
||||
model are legend swatches, and the gather excludes legend views on
|
||||
purpose. So a zero here is a real zero -- and it is still not a pass:
|
||||
this reader has never seen a job that DOES draw a recess, so it cannot
|
||||
tell a piece that needs none from a piece whose recesses were
|
||||
forgotten, and the sentence says exactly that."""
|
||||
t = _ticket()
|
||||
found = TC.check_recess_regions(t, {}, _know(), None)
|
||||
assert found == []
|
||||
said = t['_recess_silence']
|
||||
assert 'no recess region is drawn on any ticket view' in said
|
||||
assert 'legend views are excluded' in said
|
||||
assert 'cannot tell a piece that needs no recess' in said
|
||||
|
||||
|
||||
def test_514_no_longer_hedges_about_a_modelled_path_that_was_settled():
|
||||
"""The one candidate for a modelled recess was measured and ruled out,
|
||||
so `modelled_path_verified` is true and the caveat goes. A tool that
|
||||
kept hedging about a closed question is noise on a red row."""
|
||||
t = _ticket()
|
||||
TC.check_recess_regions(t, {}, _know(), None)
|
||||
assert 'modelled-recess path was NOT consulted' not in t['_recess_silence']
|
||||
know = _know()
|
||||
know['insulation']['recess']['modelled_path_verified'] = False
|
||||
know['insulation']['recess']['modelled_path_unverified_because'] = 'asked'
|
||||
t2 = _ticket()
|
||||
TC.check_recess_regions(t2, {}, know, None)
|
||||
assert 'NOT consulted' in t2['_recess_silence']
|
||||
|
||||
|
||||
def test_514_becomes_a_real_comparison_the_day_a_design_side_arrives():
|
||||
"""No new plumbing: the row already compares when something states what
|
||||
this piece should have."""
|
||||
t = _ticket(recess_regions={RECESS_A: 2})
|
||||
found = TC.check_recess_regions(t, {'recesses': {RECESS_A: 2}},
|
||||
_know(), None)
|
||||
assert found[0].severity == 'OK'
|
||||
found = TC.check_recess_regions(
|
||||
_ticket(recess_regions={}), {'recesses': {RECESS_A: 2}},
|
||||
_know(), None)
|
||||
assert found[0].severity == 'FAIL'
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- 5.15
|
||||
|
||||
def test_515_runs_clean_for_both_recess_types():
|
||||
"""The type-level patterns match their legend swatches. One finding for
|
||||
BOTH types, never one per type -- `checklist_states` keeps the last
|
||||
finding per code and a second RECESS-HATCH would silently replace the
|
||||
first."""
|
||||
t = _ticket()
|
||||
found = TC.check_recess_hatch(t, {}, _know(), None)
|
||||
assert len(found) == 1
|
||||
assert found[0].severity == 'OK'
|
||||
assert RECESS_A in found[0].message and RECESS_B in found[0].message
|
||||
# The size question is named and explicitly NOT decided here.
|
||||
assert 'not this row' in found[0].message
|
||||
|
||||
|
||||
def test_515_fails_on_the_type_that_drifted_and_still_reports_the_other():
|
||||
t = _ticket(recess_patterns={RECESS_A: 'STARS', RECESS_B: 'TUCKPOINT'})
|
||||
found = TC.check_recess_hatch(t, {}, _know(), None)
|
||||
assert found[0].severity == 'FAIL'
|
||||
assert 'STARS' in found[0].message
|
||||
assert 'TUCKPOINT' in found[0].message
|
||||
|
||||
|
||||
def test_515_stands_down_when_a_filter_could_override_the_type():
|
||||
"""THE CONDITION UNDER WHICH A TYPE-LEVEL READ IS AUTHORITATIVE, and it
|
||||
is checked on every run rather than recorded once. A filled region is a
|
||||
Detail Item; a filter selecting that category beats the type, and then
|
||||
the type says what the region WOULD have drawn with. Measured
|
||||
2026-08-31: no filter on this template carries the category -- which is
|
||||
a fact about today's template, not a property of Revit."""
|
||||
t = _ticket(recess_patterns=None, insulation_hatch_notes=[
|
||||
'filter(s) INSULATION select the Detail Items category on the '
|
||||
'insulation layout view, so a filled region\'s TYPE no longer says '
|
||||
'what it drew with -- the recess patterns were not read'])
|
||||
found = TC.check_recess_hatch(t, {}, _know(), None)
|
||||
assert found == []
|
||||
said = t['_recess_hatch_silence']
|
||||
assert 'Detail Items category' in said
|
||||
assert 'what the gather recorded' in said
|
||||
|
||||
|
||||
def test_the_sheets_own_legend_beats_the_plant_map_and_the_finding_says_so():
|
||||
""""the legend says ANSI35" and "a YAML file written on 2026-08-31 says
|
||||
ANSI35" are different claims. The legend is the self-updating one."""
|
||||
from_sheet = TC.check_solid_zone_hatch(_ticket(), {}, _know(), None)[0]
|
||||
assert "ticket's own legend" in from_sheet.message
|
||||
from_plant = TC.check_solid_zone_hatch(
|
||||
_ticket(legend_patterns={}), {}, _know(), None)[0]
|
||||
assert 'plant layer' in from_plant.message
|
||||
|
||||
|
||||
def test_a_gather_that_recorded_nothing_leaves_the_sentence_alone():
|
||||
"""`_hatch_gather_note` must add, never replace: the row keeps its own
|
||||
words when the gather has nothing to add."""
|
||||
assert TC._hatch_gather_note({}, 'because X') == 'because X'
|
||||
assert TC._hatch_gather_note({'insulation_hatch_notes': []},
|
||||
'because X') == 'because X'
|
||||
@@ -0,0 +1,154 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for `vibe.joblocate` -- picking a job's knowledge.yaml with no doc.
|
||||
|
||||
Loaded the ordinary way `conftest.py` makes possible (the module has no
|
||||
Revit import at all, so a plain `from vibe import joblocate` just works).
|
||||
Every env var is passed explicitly through `env={...}` rather than touched
|
||||
on the real process environment, so these never depend on, or leak into,
|
||||
whatever WILLTOOLS_JOBS happens to be set to on the machine running them.
|
||||
"""
|
||||
from vibe import joblocate as JL
|
||||
|
||||
|
||||
def _mk_job(tmp_path, name, text=u'job:\n plant: x\n'):
|
||||
folder = tmp_path / name
|
||||
folder.mkdir()
|
||||
(folder / 'knowledge.yaml').write_text(text, encoding='utf-8')
|
||||
return folder
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- job_candidates
|
||||
|
||||
def test_job_candidates_finds_every_folder_with_a_knowledge_file(tmp_path):
|
||||
_mk_job(tmp_path, 'alpha')
|
||||
_mk_job(tmp_path, 'beta')
|
||||
(tmp_path / 'not_a_job.txt').write_text('x', encoding='utf-8')
|
||||
found = JL.job_candidates(str(tmp_path))
|
||||
assert len(found) == 2
|
||||
assert all(f.endswith('knowledge.yaml') for f in found)
|
||||
|
||||
|
||||
def test_job_candidates_ignores_a_folder_with_no_knowledge_yaml(tmp_path):
|
||||
(tmp_path / 'empty_job').mkdir()
|
||||
assert JL.job_candidates(str(tmp_path)) == []
|
||||
|
||||
|
||||
def test_job_candidates_ignores_files_at_the_top_level(tmp_path):
|
||||
(tmp_path / 'knowledge.yaml').write_text('x', encoding='utf-8')
|
||||
assert JL.job_candidates(str(tmp_path)) == []
|
||||
|
||||
|
||||
def test_job_candidates_on_a_root_that_does_not_exist_is_empty(tmp_path):
|
||||
assert JL.job_candidates(str(tmp_path / 'nope')) == []
|
||||
|
||||
|
||||
def test_job_candidates_on_no_root_at_all_is_empty():
|
||||
assert JL.job_candidates(None) == []
|
||||
assert JL.job_candidates('') == []
|
||||
|
||||
|
||||
# --------------------------------------------------------- resolve_job_knowledge
|
||||
|
||||
def test_explicit_wins_over_everything_else(tmp_path):
|
||||
_mk_job(tmp_path, 'alpha')
|
||||
_mk_job(tmp_path, 'beta')
|
||||
result = JL.resolve_job_knowledge(explicit='some/file.yaml',
|
||||
jobs_root=str(tmp_path))
|
||||
assert result.path == 'some/file.yaml'
|
||||
assert result.how == 'explicit'
|
||||
assert bool(result) is True
|
||||
|
||||
|
||||
def test_no_root_configured_is_not_blocking():
|
||||
"""The ordinary state of a machine with no WILLTOOLS_JOBS at all -- a
|
||||
caller may fall back to DEFAULTS without treating this as an error."""
|
||||
result = JL.resolve_job_knowledge(jobs_root=None, env={})
|
||||
assert result.path is None
|
||||
assert result.blocking is False
|
||||
assert 'WILLTOOLS_JOBS' in result.reason
|
||||
|
||||
|
||||
def test_a_root_that_is_not_a_folder_is_blocking(tmp_path):
|
||||
not_a_dir = tmp_path / 'not_a_dir.txt'
|
||||
not_a_dir.write_text('x', encoding='utf-8')
|
||||
result = JL.resolve_job_knowledge(jobs_root=str(not_a_dir))
|
||||
assert result.path is None
|
||||
assert result.blocking is True
|
||||
assert str(not_a_dir) in result.reason
|
||||
|
||||
|
||||
def test_a_single_job_folder_is_used_with_no_further_input(tmp_path):
|
||||
job = _mk_job(tmp_path, 'sample_job')
|
||||
result = JL.resolve_job_knowledge(jobs_root=str(tmp_path))
|
||||
assert result.path == str(job / 'knowledge.yaml')
|
||||
assert result.how == 'single'
|
||||
assert result.blocking is False
|
||||
|
||||
|
||||
def test_an_empty_jobs_root_is_not_blocking(tmp_path):
|
||||
"""Nothing under it yet -- a fresh install, not a misconfiguration."""
|
||||
result = JL.resolve_job_knowledge(jobs_root=str(tmp_path))
|
||||
assert result.path is None
|
||||
assert result.blocking is False
|
||||
assert str(tmp_path) in result.reason
|
||||
|
||||
|
||||
def test_several_job_folders_with_none_named_refuses_and_lists_them(tmp_path):
|
||||
"""The exact ruling this module exists to enforce: guessing among several
|
||||
real job folders is a worse failure than asking, so this is BLOCKING."""
|
||||
_mk_job(tmp_path, 'sample_job')
|
||||
_mk_job(tmp_path, 'Horizon')
|
||||
_mk_job(tmp_path, 'sample_job_detached')
|
||||
result = JL.resolve_job_knowledge(jobs_root=str(tmp_path))
|
||||
assert result.path is None
|
||||
assert result.blocking is True
|
||||
assert result.reason is not None
|
||||
assert 'sample_job' in result.reason
|
||||
assert 'Horizon' in result.reason
|
||||
assert 'sample_job_detached' in result.reason
|
||||
assert len(result.candidates) == 3
|
||||
assert '--job' in result.reason
|
||||
|
||||
|
||||
def test_a_named_job_picks_one_of_several(tmp_path):
|
||||
_mk_job(tmp_path, 'sample_job')
|
||||
horizon = _mk_job(tmp_path, 'Horizon')
|
||||
result = JL.resolve_job_knowledge(jobs_root=str(tmp_path),
|
||||
job_name='Horizon')
|
||||
assert result.path == str(horizon / 'knowledge.yaml')
|
||||
assert result.how == 'named'
|
||||
assert result.blocking is False
|
||||
|
||||
|
||||
def test_a_named_job_that_does_not_exist_is_blocking_and_lists_real_ones(
|
||||
tmp_path):
|
||||
_mk_job(tmp_path, 'sample_job')
|
||||
_mk_job(tmp_path, 'Horizon')
|
||||
result = JL.resolve_job_knowledge(jobs_root=str(tmp_path),
|
||||
job_name='NoSuchJob')
|
||||
assert result.path is None
|
||||
assert result.blocking is True
|
||||
assert 'NoSuchJob' in result.reason
|
||||
assert 'sample_job' in result.reason
|
||||
assert 'Horizon' in result.reason
|
||||
|
||||
|
||||
def test_env_supplies_the_root_when_jobs_root_is_not_given(tmp_path):
|
||||
job = _mk_job(tmp_path, 'only-one')
|
||||
result = JL.resolve_job_knowledge(env={JL.JOBS_ENV: str(tmp_path)})
|
||||
assert result.path == str(job / 'knowledge.yaml')
|
||||
|
||||
|
||||
def test_explicit_bypasses_env_entirely(tmp_path):
|
||||
_mk_job(tmp_path, 'alpha')
|
||||
_mk_job(tmp_path, 'beta')
|
||||
result = JL.resolve_job_knowledge(explicit='chosen.yaml',
|
||||
env={JL.JOBS_ENV: str(tmp_path)})
|
||||
assert result.path == 'chosen.yaml'
|
||||
assert result.how == 'explicit'
|
||||
|
||||
|
||||
def test_result_is_falsy_when_nothing_was_found():
|
||||
result = JL.resolve_job_knowledge(jobs_root=None, env={})
|
||||
assert not result
|
||||
assert bool(result) is False
|
||||
@@ -0,0 +1,709 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for the job-level reconciliation core.
|
||||
|
||||
NO REAL MARK, CONTROL NUMBER, DESIGN NUMBER OR JOB DETAIL APPEARS HERE.
|
||||
Everything is invented: marks PNL1xx / BM1xx, controls in a 9xxxx band that
|
||||
no real job uses, designs PNL1.0 / RB1. The reconciliation is arithmetic on
|
||||
strings; it does not need a real job to be exercised, and the repo must never
|
||||
carry one.
|
||||
|
||||
The shapes under test are the ones that have actually bitten, or that would
|
||||
bite silently if they broke:
|
||||
|
||||
* a printed RANGE is several pieces, not one token
|
||||
* a stripped page 2 is still a ticket page
|
||||
* 'could not judge' never renders as 'judged and clean'
|
||||
* nothing is filtered for looking out of scope
|
||||
"""
|
||||
from vibe import jobrecon
|
||||
|
||||
|
||||
def piece(control, mark, design='PNL1.0', **kw):
|
||||
row = {'id': kw.pop('id', hash(control) % 100000), 'category': 'X',
|
||||
'control': control, 'mark': mark, 'design': design,
|
||||
'family': kw.pop('family', 'PANEL'), 'in_assembly': None,
|
||||
'ticket_created': None, 'ticket_detailed': None}
|
||||
row.update(kw)
|
||||
return row
|
||||
|
||||
|
||||
def sheet(name, number, controls, design='PNL1.0', **kw):
|
||||
row = {'id': kw.pop('id', 1), 'number': number, 'name': name,
|
||||
'design': design, 'controls': list(controls),
|
||||
'total_required': None, 'sheet_count': None}
|
||||
row.update(kw)
|
||||
return row
|
||||
|
||||
|
||||
# ------------------------------------------------- expand_control_field
|
||||
|
||||
def test_plain_control_number():
|
||||
assert jobrecon.expand_control_field('90001') == (['90001'], [])
|
||||
|
||||
|
||||
def test_comma_list_is_several_pieces():
|
||||
got, bad = jobrecon.expand_control_field('90001, 90003 ,90007')
|
||||
assert got == ['90001', '90003', '90007']
|
||||
assert bad == []
|
||||
|
||||
|
||||
def test_range_expands_to_every_piece_in_it():
|
||||
"""A slot holding a range is four pieces. Reading it as one token
|
||||
under-counts the ticket by three and invents three orphans."""
|
||||
got, bad = jobrecon.expand_control_field('90120-90123')
|
||||
assert got == ['90120', '90121', '90122', '90123']
|
||||
assert bad == []
|
||||
|
||||
|
||||
def test_range_keeps_leading_zero_width():
|
||||
got, _bad = jobrecon.expand_control_field('008-011')
|
||||
assert got == ['008', '009', '010', '011']
|
||||
|
||||
|
||||
def test_en_dash_range_still_expands():
|
||||
got, bad = jobrecon.expand_control_field(u'90120–90122')
|
||||
assert got == ['90120', '90121', '90122']
|
||||
assert bad == []
|
||||
|
||||
|
||||
def test_absurd_range_is_reported_not_materialised():
|
||||
got, bad = jobrecon.expand_control_field('1-99999')
|
||||
assert got == []
|
||||
assert bad == ['1-99999']
|
||||
|
||||
|
||||
def test_backwards_range_is_unreadable_not_empty():
|
||||
got, bad = jobrecon.expand_control_field('90123-90120')
|
||||
assert got == []
|
||||
assert bad == ['90123-90120']
|
||||
|
||||
|
||||
def test_material_text_is_returned_as_unreadable_never_dropped():
|
||||
"""The same title-block slots carry a material list on non-ticket
|
||||
sheets. A slot the tool cannot read is a slot whose pieces it cannot
|
||||
vouch for, so it comes back rather than vanishing."""
|
||||
got, bad = jobrecon.expand_control_field('PL 1/4" X 28" X 4\'-0"')
|
||||
assert got == []
|
||||
assert bad
|
||||
|
||||
|
||||
def test_empty_slot_is_neither_control_nor_complaint():
|
||||
assert jobrecon.expand_control_field(None) == ([], [])
|
||||
assert jobrecon.expand_control_field(' ') == ([], [])
|
||||
|
||||
|
||||
# ------------------------------------------------------- classify_sheet
|
||||
|
||||
def test_sheet_named_after_a_mark_is_a_ticket():
|
||||
kind, _why = jobrecon.classify_sheet(
|
||||
sheet('PNL101', '1 OF 2', ['90001']), {'PNL101'})
|
||||
assert kind == 'ticket'
|
||||
|
||||
|
||||
def test_stripped_page_two_is_still_a_ticket_page():
|
||||
"""A multi-page ticket's later sheets carry no title block: no design
|
||||
number, no length, no weight. An and-gate on both signals drops them
|
||||
and every finding on those pages disappears."""
|
||||
kind, why = jobrecon.classify_sheet(
|
||||
sheet('PNL101', '2 OF 2', [], design=None), {'PNL101'})
|
||||
assert kind == 'ticket'
|
||||
assert 'stripped' in why
|
||||
|
||||
|
||||
def test_page_numbered_title_block_is_a_ticket_even_when_unnamed():
|
||||
kind, _why = jobrecon.classify_sheet(
|
||||
sheet('SOMETHING ELSE', '1 OF 2', ['90001']), {'PNL101'})
|
||||
assert kind == 'ticket'
|
||||
|
||||
|
||||
def test_ordinary_drawing_is_not_a_ticket():
|
||||
kind, _why = jobrecon.classify_sheet(
|
||||
sheet('FOUNDATION PLAN', 'F1.0', [], design=None), {'PNL101'})
|
||||
assert kind == 'other'
|
||||
|
||||
|
||||
def test_missing_space_in_page_number_still_reads():
|
||||
assert jobrecon.page_of(sheet('PNL101', '1OF 1', [])) == (1, 1)
|
||||
|
||||
|
||||
# ------------------------------------------------------ page_set_status
|
||||
|
||||
def test_complete_page_set_is_ok():
|
||||
status, _msg = jobrecon.page_set_status([(1, 2), (2, 2)])
|
||||
assert status == 'ok'
|
||||
|
||||
|
||||
def test_missing_page_is_broken_and_names_the_page():
|
||||
status, msg = jobrecon.page_set_status([(1, 3), (3, 3)])
|
||||
assert status == 'broken'
|
||||
assert '2' in msg
|
||||
|
||||
|
||||
def test_no_page_markers_is_unjudged_not_ok():
|
||||
"""The criterion source is absent, so nothing was judged. 'ok' here is
|
||||
the exact 'ran and said fine while seeing nothing' failure."""
|
||||
status, msg = jobrecon.page_set_status([(None, None), (None, None)])
|
||||
assert status == 'unjudged'
|
||||
assert 'not be judged' in msg
|
||||
|
||||
|
||||
def test_pages_disagreeing_on_the_total_is_broken():
|
||||
status, _msg = jobrecon.page_set_status([(1, 6), (2, 2)])
|
||||
assert status == 'broken'
|
||||
|
||||
|
||||
# ------------------------------------------------------------ reconcile
|
||||
|
||||
def test_a_piece_with_no_ticket_is_named_individually():
|
||||
pieces = [piece('90001', 'PNL101'), piece('90002', 'PNL102')]
|
||||
sheets = [sheet('PNL101', '1 OF 1', ['90001'])]
|
||||
r = jobrecon.reconcile(pieces, sheets)
|
||||
assert r['totals']['pieces_uncovered'] == 1
|
||||
assert [x['control'] for x in r['pieces_without_ticket']] == ['90002']
|
||||
assert r['pieces_without_ticket'][0]['marks'] == ['PNL102']
|
||||
|
||||
|
||||
def test_a_ticket_for_a_piece_that_does_not_exist_is_named():
|
||||
pieces = [piece('90001', 'PNL101')]
|
||||
sheets = [sheet('PNL101', '1 OF 1', ['90001, 90099'])]
|
||||
r = jobrecon.reconcile(pieces, sheets)
|
||||
assert [x['control'] for x in r['controls_without_piece']] == ['90099']
|
||||
assert r['controls_without_piece'][0]['sheets'] == ['PNL101']
|
||||
|
||||
|
||||
def test_range_on_a_ticket_covers_every_piece_in_it():
|
||||
pieces = [piece('90120', 'BM100', design='RB1'),
|
||||
piece('90121', 'BM100', design='RB1'),
|
||||
piece('90122', 'BM100', design='RB1')]
|
||||
sheets = [sheet('BM100', '1 OF 1', ['90120-90122'], design='RB1')]
|
||||
r = jobrecon.reconcile(pieces, sheets)
|
||||
assert r['totals']['pieces_uncovered'] == 0
|
||||
assert r['mark_count_mismatch'] == []
|
||||
|
||||
|
||||
def test_count_mismatch_names_both_sides():
|
||||
pieces = [piece('90001', 'PNL101'), piece('90002', 'PNL101')]
|
||||
sheets = [sheet('PNL101', '1 OF 1', ['90001', '90050'])]
|
||||
r = jobrecon.reconcile(pieces, sheets)
|
||||
row = r['mark_count_mismatch'][0]
|
||||
assert row['mark'] == 'PNL101'
|
||||
assert row['issued'] == 2 and row['modelled'] == 2
|
||||
assert row['issued_only'] == ['90050']
|
||||
assert row['modelled_only'] == ['90002']
|
||||
|
||||
|
||||
def test_mark_with_no_ticket_at_all_is_its_own_finding():
|
||||
pieces = [piece('90001', 'PNL101'), piece('90002', 'PNL102')]
|
||||
sheets = [sheet('PNL101', '1 OF 1', ['90001'])]
|
||||
r = jobrecon.reconcile(pieces, sheets)
|
||||
assert [x['mark'] for x in r['marks_without_ticket']] == ['PNL102']
|
||||
assert r['marks_without_ticket'][0]['pieces'] == 1
|
||||
|
||||
|
||||
def test_stripped_page_two_does_not_read_as_a_missing_ticket():
|
||||
"""Regression for the classifier trap: page 2 has no design number and
|
||||
no control numbers. If it is dropped the set looks incomplete; if it is
|
||||
counted as a silent page the piece is still covered by page 1."""
|
||||
pieces = [piece('90001', 'PNL101')]
|
||||
sheets = [sheet('PNL101', '1 OF 2', ['90001']),
|
||||
sheet('PNL101', '2 OF 2', [], design=None)]
|
||||
r = jobrecon.reconcile(pieces, sheets)
|
||||
assert r['totals']['pieces_uncovered'] == 0
|
||||
assert r['page_set_problems'] == []
|
||||
assert len(r['silent_ticket_pages']) == 1
|
||||
|
||||
|
||||
def test_quantity_stated_but_not_printed_is_a_finding():
|
||||
"""A title block with a fixed number of control slots can state a
|
||||
quantity larger than it can print. The pieces past the last slot are
|
||||
unaccounted for on that ticket."""
|
||||
pieces = [piece('90001', 'PNL101'), piece('90002', 'PNL101')]
|
||||
sheets = [sheet('PNL101', '1 OF 1', ['90001', '90002'],
|
||||
total_required='9')]
|
||||
r = jobrecon.reconcile(pieces, sheets)
|
||||
assert r['quantity_disagreements'][0]['stated'] == 9
|
||||
assert r['quantity_disagreements'][0]['listed'] == 2
|
||||
|
||||
|
||||
def test_ticket_design_number_disagreeing_with_its_pieces():
|
||||
pieces = [piece('90001', 'PNL101', design='PNL4.0')]
|
||||
sheets = [sheet('PNL101', '1 OF 1', ['90001'], design='PNL2.0')]
|
||||
r = jobrecon.reconcile(pieces, sheets)
|
||||
row = r['design_disagreements'][0]
|
||||
assert row['printed'] == 'PNL2.0' and row['pieces_say'] == ['PNL4.0']
|
||||
|
||||
|
||||
def test_stated_sheet_count_disagreeing_with_sheets_found():
|
||||
pieces = [piece('90001', 'PNL101')]
|
||||
sheets = [sheet('PNL101', '1 OF 2', ['90001'], sheet_count='1'),
|
||||
sheet('PNL101', '2 OF 2', [], design=None)]
|
||||
r = jobrecon.reconcile(pieces, sheets)
|
||||
assert r['stated_page_counts'][0]['stated'] == 1
|
||||
assert r['stated_page_counts'][0]['found'] == 2
|
||||
|
||||
|
||||
def test_model_claiming_a_ticket_that_does_not_exist():
|
||||
pieces = [piece('90002', 'PNL102', ticket_created='Yes')]
|
||||
r = jobrecon.reconcile(pieces, [])
|
||||
assert r['state_contradictions'][0]['control'] == '90002'
|
||||
assert 'was created' in r['state_contradictions'][0]['problem']
|
||||
|
||||
|
||||
def test_piece_missing_from_a_ticket_that_exists_is_separated_out():
|
||||
"""The worst shape here, and the one a per-ticket checker cannot see:
|
||||
the ticket for the mark exists and passes every check on it, and this
|
||||
piece's control number is not printed on it."""
|
||||
pieces = [piece('90001', 'PNL101'),
|
||||
piece('90002', 'PNL101', ticket_created='Yes')]
|
||||
sheets = [sheet('PNL101', '1 OF 1', ['90001'])]
|
||||
r = jobrecon.reconcile(pieces, sheets)
|
||||
rows = {x['control']: x for x in r['pieces_without_ticket']}
|
||||
assert rows['90002']['mark_has_ticket'] is True
|
||||
assert 'does not print this control number' in \
|
||||
r['state_contradictions'][0]['problem']
|
||||
text = '\n'.join(jobrecon.format_report(r))
|
||||
assert 'PIECES MISSING FROM A TICKET THAT EXISTS -- 1 piece(s)' in text
|
||||
# The other bucket found nothing, and 'found nothing' is still printed --
|
||||
# by name, in the folded section, never as a heading over the word 'none'.
|
||||
assert 'PIECES WITH NO TICKET FOR THEIR MARK AT ALL -- ran, found none' \
|
||||
in text
|
||||
|
||||
|
||||
def test_piece_whose_mark_was_never_drawn_is_the_other_bucket():
|
||||
pieces = [piece('90002', 'PNL102', ticket_created='Yes')]
|
||||
r = jobrecon.reconcile(pieces, [])
|
||||
assert r['pieces_without_ticket'][0]['mark_has_ticket'] is False
|
||||
assert 'there is no ticket' in r['state_contradictions'][0]['problem']
|
||||
|
||||
|
||||
def test_model_saying_no_ticket_while_a_ticket_prints_the_piece():
|
||||
pieces = [piece('90001', 'PNL101', ticket_created='No')]
|
||||
sheets = [sheet('PNL101', '1 OF 1', ['90001'])]
|
||||
r = jobrecon.reconcile(pieces, sheets)
|
||||
assert r['state_contradictions'][0]['control'] == '90001'
|
||||
|
||||
|
||||
def test_unset_ticket_flag_is_not_a_contradiction():
|
||||
"""None is not False. 'the model never said' and 'the model said no' are
|
||||
different facts, and only the second is a contradiction."""
|
||||
pieces = [piece('90002', 'PNL102', ticket_created=None)]
|
||||
r = jobrecon.reconcile(pieces, [])
|
||||
assert r['state_contradictions'] == []
|
||||
|
||||
|
||||
def test_one_control_number_on_several_elements_is_reported():
|
||||
"""Three elements sharing a control number is one piece modelled three
|
||||
ways. It is counted once as a piece and reported once as a fact -- never
|
||||
filtered, whatever the families are called."""
|
||||
pieces = [piece('90200', 'BM100', family='BEAM', id=1),
|
||||
piece('90200', 'BM100', family='BEAM_FLAT (DO NOT USE)', id=2),
|
||||
piece('90200', 'BM100', family='BEAM_WARPED (DO NOT USE)', id=3)]
|
||||
sheets = [sheet('BM100', '1 OF 1', ['90200'])]
|
||||
r = jobrecon.reconcile(pieces, sheets)
|
||||
assert r['totals']['elements'] == 3
|
||||
assert r['totals']['pieces'] == 1
|
||||
assert r['totals']['pieces_uncovered'] == 0
|
||||
row = r['shared_controls'][0]
|
||||
assert row['count'] == 3
|
||||
assert 'BEAM_FLAT (DO NOT USE)' in row['families']
|
||||
|
||||
|
||||
def test_an_element_with_no_control_number_is_a_blind_spot_not_a_pass():
|
||||
pieces = [piece('90001', 'PNL101'), piece('', 'PNL199', id=4242)]
|
||||
sheets = [sheet('PNL101', '1 OF 1', ['90001'])]
|
||||
r = jobrecon.reconcile(pieces, sheets)
|
||||
assert r['totals']['pieces'] == 1
|
||||
assert r['pieces_without_control'][0]['id'] == 4242
|
||||
assert any('no control number' in b for b in r['blind_spots'])
|
||||
|
||||
|
||||
def test_unreadable_slot_becomes_a_blind_spot():
|
||||
pieces = [piece('90001', 'PNL101')]
|
||||
sheets = [sheet('PNL101', '1 OF 1', ['90001', 'SEE SCHEDULE'])]
|
||||
r = jobrecon.reconcile(pieces, sheets)
|
||||
assert r['unreadable_control_slots'][0]['token'] == 'SEE SCHEDULE'
|
||||
assert any('cannot read' in b for b in r['blind_spots'])
|
||||
|
||||
|
||||
def test_a_genuinely_clean_job_says_so_and_has_no_blind_spots():
|
||||
pieces = [piece('90001', 'PNL101'), piece('90002', 'PNL101')]
|
||||
sheets = [sheet('PNL101', '1 OF 1', ['90001, 90002'])]
|
||||
r = jobrecon.reconcile(pieces, sheets)
|
||||
assert r['totals']['pieces_uncovered'] == 0
|
||||
assert r['controls_without_piece'] == []
|
||||
assert r['mark_count_mismatch'] == []
|
||||
assert r['blind_spots'] == []
|
||||
|
||||
|
||||
def test_coverage_by_design_shows_a_wholly_unticketed_design():
|
||||
pieces = [piece('90001', 'PNL101', design='PNL1.0'),
|
||||
piece('90300', 'BM100', design='RB1'),
|
||||
piece('90301', 'BM101', design='RB1')]
|
||||
sheets = [sheet('PNL101', '1 OF 1', ['90001'])]
|
||||
r = jobrecon.reconcile(pieces, sheets)
|
||||
assert r['coverage_by_design']['RB1'] == {
|
||||
'pieces': 2, 'covered': 0, 'uncovered': ['90300', '90301']}
|
||||
assert r['coverage_by_design']['PNL1.0']['covered'] == 1
|
||||
|
||||
|
||||
def test_nothing_is_excluded_for_looking_out_of_scope():
|
||||
"""The original sin this module is built against: one unauthorised
|
||||
exclusion hid forty tickets and every check that ran came back clean.
|
||||
A piece whose family is literally named 'DO NOT USE' is still a piece."""
|
||||
pieces = [piece('90400', 'BM199', family='ZZ_SCRATCH (DO NOT USE)')]
|
||||
r = jobrecon.reconcile(pieces, [])
|
||||
assert [x['control'] for x in r['pieces_without_ticket']] == ['90400']
|
||||
|
||||
|
||||
# --------------------------------------------------------- format_report
|
||||
|
||||
def test_report_names_pieces_rather_than_counting_them():
|
||||
pieces = [piece('90002', 'PNL102')]
|
||||
text = '\n'.join(jobrecon.format_report(jobrecon.reconcile(pieces, [])))
|
||||
assert '90002' in text and 'PNL102' in text
|
||||
|
||||
|
||||
def test_report_says_what_it_could_not_judge():
|
||||
pieces = [piece('', 'PNL199', id=7)]
|
||||
text = '\n'.join(jobrecon.format_report(jobrecon.reconcile(pieces, [])))
|
||||
assert 'COULD NOT JUDGE' in text
|
||||
assert 'no control number' in text
|
||||
|
||||
|
||||
def test_report_truncation_admits_it_truncated():
|
||||
pieces = [piece(str(90000 + n), 'PNL1' + str(n)) for n in range(40)]
|
||||
text = '\n'.join(jobrecon.format_report(jobrecon.reconcile(pieces, []),
|
||||
limit=5))
|
||||
assert 'and 35 more (not printed)' in text
|
||||
|
||||
|
||||
def test_report_on_a_clean_job_says_nothing_was_unseen():
|
||||
pieces = [piece('90001', 'PNL101')]
|
||||
sheets = [sheet('PNL101', '1 OF 1', ['90001'])]
|
||||
text = '\n'.join(jobrecon.format_report(jobrecon.reconcile(pieces, sheets)))
|
||||
assert 'every piece and every sheet was read' in text
|
||||
|
||||
|
||||
# ------------------------------------------------------- the declared scope
|
||||
#
|
||||
# The reconciliation is the one entry point that sweeps the WHOLE job, so it
|
||||
# is the one where judging another engineer's pieces does the most damage:
|
||||
# every beam ticket becomes a disagreement, and the panels' real findings
|
||||
# drown in them. These tests pin the three states -- undeclared, declared and
|
||||
# filtering, declared and filtering nothing -- and above all that what was
|
||||
# set aside is COUNTED AND NAMED rather than missing.
|
||||
|
||||
from vibe import scope as scopelib
|
||||
|
||||
|
||||
def _mixed():
|
||||
pieces = [piece('1', 'PNL001'), piece('2', 'PNL002'),
|
||||
piece('3', 'XX001', design='XX1'),
|
||||
piece('4', 'YY001', design='YY1')]
|
||||
sheets = [sheet('PNL001', '1 OF 1', ['1']),
|
||||
sheet('XX001', '1 OF 1', ['3'], design='XX1'),
|
||||
sheet('GENERAL ARRANGEMENT', 'S-101', [], design='')]
|
||||
return pieces, sheets
|
||||
|
||||
|
||||
def test_no_scope_declared_reconciles_every_piece():
|
||||
"""An undeclared scope is not an empty scope. Nothing is set aside and
|
||||
the report still carries the line saying so."""
|
||||
pieces, sheets = _mixed()
|
||||
r = jobrecon.reconcile(pieces, sheets)
|
||||
assert r['totals']['pieces'] == 4
|
||||
assert r['out_of_scope']['declared'] is False
|
||||
assert r['out_of_scope']['piece_count'] == 0
|
||||
assert 'No scope is declared' in r['out_of_scope']['piece_line']
|
||||
|
||||
|
||||
def test_a_declared_scope_sets_the_other_engineers_pieces_aside():
|
||||
pieces, sheets = _mixed()
|
||||
r = jobrecon.reconcile(pieces, sheets,
|
||||
scope=scopelib.Scope(prefixes=('PNL',)))
|
||||
assert r['totals']['pieces'] == 2
|
||||
assert r['out_of_scope']['piece_count'] == 2
|
||||
assert r['out_of_scope']['pieces'] == [('XX', 1), ('YY', 1)]
|
||||
# And nothing about them is reported as a finding -- neither clean nor
|
||||
# failing. They are simply not in the arithmetic. PNL002 genuinely has
|
||||
# no ticket and is still reported; the other engineers' pieces are not.
|
||||
found = [m for row in r['pieces_without_ticket'] for m in row['marks']]
|
||||
assert found == ['PNL002']
|
||||
|
||||
|
||||
def test_the_out_of_scope_ticket_is_not_reported_as_an_orphan():
|
||||
"""The failure this prevents: another engineer's ticket printing control
|
||||
numbers whose pieces were filtered out reads as a fault on his work."""
|
||||
pieces, sheets = _mixed()
|
||||
r = jobrecon.reconcile(pieces, sheets,
|
||||
scope=scopelib.Scope(prefixes=('PNL',)))
|
||||
assert r['controls_without_piece'] == []
|
||||
assert r['out_of_scope']['sheet_count'] == 1
|
||||
|
||||
|
||||
def test_a_sheet_that_is_not_a_mark_is_never_treated_as_somebody_elses():
|
||||
pieces, sheets = _mixed()
|
||||
r = jobrecon.reconcile(pieces, sheets,
|
||||
scope=scopelib.Scope(prefixes=('PNL',)))
|
||||
kinds = dict(r['out_of_scope']['sheets'])
|
||||
assert 'GENERAL' not in kinds
|
||||
assert kinds == {'XX': 1}
|
||||
|
||||
|
||||
def test_a_piece_with_no_readable_mark_is_reconciled_not_dropped():
|
||||
pieces = [piece('1', 'PNL001'), piece('2', '')]
|
||||
r = jobrecon.reconcile(pieces, [], scope=scopelib.Scope(prefixes=('PNL',)))
|
||||
assert r['totals']['pieces'] == 2
|
||||
assert r['out_of_scope']['piece_count'] == 0
|
||||
assert 'no readable mark prefix' in r['out_of_scope']['piece_line']
|
||||
|
||||
|
||||
def test_the_report_prints_what_it_did_not_judge_as_its_own_section():
|
||||
pieces, sheets = _mixed()
|
||||
r = jobrecon.reconcile(pieces, sheets,
|
||||
scope=scopelib.Scope(prefixes=('PNL',),
|
||||
declared_by='somebody'))
|
||||
text = '\n'.join(jobrecon.format_report(r))
|
||||
assert 'WHAT THIS RUN DID NOT JUDGE -- OUTSIDE THE DECLARED SCOPE' in text
|
||||
assert 'XX 1' in text and 'YY 1' in text
|
||||
assert 'scope declared by somebody' in text
|
||||
# The two states stay separate: not judged, and could not be judged.
|
||||
assert 'WHAT THIS RUN COULD NOT JUDGE' in text
|
||||
|
||||
|
||||
def test_the_scope_section_is_printed_even_when_nothing_was_set_aside():
|
||||
pieces = [piece('1', 'PNL001')]
|
||||
r = jobrecon.reconcile(pieces, [], scope=scopelib.Scope(prefixes=('PNL',)))
|
||||
text = '\n'.join(jobrecon.format_report(r))
|
||||
assert 'WHAT THIS RUN DID NOT JUDGE -- OUTSIDE THE DECLARED SCOPE' in text
|
||||
assert 'none -- nothing was set aside' in text
|
||||
|
||||
|
||||
# --------------------------------------------------------- the folding page
|
||||
#
|
||||
# THE RULE, and whose it is: the owner, 2026-08-30, now standing across the
|
||||
# tab. The answer is the headline and is never folded; everything else is
|
||||
# collapsed; a section that carries a problem opens itself; a folded header
|
||||
# still says what it holds. The contract the reader gets is that NOTHING OPEN
|
||||
# BELOW THE HEADLINE MEANS NOTHING NEEDS HIM -- so the tests that matter most
|
||||
# here are the two that guard it in both directions: a clean run opens
|
||||
# nothing, and a run with a finding opens exactly that finding.
|
||||
#
|
||||
# The fold itself is `vibe.foldreport`'s and is tested there. These are about
|
||||
# which sections this report has, what is in the headline, and what may never
|
||||
# be folded into silence.
|
||||
|
||||
from vibe import foldreport as fold
|
||||
|
||||
|
||||
def _clean():
|
||||
return jobrecon.reconcile([piece('90001', 'PNL101')],
|
||||
[sheet('PNL101', '1 OF 1', ['90001'])])
|
||||
|
||||
|
||||
def _by_key(sections):
|
||||
return dict((s.key, s) for s in sections)
|
||||
|
||||
|
||||
def _md(section):
|
||||
return '\n'.join(b[1] for b in section.blocks if b[0] == fold.MD)
|
||||
|
||||
|
||||
def test_the_headline_is_first_and_is_never_folded():
|
||||
sections = jobrecon.render(_clean())
|
||||
assert sections[0].headline is True
|
||||
assert sections[0].is_open is True
|
||||
text = _md(sections[0])
|
||||
assert 'JOB RECONCILIATION' in text
|
||||
# The verdict is the NUMBERS, not the findings.
|
||||
assert '1 of 1 pieces are printed on a ticket' in text
|
||||
assert '1 of 1 marks have a ticket' in text
|
||||
|
||||
|
||||
def test_a_clean_run_opens_nothing_below_the_headline():
|
||||
"""The whole contract. If this fails the page is lying to the reader."""
|
||||
sections = jobrecon.render(_clean())
|
||||
assert [s.key for s in sections[1:] if s.is_open] == []
|
||||
|
||||
|
||||
def test_a_finding_opens_its_own_section_and_says_why():
|
||||
pieces = [piece('90001', 'PNL101'),
|
||||
piece('90002', 'PNL101', ticket_created='Yes')]
|
||||
sections = jobrecon.render(
|
||||
jobrecon.reconcile(pieces, [sheet('PNL101', '1 OF 1', ['90001'])]))
|
||||
opened = _by_key(fold.sections_with_problems(sections[1:]))
|
||||
assert 'missing_from_ticket' in opened
|
||||
# A section that opens has to name what is wrong, not merely be open.
|
||||
assert '1 piece(s)' in opened['missing_from_ticket'].problem
|
||||
|
||||
|
||||
def test_the_headline_carries_the_count_of_every_finding():
|
||||
"""A headline saying 'clean' over an open section below it is the page
|
||||
this replaces. The counts are built from the same list the sections are,
|
||||
so the two cannot disagree."""
|
||||
pieces = [piece('90002', 'PNL102'), piece('90003', 'PNL103')]
|
||||
recon = jobrecon.reconcile(pieces, [])
|
||||
sections = jobrecon.render(recon)
|
||||
tables = [b for b in sections[0].blocks if b[0] == fold.TABLE]
|
||||
counted = dict((row[0], row[1]) for row in tables[0][2])
|
||||
opened = [s for s in sections[1:] if s.problem and s.key != 'blind_spots']
|
||||
assert len(counted) == len(opened)
|
||||
assert counted['PIECES WITH NO TICKET FOR THEIR MARK AT ALL'] == 2
|
||||
|
||||
|
||||
def test_every_check_is_either_a_section_or_named_as_having_found_nothing():
|
||||
"""Folded is not gone, and a check that passed is not gone either:
|
||||
'ran and found none' and 'never ran' have to stay tellable apart."""
|
||||
recon = _clean()
|
||||
sections = jobrecon.render(recon)
|
||||
keys = set(s.key for s in sections)
|
||||
printed = _md(_by_key(sections)['clear'])
|
||||
for key, title, _rows, _phrase, _line, _lead in jobrecon.findings(recon):
|
||||
assert key in keys or title in printed
|
||||
|
||||
|
||||
def test_a_routine_section_is_folded_and_its_header_says_what_it_holds():
|
||||
coverage = _by_key(jobrecon.render(_clean()))['coverage']
|
||||
assert coverage.is_open is False
|
||||
assert '1 design(s)' in coverage.summary
|
||||
|
||||
|
||||
def test_the_out_of_scope_header_carries_its_counts_while_folded():
|
||||
"""Read with the fold SHUT. A header saying 'details' would soften
|
||||
exactly the distinction this section exists to keep."""
|
||||
pieces = [piece('1', 'PNL001'), piece('2', 'BM001', design='BM1'),
|
||||
piece('3', 'BM002', design='BM1'),
|
||||
piece('4', 'SS001', design='SS1')]
|
||||
recon = jobrecon.reconcile(pieces, [],
|
||||
scope=scopelib.Scope(prefixes=('PNL',)))
|
||||
section = _by_key(jobrecon.render(recon))['out_of_scope']
|
||||
assert section.is_open is False
|
||||
assert 'OUT OF SCOPE: 3 model element(s)' in section.summary
|
||||
assert 'BM 2' in section.summary and 'SS 1' in section.summary
|
||||
|
||||
|
||||
def test_the_blind_spot_header_carries_its_count_while_folded():
|
||||
recon = jobrecon.reconcile([piece('90001', 'PNL101'),
|
||||
piece('', 'PNL199', id=7)],
|
||||
[sheet('PNL101', '1 OF 1', ['90001'])])
|
||||
section = _by_key(jobrecon.render(recon))['blind_spots']
|
||||
assert section.is_open is False
|
||||
assert '1 blind spot(s)' in section.summary
|
||||
|
||||
|
||||
def test_could_not_see_and_did_not_judge_are_never_the_same_section():
|
||||
recon = jobrecon.reconcile([piece('1', 'PNL001'),
|
||||
piece('2', 'BM001', design='BM1')],
|
||||
[], scope=scopelib.Scope(prefixes=('PNL',)))
|
||||
keys = [s.key for s in jobrecon.render(recon)]
|
||||
assert 'out_of_scope' in keys and 'blind_spots' in keys
|
||||
|
||||
|
||||
def test_a_refusal_is_a_problem_and_opens_itself():
|
||||
"""Nothing reconciled is not a clean job. It opens the blind spots and
|
||||
it is said again in the headline, because a refusal reported quietly
|
||||
reads exactly like a pass."""
|
||||
recon = jobrecon.reconcile([], [])
|
||||
recon['blind_spots'].insert(
|
||||
0, 'the role member.control_number is not mapped -- no piece could '
|
||||
'be identified, so NOTHING was reconciled.')
|
||||
sections = jobrecon.render(recon)
|
||||
spots = _by_key(sections)['blind_spots']
|
||||
assert spots.is_open is True
|
||||
assert 'NOTHING WAS RECONCILED' in spots.problem
|
||||
headline = _md(sections[0])
|
||||
assert 'NOTHING WAS RECONCILED' in headline
|
||||
assert 'Nothing needs you' not in headline
|
||||
|
||||
|
||||
def test_an_empty_model_is_a_refusal_even_with_no_note_about_it():
|
||||
"""Structural, not only from the gatherer's note: a run can reconcile
|
||||
nothing without anybody having written a note saying so."""
|
||||
assert jobrecon.blind_refusal(jobrecon.reconcile([], [])) is not None
|
||||
assert jobrecon.blind_refusal(_clean()) is None
|
||||
|
||||
|
||||
def test_sheets_that_no_page_classified_as_a_ticket_is_a_refusal():
|
||||
recon = jobrecon.reconcile([piece('90001', 'PNL101')],
|
||||
[sheet('GENERAL ARRANGEMENT', 'S-101', [],
|
||||
design='')])
|
||||
assert 'NO SHEET WAS RECOGNISED AS A TICKET' in \
|
||||
jobrecon.blind_refusal(recon)
|
||||
|
||||
|
||||
def test_a_refusal_does_not_let_the_clean_checks_read_as_a_pass():
|
||||
recon = jobrecon.reconcile([], [])
|
||||
clear = _by_key(jobrecon.render(recon))['clear']
|
||||
assert 'not a pass' in clear.summary
|
||||
|
||||
|
||||
def test_the_page_caps_a_long_list_and_the_file_does_not():
|
||||
pieces = [piece(str(90000 + n), 'PNL1' + str(n)) for n in range(40)]
|
||||
recon = jobrecon.reconcile(pieces, [])
|
||||
page = fold.text(jobrecon.render(recon, limit=5))
|
||||
assert 'and 35 more (not printed)' in page
|
||||
# The file is the copy nothing is lost from: no cap, nothing folded.
|
||||
whole = '\n'.join(jobrecon.format_report(recon))
|
||||
assert 'not printed' not in whole
|
||||
assert '90039' in whole
|
||||
|
||||
|
||||
def test_the_file_prints_the_folded_sections_too():
|
||||
"""A folded section that will not open must cost a file open, never a
|
||||
sentence -- so `format_report` shows everything by default."""
|
||||
text = '\n'.join(jobrecon.format_report(_clean()))
|
||||
assert 'COVERAGE BY DESIGN' in text
|
||||
assert 'PAGE-SET INVENTORY' in text
|
||||
assert 'CHECKS THAT RAN AND FOUND NOTHING' in text
|
||||
|
||||
|
||||
def test_what_was_swept_folds_apart_from_what_could_not_be_seen():
|
||||
"""`jobreconscan` hands over one list of notes: 'I opened these
|
||||
collectors' is an inventory, 'I could not see X' is a blind spot, and the
|
||||
report folds the first while the second can open itself."""
|
||||
recon = _clean()
|
||||
recon['searched'] = ['categories searched: OST_Walls.']
|
||||
section = _by_key(jobrecon.render(recon))['searched']
|
||||
assert section.is_open is False
|
||||
assert 'OST_Walls' in _md(section)
|
||||
|
||||
|
||||
def test_the_page_names_the_file_the_whole_report_went_to():
|
||||
sections = _by_key(jobrecon.render(_clean(), full_path='X/Y.txt'))
|
||||
assert 'X/Y.txt' in _md(sections['run'])
|
||||
|
||||
|
||||
def test_no_header_carries_an_entity_the_panel_will_not_decode():
|
||||
"""This page writes ASCII '--' in a title, a summary and a problem, so the
|
||||
one string is right in the panel and in the .md alike. (`foldreport` does
|
||||
restore the reports' entities in those three since 2026-08-30, so an
|
||||
`—` would no longer leak -- but this page does not need one.)"""
|
||||
pieces = [piece('1', 'PNL001'), piece('2', 'BM001', design='BM1')]
|
||||
recon = jobrecon.reconcile(pieces, [],
|
||||
scope=scopelib.Scope(prefixes=('PNL',)))
|
||||
recon['timing'] = {'total_s': 1, 'pieces_s': 1, 'sheets_s': 0}
|
||||
for section in jobrecon.render(recon):
|
||||
for value in (section.title, section.summary, section.problem):
|
||||
assert '&' not in (value or '')
|
||||
|
||||
|
||||
def test_every_section_key_is_unique_on_the_page():
|
||||
"""They become element ids for the toggle. Two of the same and one fold
|
||||
opens the other one's body."""
|
||||
pieces = [piece('1', 'PNL001'), piece('2', 'BM001', design='BM1')]
|
||||
recon = jobrecon.reconcile(pieces, [],
|
||||
scope=scopelib.Scope(prefixes=('PNL',)))
|
||||
keys = [s.key for s in jobrecon.render(recon)]
|
||||
assert len(keys) == len(set(keys))
|
||||
|
||||
|
||||
def test_the_panel_html_renders_and_a_problem_section_needs_no_script():
|
||||
"""The toggle is IE5-era DOM and has not been exercised inside Revit, so
|
||||
a section with a problem in it must render OPEN as plain HTML."""
|
||||
sections = jobrecon.render(jobrecon.reconcile([piece('90002', 'PNL102')],
|
||||
[]))
|
||||
html = fold.html(sections[1:])
|
||||
# Every newline starts a new entry in pyRevit's panel stream. So: none.
|
||||
assert '\n' not in html
|
||||
assert 'display:block' in html
|
||||
assert '<details' not in html
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,517 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for tools/knowledge_drift.py.
|
||||
|
||||
The checker is host CPython (it must be -- it parses IronPython source with
|
||||
`ast` and reads YAML with PyYAML), so it is loaded by path here rather than
|
||||
imported as a package, the same trick conftest.py uses for `vibe`.
|
||||
|
||||
What is worth testing about a drift checker is not that it finds things. It
|
||||
is that it does not find things that are not there, and that it ADMITS what it
|
||||
could not look at -- a false finding and a silently-missed blind spot both
|
||||
end the same way, with somebody deciding the tool is not worth reading.
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))))
|
||||
TOOLS = os.path.join(ROOT, 'tools')
|
||||
if TOOLS not in sys.path:
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
yaml = pytest.importorskip('yaml')
|
||||
knowledge_drift = pytest.importorskip('knowledge_drift')
|
||||
kd = knowledge_drift
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- flattening
|
||||
|
||||
def test_flatten_emits_containers_as_well_as_leaves():
|
||||
# A container IS a read target: `strands.area_in2` is fetched whole and
|
||||
# walked, so both sides have to be able to name it.
|
||||
paths = kd.flatten({'a': {'b': 1, 'c': {'d': 2}}, 'e': 3})
|
||||
assert set(paths) == {'a', 'a.b', 'a.c', 'a.c.d', 'e'}
|
||||
|
||||
|
||||
def test_flatten_ignores_non_string_keys():
|
||||
assert kd.flatten({1: 'x', 'a': 2}) == ['a']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- reader tiers
|
||||
|
||||
@pytest.mark.parametrize('rel, tier', [
|
||||
('WillTools.extension/lib/vibe/ticketchecks.py', 'behaviour'),
|
||||
('WillTools.extension/lib/vibe/configdump.py', 'surface'),
|
||||
('WillTools.extension/lib/vibe/configmodel.py', 'surface'),
|
||||
# `tier_of` matches the `ProjectConfig.pushbutton` segment and nothing
|
||||
# around it, so this case survives the ribbon moving -- but the path is
|
||||
# written out as it really is (2026-08-30) rather than as it once was,
|
||||
# because a reader who cannot find the file stops trusting the test.
|
||||
('WillTools.extension/WillTools.tab/Setup.panel/Config.pulldown/'
|
||||
'ProjectConfig.pushbutton/script.py', 'surface'),
|
||||
('WillTools.extension/tests/test_liftexport.py', 'test'),
|
||||
('panellift/tests/conftest.py', 'test'),
|
||||
('panellift/src/panellift/checks.py', 'behaviour'),
|
||||
])
|
||||
def test_tier_of(rel, tier):
|
||||
assert kd.tier_of(rel) == tier
|
||||
|
||||
|
||||
def test_tier_of_handles_windows_separators():
|
||||
assert kd.tier_of(r'WillTools.extension\lib\vibe\configmodel.py') \
|
||||
== 'surface'
|
||||
|
||||
|
||||
# --------------------------------------------------------------- the detector
|
||||
|
||||
def _scan(source, rel='WillTools.extension/lib/vibe/thing.py'):
|
||||
import ast
|
||||
scan = kd.Scan(rel)
|
||||
scan.visit(ast.parse(source))
|
||||
return scan
|
||||
|
||||
|
||||
def test_literal_get_is_a_reader():
|
||||
scan = _scan("x = K.get(know, 'rails.strand_datum', 'left_rail')")
|
||||
assert [(r.path, r.default) for r in scan.readers] == \
|
||||
[('rails.strand_datum', "'left_rail'")]
|
||||
assert scan.blind == []
|
||||
|
||||
|
||||
def test_get_with_no_default_records_none_not_missing():
|
||||
# `get(k, 'a')` and `get(k, 'a', None)` mean the same thing, and reporting
|
||||
# them as disagreeing fallbacks would be a finding about nothing.
|
||||
scan = _scan("K.get(know, 'a.b')\nK.get(know, 'a.b', None)")
|
||||
assert {r.default for r in scan.readers} == {'None'}
|
||||
|
||||
|
||||
def test_a_get_on_something_that_is_not_knowledge_is_ignored():
|
||||
scan = _scan("d = {}\nd.get('a.b', 1)\nrow.get('handling.rig_mode')")
|
||||
assert scan.readers == []
|
||||
|
||||
|
||||
def test_non_literal_path_becomes_a_blind_spot_not_a_reader():
|
||||
scan = _scan("def f(know, path):\n return K.get(know, path, 1)\n")
|
||||
assert scan.readers == []
|
||||
assert scan.blind and scan.blind[0][2] == 'non-literal path'
|
||||
|
||||
|
||||
def test_a_local_string_variable_is_resolved():
|
||||
scan = _scan(
|
||||
"def f(know):\n"
|
||||
" key = 'handling.fast_lifter_box'\n"
|
||||
" return K.get(know, key, True)\n")
|
||||
assert [r.path for r in scan.readers] == ['handling.fast_lifter_box']
|
||||
assert scan.blind == []
|
||||
|
||||
|
||||
def test_a_ternary_between_two_literals_resolves_to_both():
|
||||
# liftexport._fast_box. Refusing to follow it accused both keys of being
|
||||
# read only by the config screen.
|
||||
scan = _scan(
|
||||
"def f(know, batch):\n"
|
||||
" key = 'handling.a' if batch else 'handling.b'\n"
|
||||
" return K.get(know, key, not batch)\n")
|
||||
assert sorted(r.path for r in scan.readers) == ['handling.a', 'handling.b']
|
||||
|
||||
|
||||
def test_a_variable_reassigned_to_something_opaque_is_not_resolved():
|
||||
# Half-knowing is worse than not knowing: a confident finding about the
|
||||
# wrong key is unfalsifiable from the report.
|
||||
scan = _scan(
|
||||
"def f(know, other):\n"
|
||||
" key = 'handling.a'\n"
|
||||
" key = other\n"
|
||||
" return K.get(know, key, 1)\n")
|
||||
assert scan.readers == []
|
||||
assert scan.blind
|
||||
|
||||
|
||||
def test_locals_do_not_leak_between_functions():
|
||||
scan = _scan(
|
||||
"def a(know):\n"
|
||||
" key = 'handling.a'\n"
|
||||
" return K.get(know, key)\n"
|
||||
"def b(know, key):\n"
|
||||
" return K.get(know, key)\n")
|
||||
assert [r.path for r in scan.readers] == ['handling.a']
|
||||
assert len(scan.blind) == 1
|
||||
|
||||
|
||||
def test_concatenated_path_is_a_blind_prefix():
|
||||
scan = _scan("def f(know, k):\n return K.get(know, 'nomenclature.' + k)")
|
||||
assert scan.blind[0][2] == 'concatenated path'
|
||||
assert scan.blind[0][3] == 'nomenclature.<var>'
|
||||
|
||||
|
||||
def test_a_wrapper_body_does_not_blind_its_own_prefix():
|
||||
# `_nomen` is resolved at its call sites, so treating the concat inside it
|
||||
# as a blind prefix would hide every nomenclature key behind the very
|
||||
# helper that makes them visible.
|
||||
scan = _scan(
|
||||
"def _nomen(know, key, default):\n"
|
||||
" return K.get(know, 'nomenclature.' + key, default)\n")
|
||||
assert scan.blind == []
|
||||
|
||||
|
||||
def test_helper_call_sites_are_resolved():
|
||||
scan = _scan("_nomen(know, 'design_folder', '')")
|
||||
assert [(r.path, r.default) for r in scan.readers] == \
|
||||
[('nomenclature.design_folder', "''")]
|
||||
|
||||
|
||||
def test_param_names_call_sites_are_resolved():
|
||||
scan = _scan("param_names(know, 'sheet.fc')")
|
||||
assert [r.path for r in scan.readers] == ['parameters.sheet.fc']
|
||||
|
||||
|
||||
def test_helper_with_a_variable_key_is_a_blind_spot():
|
||||
scan = _scan("def f(know, role):\n return param_names(know, role)")
|
||||
assert scan.readers == []
|
||||
assert 'non-literal key into param_names' in scan.blind[0][2]
|
||||
|
||||
|
||||
def test_config_rows_are_non_covering_readers():
|
||||
scan = _scan("_s('handling.rig_mode', 'Lift type', 's', 'choice', 'job')")
|
||||
assert len(scan.readers) == 1
|
||||
assert scan.readers[0].path == 'handling.rig_mode'
|
||||
assert scan.readers[0].covering is False
|
||||
|
||||
|
||||
def test_bare_get_counts_only_inside_knowledge_py():
|
||||
src = "def is_strand(k, n):\n return get(k, 'families.strand_include')"
|
||||
assert _scan(src).readers == []
|
||||
inside = _scan(src, 'WillTools/lib/vibe/knowledge.py')
|
||||
assert [r.path for r in inside.readers] == ['families.strand_include']
|
||||
|
||||
|
||||
def test_role_tuples_expand_into_parameter_readers():
|
||||
import ast
|
||||
tree = ast.parse("PARAMETER_ROLES = ('panel.length', 'sheet.fc')")
|
||||
readers = kd._role_tuples(tree, 'x.py')
|
||||
assert sorted(r.path for r in readers) == \
|
||||
['parameters.panel.length', 'parameters.sheet.fc']
|
||||
|
||||
|
||||
# ------------------------------------------------- unread / coverage semantics
|
||||
|
||||
def _unread(declared, read, covering=None):
|
||||
covering = set(read) if covering is None else set(covering)
|
||||
out = []
|
||||
kd._collect_unread(kd._paths_to_tree(sorted(declared)), '',
|
||||
set(read), covering, out)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def test_a_covering_read_of_a_branch_silences_everything_under_it():
|
||||
# `strands.area_in2` is fetched whole and walked; every size in it is read.
|
||||
assert _unread(['s.area', 's.area.half', 's.area.three_eighths'],
|
||||
['s.area']) == []
|
||||
|
||||
|
||||
def test_an_unread_branch_is_reported_once_not_leaf_by_leaf():
|
||||
# A 200-entry table nobody reads must be one line. A checker that prints
|
||||
# 200 gets ignored, and then the real findings go unread with them.
|
||||
assert _unread(['t.a', 't.a.x', 't.a.y', 't.a.z'], []) == ['t']
|
||||
|
||||
|
||||
def test_the_tool_descends_to_the_exact_unread_sibling():
|
||||
assert _unread(['n.a', 'n.b', 'n.c'], ['n.a']) == ['n.b', 'n.c']
|
||||
|
||||
|
||||
def test_a_branch_only_NAMED_by_the_config_screen_is_not_split_into_leaves():
|
||||
# One finding about the branch (surface-only) says the whole thing.
|
||||
assert _unread(['o', 'o.a', 'o.b'], ['o'], covering=set()) == []
|
||||
|
||||
|
||||
def test_a_named_branch_with_one_read_leaf_still_reports_its_siblings():
|
||||
assert _unread(['t', 't.a', 't.b'], ['t', 't.a'], covering={'t.a'}) \
|
||||
== ['t.b']
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ allowlist
|
||||
|
||||
def _allow(tmp_path, text, monkeypatch):
|
||||
path = tmp_path / 'allow.txt'
|
||||
path.write_text(text, encoding='utf-8')
|
||||
monkeypatch.setattr(kd, 'ALLOWLIST', str(path))
|
||||
return kd.load_allowlist()
|
||||
|
||||
|
||||
def test_allowlist_reads_a_well_formed_entry(tmp_path, monkeypatch):
|
||||
entries, errors = _allow(tmp_path,
|
||||
'# a comment\n\nunread a.b -- derived, on purpose\n',
|
||||
monkeypatch)
|
||||
assert errors == []
|
||||
assert entries == {('unread', 'a.b'): 'derived, on purpose'}
|
||||
|
||||
|
||||
def test_an_exception_with_no_reason_is_refused(tmp_path, monkeypatch):
|
||||
# An unexplained suppression is how a checker quietly stops checking.
|
||||
entries, errors = _allow(tmp_path, 'unread a.b\n', monkeypatch)
|
||||
assert entries == {}
|
||||
assert errors and 'needs a reason' in errors[0]
|
||||
|
||||
|
||||
def test_an_empty_reason_is_refused(tmp_path, monkeypatch):
|
||||
_entries, errors = _allow(tmp_path, 'unread a.b -- \n', monkeypatch)
|
||||
assert errors
|
||||
|
||||
|
||||
def test_an_unknown_category_is_refused(tmp_path, monkeypatch):
|
||||
_entries, errors = _allow(tmp_path, 'whoops a.b -- why\n', monkeypatch)
|
||||
assert errors and 'category must be one of' in errors[0]
|
||||
|
||||
|
||||
def test_a_missing_allowlist_is_not_an_error(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(kd, 'ALLOWLIST', str(tmp_path / 'nope.txt'))
|
||||
assert kd.load_allowlist() == ({}, [])
|
||||
|
||||
|
||||
def test_the_shipped_allowlist_parses():
|
||||
entries, errors = kd.load_allowlist()
|
||||
assert errors == [], errors
|
||||
assert isinstance(entries, dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- end to end
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def result():
|
||||
return kd.analyse()
|
||||
|
||||
|
||||
def test_it_runs_on_this_tree_and_finds_the_public_layers(result):
|
||||
assert 'knowledge.DEFAULTS' in result['layers']
|
||||
assert 'catalogue.yaml' in result['layers']
|
||||
assert result['counts']['declared'] > 100
|
||||
assert result['counts']['read'] > 50
|
||||
|
||||
|
||||
def test_no_python_file_failed_to_parse(result):
|
||||
# A file that will not parse is a file whose readers are invisible. It is
|
||||
# reported, never skipped quietly -- but on this tree there should be none.
|
||||
assert result['parse_fail'] == []
|
||||
|
||||
|
||||
def test_the_families_tables_are_seen_as_read(result):
|
||||
# knowledge.is_strand calls its own `get` bare. Missing that reported the
|
||||
# most-read keys in the tool as read by nothing.
|
||||
unread = {row[0] for row in result['findings']['unread']}
|
||||
for path in ('families.strand_include', 'families.strand_exclude',
|
||||
'families.lifter_include', 'catalogue.inserts',
|
||||
'lifters.catalogue_alias', 'strands.area_in2'):
|
||||
assert path not in unread, path
|
||||
|
||||
|
||||
def test_nomenclature_keys_are_judged_not_hidden(result):
|
||||
# _nomen's own body must not blind the whole nomenclature subtree.
|
||||
assert 'nomenclature' not in result['dyn_prefixes']
|
||||
|
||||
|
||||
def test_fixed_drift_does_not_come_back_as_a_finding(result):
|
||||
# Four of the seven known instances were fixed before this tool existed.
|
||||
# They are the regression test that its readers-side is not blind.
|
||||
everything = {row[0] for rows in result['findings'].values()
|
||||
for row in rows}
|
||||
for path in ('drawings.annotation_covered_by_parent',
|
||||
'drawings.called_out_on_detail',
|
||||
'rails.left_rail_is_fixed',
|
||||
'catalogue.safety_factors.tension_fos'):
|
||||
assert path not in everything, path
|
||||
|
||||
|
||||
def test_a_test_only_reader_is_graded_as_such():
|
||||
# weight_three_way's shape: production never touches it.
|
||||
readers = [kd.Reader('a.b', 'None', 'WillTools.extension/tests/t_x.py',
|
||||
1, 'literal')]
|
||||
assert readers[0].tier == 'test'
|
||||
|
||||
|
||||
def test_the_report_always_states_what_it_could_not_see(result):
|
||||
buf = io.StringIO()
|
||||
kd.report(result, buf)
|
||||
text = buf.getvalue()
|
||||
assert 'WHAT THIS TOOL COULD NOT SEE' in text
|
||||
assert '"Could not judge" is not "judged and found nothing"' in text
|
||||
assert 'suppressed by' in text
|
||||
|
||||
|
||||
def test_the_blind_only_report_is_still_the_blind_report(result):
|
||||
buf = io.StringIO()
|
||||
kd.report(result, buf, blind_only=True)
|
||||
assert 'WHAT THIS TOOL COULD NOT SEE' in buf.getvalue()
|
||||
|
||||
|
||||
def test_the_report_never_names_a_private_file(result):
|
||||
# The plant and job layers are gitignored. Their KEYS may be reported;
|
||||
# their paths, filenames and values may not -- this output is meant to be
|
||||
# pasted into an issue.
|
||||
buf = io.StringIO()
|
||||
kd.report(result, buf, show_all=True)
|
||||
text = buf.getvalue()
|
||||
for banned in ('knowledge/plant', 'knowledge/job', 'knowledge\\plant',
|
||||
'knowledge\\job', '.rvt', 'C:\\Users'):
|
||||
assert banned not in text, banned
|
||||
|
||||
|
||||
def test_an_allowlist_entry_suppresses_and_is_still_counted(tmp_path,
|
||||
monkeypatch):
|
||||
# The tool has to be able to reach a clean state on a codebase with real
|
||||
# exceptions -- and the run has to keep saying how many it is sitting on,
|
||||
# or a shrinking report reads as a shrinking problem.
|
||||
baseline = kd.analyse()
|
||||
unread = [row[0] for row in baseline['findings']['unread']]
|
||||
if not unread:
|
||||
pytest.skip('nothing unread on this tree to exempt')
|
||||
path = tmp_path / 'a.txt'
|
||||
path.write_text('unread %s -- exercised by the test suite\n' % unread[0],
|
||||
encoding='utf-8')
|
||||
monkeypatch.setattr(kd, 'ALLOWLIST', str(path))
|
||||
after = kd.analyse()
|
||||
assert unread[0] not in [row[0] for row in after['findings']['unread']]
|
||||
assert after['suppressed']['unread'] == \
|
||||
[(unread[0], 'exercised by the test suite')]
|
||||
buf = io.StringIO()
|
||||
kd.report(after, buf)
|
||||
assert '1 suppressed by' in buf.getvalue()
|
||||
|
||||
|
||||
def test_an_entry_that_matches_nothing_is_reported_stale(tmp_path,
|
||||
monkeypatch):
|
||||
# A key that was fixed or deleted must not leave a permanent blindfold.
|
||||
path = tmp_path / 'a.txt'
|
||||
path.write_text('unread no.such.key.anywhere -- gone\n', encoding='utf-8')
|
||||
monkeypatch.setattr(kd, 'ALLOWLIST', str(path))
|
||||
res = kd.analyse()
|
||||
assert res['stale_allow'] == [('unread', 'no.such.key.anywhere')]
|
||||
buf = io.StringIO()
|
||||
kd.report(res, buf)
|
||||
assert 'STALE allowlist entries' in buf.getvalue()
|
||||
|
||||
|
||||
def test_a_malformed_allowlist_exits_two_not_one(tmp_path, monkeypatch,
|
||||
capsys):
|
||||
# Exit 1 means "drift found"; a broken exception file is a different
|
||||
# problem and must not be mistaken for one.
|
||||
path = tmp_path / 'a.txt'
|
||||
path.write_text('unread a.b\n', encoding='utf-8')
|
||||
monkeypatch.setattr(kd, 'ALLOWLIST', str(path))
|
||||
assert kd.main([]) == 2
|
||||
assert 'ALLOWLIST ERROR' in capsys.readouterr().out
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------- honouring the env vars
|
||||
#
|
||||
# `machine-setup.md` in the private repo recommended pointing
|
||||
# WILLTOOLS_KNOWLEDGE / WILLTOOLS_JOBS at a private clone and said this tool
|
||||
# did not actually honour them -- "a recommendation, not implemented". These
|
||||
# pin the fix: the same env vars every other reader of these layers uses
|
||||
# (`vibe.plantpath`, `vibe.designspec.root_dir`) now decide where THIS tool
|
||||
# looks too, reusing `vibe.plantpath.plant_root()` rather than a second guess.
|
||||
|
||||
def test_plant_root_defaults_to_the_checkout_folder_when_unset(monkeypatch):
|
||||
monkeypatch.delenv(kd.plantpath.PLANT_ENV, raising=False)
|
||||
assert kd._plant_root() == os.path.join(kd.ROOT, 'knowledge', 'plant')
|
||||
|
||||
|
||||
def test_plant_root_honours_willtools_knowledge_set_to_a_folder(
|
||||
tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(kd.plantpath.PLANT_ENV, str(tmp_path))
|
||||
assert kd._plant_root() == str(tmp_path)
|
||||
|
||||
|
||||
def test_plant_root_honours_willtools_knowledge_set_to_a_file(
|
||||
tmp_path, monkeypatch):
|
||||
# The single-file arrangement: the variable has always been allowed to
|
||||
# name the machine-wide file directly, and its DIRECTORY is the root --
|
||||
# see `vibe.plantpath.plant_root`'s own docstring for why.
|
||||
plant_file = tmp_path / 'knowledge.yaml'
|
||||
monkeypatch.setenv(kd.plantpath.PLANT_ENV, str(plant_file))
|
||||
assert kd._plant_root() == str(tmp_path)
|
||||
|
||||
|
||||
def test_jobs_root_defaults_to_the_checkout_folder_when_unset(monkeypatch):
|
||||
monkeypatch.delenv(kd.joblocate.JOBS_ENV, raising=False)
|
||||
assert kd._jobs_root() == os.path.join(kd.ROOT, 'knowledge', 'job')
|
||||
|
||||
|
||||
def test_jobs_root_honours_willtools_jobs_when_set(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(kd.joblocate.JOBS_ENV, str(tmp_path))
|
||||
assert kd._jobs_root() == str(tmp_path)
|
||||
|
||||
|
||||
def test_plant_files_walks_the_env_pointed_root_not_the_checkout_one(
|
||||
tmp_path, monkeypatch):
|
||||
"""The regression this exists for: an env var naming a folder OUTSIDE
|
||||
this checkout used to be silently ignored -- the walk never left
|
||||
`<repo>/knowledge/plant`, so a private clone living anywhere else was
|
||||
invisible to this tool no matter how the machine was set up."""
|
||||
plant_dir = tmp_path / 'plants' / 'sample_plant'
|
||||
plant_dir.mkdir(parents=True)
|
||||
(plant_dir / 'knowledge.yaml').write_text(
|
||||
u'nomenclature:\n design_folder: X\n', encoding='utf-8')
|
||||
monkeypatch.setenv(kd.plantpath.PLANT_ENV, str(tmp_path / 'plants'))
|
||||
found = kd._plant_files()
|
||||
assert found == [str(plant_dir / 'knowledge.yaml')]
|
||||
|
||||
|
||||
def test_plant_files_skips_bak_files_from_the_env_pointed_root(
|
||||
tmp_path, monkeypatch):
|
||||
plant_dir = tmp_path / 'sample_plant'
|
||||
plant_dir.mkdir()
|
||||
(plant_dir / 'knowledge.yaml').write_text('x: 1\n', encoding='utf-8')
|
||||
(plant_dir / 'knowledge.yaml.bak-20260101').write_text(
|
||||
'x: 1\n', encoding='utf-8')
|
||||
monkeypatch.setenv(kd.plantpath.PLANT_ENV, str(tmp_path))
|
||||
found = kd._plant_files()
|
||||
assert found == [str(plant_dir / 'knowledge.yaml')]
|
||||
|
||||
|
||||
def test_job_files_walks_the_env_pointed_root_not_the_checkout_one(
|
||||
tmp_path, monkeypatch):
|
||||
job_dir = tmp_path / 'jobs' / 'sample_job'
|
||||
job_dir.mkdir(parents=True)
|
||||
(job_dir / 'knowledge.yaml').write_text(
|
||||
u'scope:\n declared_by: Will\n', encoding='utf-8')
|
||||
monkeypatch.setenv(kd.joblocate.JOBS_ENV, str(tmp_path / 'jobs'))
|
||||
found = kd._job_files()
|
||||
assert found == [str(job_dir / 'knowledge.yaml')]
|
||||
|
||||
|
||||
def test_plant_and_job_roots_on_a_fresh_machine_with_no_env_and_no_checkout(
|
||||
tmp_path, monkeypatch):
|
||||
"""Neither the env var nor `<repo>/knowledge/plant` (or `/job`) existing
|
||||
is not an error here -- it is the ordinary state of a fresh clone, and
|
||||
`_plant_files` / `_job_files` must just come back empty."""
|
||||
monkeypatch.delenv(kd.plantpath.PLANT_ENV, raising=False)
|
||||
monkeypatch.delenv(kd.joblocate.JOBS_ENV, raising=False)
|
||||
monkeypatch.setattr(kd, 'ROOT', str(tmp_path))
|
||||
assert kd._plant_files() == []
|
||||
assert kd._job_files() == []
|
||||
|
||||
|
||||
def test_it_still_works_with_no_private_layers(monkeypatch):
|
||||
# A colleague cloning the repo has only the public files. The run must
|
||||
# still be useful, and must say which layers it actually saw.
|
||||
monkeypatch.setattr(kd, '_plant_files', lambda: [])
|
||||
monkeypatch.setattr(kd, '_job_files', lambda: [])
|
||||
res = kd.analyse()
|
||||
assert res['layers'] == ['PLANT_STARTER', 'STARTER', 'catalogue.yaml',
|
||||
'knowledge.DEFAULTS']
|
||||
buf = io.StringIO()
|
||||
kd.report(res, buf)
|
||||
assert 'No private plant or job layer in this checkout' in buf.getvalue()
|
||||
|
||||
|
||||
def test_json_mode_exits_the_same_way_as_the_text_report(capsys):
|
||||
import json
|
||||
code = kd.main(['--json'])
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
total = sum(len(v) for v in payload['findings'].values())
|
||||
assert code == (1 if total else 0)
|
||||
assert set(payload['findings']) == set(kd.CATEGORIES)
|
||||
@@ -0,0 +1,208 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for tools/knowledgelint.py -- the host-side gate on the layer files.
|
||||
|
||||
Loaded by path, like `test_knowledge_drift.py`: the tool is host CPython and
|
||||
must be (it reads YAML with PyYAML, on purpose, because that is the only
|
||||
reader on this side as strict as the YamlDotNet one inside Revit).
|
||||
|
||||
What matters about a gate is the two answers it gives and that it never
|
||||
gives a third by accident: a file that will not load has to FAIL and set the
|
||||
exit code, and a file that loads has to pass without anybody learning to
|
||||
ignore it. A gate that cannot tell them apart is the silence this whole tool
|
||||
exists against -- 2026-09-01, one duplicate key, one whole plant layer gone,
|
||||
every report normal.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))))
|
||||
TOOLS = os.path.join(ROOT, 'tools')
|
||||
if TOOLS not in sys.path:
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
pytest.importorskip('yaml')
|
||||
lint = pytest.importorskip('knowledgelint')
|
||||
|
||||
|
||||
def _write(tmp_path, name, text):
|
||||
path = tmp_path / name
|
||||
path.write_text(text, encoding='utf-8')
|
||||
return str(path)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- one file
|
||||
|
||||
def test_a_clean_file_passes(tmp_path):
|
||||
path = _write(tmp_path, 'ok.yaml', u'a:\n b: 1\n c: [1, 2]\n')
|
||||
assert lint.check_file(path) == (True, 'ok')
|
||||
|
||||
|
||||
def test_a_duplicate_key_fails_with_the_line_to_delete(tmp_path):
|
||||
"""The incident's exact shape: nested, not top level."""
|
||||
path = _write(tmp_path, 'dup.yaml',
|
||||
u'checks:\n'
|
||||
u' rules:\n'
|
||||
u' cap_label_mismatch_is_view_specific: true\n'
|
||||
u' other: 1\n'
|
||||
u' cap_label_mismatch_is_view_specific: false\n')
|
||||
ok, message = lint.check_file(path)
|
||||
assert ok is False
|
||||
assert message.startswith('Duplicate key')
|
||||
assert 'cap_label_mismatch_is_view_specific' in message
|
||||
assert 'line 5' in message # the SECOND statement of the key
|
||||
|
||||
|
||||
def test_an_ordinary_syntax_error_fails_with_its_position(tmp_path):
|
||||
path = _write(tmp_path, 'broken.yaml', u'a:\n - [1, 2\n')
|
||||
ok, message = lint.check_file(path)
|
||||
assert ok is False
|
||||
assert 'line' in message
|
||||
|
||||
|
||||
def test_the_same_key_under_two_different_parents_is_not_a_duplicate(tmp_path):
|
||||
path = _write(tmp_path, 'notes.yaml', u'a:\n note: x\nb:\n note: y\n')
|
||||
assert lint.check_file(path)[0] is True
|
||||
|
||||
|
||||
def test_a_file_that_is_not_there_is_a_failure_not_a_pass(tmp_path):
|
||||
ok, message = lint.check_file(str(tmp_path / 'nope.yaml'))
|
||||
assert ok is False
|
||||
assert 'cannot open' in message
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- the run
|
||||
|
||||
def test_main_exits_zero_on_a_clean_tree(tmp_path, capsys):
|
||||
_write(tmp_path, 'a.yaml', u'a: 1\n')
|
||||
_write(tmp_path, 'b.yaml', u'b: 2\n')
|
||||
assert lint.main([str(tmp_path)]) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert '2 layer file(s) parse.' in out
|
||||
assert 'FAILED' not in out
|
||||
|
||||
|
||||
def test_main_exits_one_and_names_the_file(tmp_path, capsys):
|
||||
_write(tmp_path, 'a.yaml', u'a: 1\n')
|
||||
bad = _write(tmp_path, 'b.yaml', u'b: 1\nb: 2\n')
|
||||
assert lint.main([str(tmp_path)]) == 1
|
||||
out = capsys.readouterr().out
|
||||
assert 'FAILED' in out
|
||||
assert bad in out
|
||||
assert 'Duplicate key' in out
|
||||
# the sentence that says what a dropped layer looks like from the outside
|
||||
assert 'agrees with the defaults' in out
|
||||
|
||||
|
||||
def test_main_with_nothing_to_check_is_not_a_pass(tmp_path, capsys):
|
||||
"""`no files found` printed with exit 0 is a gate that has stopped
|
||||
checking and says it is fine -- which is the failure mode, not a state to
|
||||
report cheerfully."""
|
||||
assert lint.main([str(tmp_path)]) == 1
|
||||
assert 'no knowledge layer found' in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_json_output_carries_every_result(tmp_path, capsys):
|
||||
import json
|
||||
_write(tmp_path, 'a.yaml', u'a: 1\n')
|
||||
_write(tmp_path, 'b.yaml', u'b: 1\nb: 2\n')
|
||||
assert lint.main([str(tmp_path), '--json']) == 1
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload['checked'] == 2 and payload['failed'] == 1
|
||||
assert [r['ok'] for r in payload['results']] == [True, False]
|
||||
|
||||
|
||||
def test_list_prints_the_targets_and_checks_nothing(tmp_path, capsys):
|
||||
bad = _write(tmp_path, 'b.yaml', u'b: 1\nb: 2\n')
|
||||
assert lint.main([str(tmp_path), '--list']) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert bad in out
|
||||
assert 'FAILED' not in out
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- targets
|
||||
|
||||
def test_a_folder_argument_is_walked_and_bak_snapshots_are_left_alone(
|
||||
tmp_path):
|
||||
_write(tmp_path, 'a.yaml', u'a: 1\n')
|
||||
_write(tmp_path, 'k.bak-2026-01-01.yaml', u'x: 1\nx: 2\n')
|
||||
nested = tmp_path / 'deeper'
|
||||
nested.mkdir()
|
||||
_write(nested, 'b.yml', u'b: 1\n')
|
||||
found = [os.path.basename(p) for p in lint.walk_yaml(str(tmp_path))]
|
||||
assert found == ['a.yaml', 'b.yml']
|
||||
|
||||
|
||||
def test_default_targets_include_the_catalogue_that_ships_with_the_tool():
|
||||
"""It is the one layer in the repository, so it is the one target that is
|
||||
there on every machine -- including a colleague's clone with no plant or
|
||||
job folder and no environment set."""
|
||||
assert lint.CATALOGUE in lint.default_targets()
|
||||
|
||||
|
||||
def test_default_targets_are_files_that_exist_and_are_not_repeated():
|
||||
targets = lint.default_targets()
|
||||
assert all(os.path.isfile(p) for p in targets)
|
||||
assert len(set(os.path.realpath(p) for p in targets)) == len(targets)
|
||||
|
||||
|
||||
def test_the_plant_root_is_read_the_way_plantpath_reads_it(monkeypatch,
|
||||
tmp_path):
|
||||
"""Mirrors `vibe.plantpath.plant_root`, which cannot be imported here.
|
||||
Set to a FOLDER that folder is the root; set to a FILE its directory is,
|
||||
because the machine-wide file lives IN the root."""
|
||||
monkeypatch.setenv(lint.PLANT_ENV, str(tmp_path))
|
||||
assert lint.plant_root() == str(tmp_path)
|
||||
monkeypatch.setenv(lint.PLANT_ENV, str(tmp_path / 'knowledge.yaml'))
|
||||
assert lint.plant_root() == str(tmp_path)
|
||||
|
||||
|
||||
def test_the_plant_and_job_layers_of_this_machine_are_targets(monkeypatch,
|
||||
tmp_path):
|
||||
plants = tmp_path / 'plants'
|
||||
(plants / 'northgate').mkdir(parents=True)
|
||||
_write(plants / 'northgate', 'knowledge.yaml', u'a: 1\n')
|
||||
_write(plants, 'knowledge.yaml', u'b: 1\n')
|
||||
jobs = tmp_path / 'jobs'
|
||||
(jobs / 'sample_job').mkdir(parents=True)
|
||||
_write(jobs / 'sample_job', 'knowledge.yaml', u'c: 1\n')
|
||||
monkeypatch.setenv(lint.PLANT_ENV, str(plants))
|
||||
monkeypatch.setenv(lint.JOBS_ENV, str(jobs))
|
||||
|
||||
targets = [os.path.realpath(p) for p in lint.default_targets()]
|
||||
for expected in (plants / 'knowledge.yaml',
|
||||
plants / 'northgate' / 'knowledge.yaml',
|
||||
jobs / 'sample_job' / 'knowledge.yaml'):
|
||||
assert os.path.realpath(str(expected)) in targets
|
||||
|
||||
|
||||
def test_a_project_config_pointer_is_followed_but_a_dead_one_is_not_a_verdict(
|
||||
monkeypatch, tmp_path):
|
||||
"""A pointer to a file that is gone is `knowledge._config_missing` -- the
|
||||
buttons already refuse on it, and the file may simply live on a share this
|
||||
machine is not on today. Not a parse failure, so not this tool's verdict."""
|
||||
jobs = tmp_path / 'jobs'
|
||||
(jobs / 'sample_job').mkdir(parents=True)
|
||||
_write(jobs / 'sample_job', 'knowledge.yaml', u'c: 1\n')
|
||||
config = _write(tmp_path, 'active-config.yaml', u'knowledge:\n a: 1\n')
|
||||
monkeypatch.setenv(lint.JOBS_ENV, str(jobs))
|
||||
|
||||
(jobs / 'sample_job' / 'active-config.txt').write_text(
|
||||
config, encoding='utf-8')
|
||||
assert os.path.realpath(config) in [os.path.realpath(p)
|
||||
for p in lint.default_targets()]
|
||||
|
||||
(jobs / 'sample_job' / 'active-config.txt').write_text(
|
||||
str(tmp_path / 'gone.yaml'), encoding='utf-8')
|
||||
assert os.path.realpath(str(tmp_path / 'gone.yaml')) not in \
|
||||
[os.path.realpath(p) for p in lint.default_targets()]
|
||||
|
||||
|
||||
# The obvious next test -- "every real layer on this machine parses" -- is
|
||||
# deliberately NOT here. It is the gate's whole job, but the gate is
|
||||
# `python tools/knowledgelint.py`, run by whoever edited a layer; as a pytest
|
||||
# case it would turn a broken gitignored file that no code change touched
|
||||
# into a red suite, and this repository already has one env-dependent test
|
||||
# teaching people that a failure is not necessarily theirs. Run the tool.
|
||||
@@ -0,0 +1,348 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for the Revit-free half of lateral extraction.
|
||||
|
||||
`latmodel` and `latrisa` import nothing from Revit at module scope, so they
|
||||
run straight on host CPython through the conftest trick -- same as
|
||||
`ticketpage`. `latextract` is the Revit half and is deliberately not
|
||||
reachable from here.
|
||||
|
||||
No real parameter name and no real mark appears anywhere below. The marks
|
||||
are `MARK_A` / `MARK_B` and the field names are placeholders, because a
|
||||
parameter name is this office's template and the repository is shareable.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from vibe import latmodel as LM
|
||||
from vibe import latrisa as LR
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the marker
|
||||
|
||||
@pytest.mark.parametrize('text', [
|
||||
'lateral-resistent',
|
||||
'LATERAL-RESISTENT',
|
||||
'Lateral-Resistent',
|
||||
'lateral resistent',
|
||||
'lateral_resistent',
|
||||
'lateral resistent',
|
||||
'lateral.resistent',
|
||||
'lateral/resistent',
|
||||
'shear wall, lateral-resistent',
|
||||
'lateral-resistent (see S2.1)',
|
||||
'PRECAST BEAM - LATERAL RESISTENT - NORTH BAY',
|
||||
])
|
||||
def test_marker_accepts_owner_spelling(text):
|
||||
verdict, token = LM.match_marker(text)
|
||||
assert verdict == LM.MATCH_YES
|
||||
assert token == 'lateral-resistent'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('text', [
|
||||
'lateral-resistant',
|
||||
'LATERAL RESISTANT',
|
||||
'a lateral_resistant column',
|
||||
])
|
||||
def test_marker_accepts_conventional_spelling(text):
|
||||
verdict, token = LM.match_marker(text)
|
||||
assert verdict == LM.MATCH_YES
|
||||
assert token == 'lateral-resistant'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('text', [
|
||||
None,
|
||||
'',
|
||||
' ',
|
||||
'lateral',
|
||||
'resistant',
|
||||
'resistent',
|
||||
'lateralresistent', # no separator -- see normalise()
|
||||
'laterally resistant', # 'laterally' is a different word
|
||||
'lateral resistance',
|
||||
'PRECAST CONCRETE BEAM',
|
||||
])
|
||||
def test_marker_rejects(text):
|
||||
verdict, token = LM.match_marker(text)
|
||||
assert verdict == LM.MATCH_NO
|
||||
assert token is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize('text', [
|
||||
'NOT lateral-resistent',
|
||||
'not lateral resistant',
|
||||
'non lateral-resistent',
|
||||
'no lateral-resistent',
|
||||
'excluded lateral-resistent',
|
||||
])
|
||||
def test_marker_negated_is_ambiguous_never_silently_included(text):
|
||||
"""A negated marker is settled by a human, never by this code."""
|
||||
verdict, _ = LM.match_marker(text)
|
||||
assert verdict == LM.MATCH_AMBIGUOUS
|
||||
|
||||
|
||||
def test_marker_tokens_are_configurable():
|
||||
verdict, token = LM.match_marker('this is an LFRS member', ['LFRS'])
|
||||
assert verdict == LM.MATCH_YES and token == 'LFRS'
|
||||
# and the defaults no longer apply when a plant overrides them
|
||||
assert LM.match_marker('lateral-resistent', ['LFRS'])[0] == LM.MATCH_NO
|
||||
|
||||
|
||||
def test_normalise():
|
||||
assert LM.normalise('A-B_C D') == 'a b c d'
|
||||
assert LM.normalise(None) == ''
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the model
|
||||
|
||||
def _raw(eid, i, j, section='PLACEHOLDER-TYPE', material='PLACEHOLDER-MAT',
|
||||
verified=False, **kw):
|
||||
row = {'element_id': eid, 'mark': 'MARK_{0}'.format(eid),
|
||||
'category': 'Structural Framing', 'desc': 'lateral-resistent',
|
||||
'token': 'lateral-resistent', 'i': i, 'j': j,
|
||||
'axis_source': 'location_curve',
|
||||
'section': ({'name': section, 'source': 'revit_type_name',
|
||||
'verified': verified} if section else None),
|
||||
'material': material, 'note': None}
|
||||
row.update(kw)
|
||||
return row
|
||||
|
||||
|
||||
def test_build_makes_nodes_and_members():
|
||||
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0)),
|
||||
_raw(2, (10, 0, 0), (10, 0, 12))])
|
||||
assert len(m['members']) == 2
|
||||
assert len(m['nodes']) == 3 # the shared end welded
|
||||
assert m['members'][0]['j_node'] == m['members'][1]['i_node']
|
||||
assert m['length_unit'] == 'ft'
|
||||
|
||||
|
||||
def test_units_are_feet_and_nothing_converts():
|
||||
"""A 12x error has already cost this project real time. Feet in, feet out."""
|
||||
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0))])
|
||||
assert m['members'][0]['length_ft'] == pytest.approx(10.0)
|
||||
assert m['nodes'][1]['x'] == pytest.approx(10.0)
|
||||
assert 'feet' in m['coordinate_note'].lower()
|
||||
|
||||
|
||||
def test_endpoints_within_tolerance_weld_into_one_node():
|
||||
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0)),
|
||||
_raw(2, (10.005, 0, 0), (10, 0, 12))])
|
||||
assert len(m['nodes']) == 3
|
||||
|
||||
|
||||
def test_endpoints_outside_tolerance_stay_separate_and_are_reported():
|
||||
"""A gap is never quietly closed. It is reported as a near miss."""
|
||||
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0)),
|
||||
_raw(2, (10.25, 0, 0), (10.25, 0, 12))])
|
||||
assert len(m['nodes']) == 4
|
||||
near = m['connectivity']['near_misses']
|
||||
assert near and near[0]['gap_ft'] == pytest.approx(0.25)
|
||||
|
||||
|
||||
def test_welding_works_across_a_grid_cell_boundary():
|
||||
tol = 0.01
|
||||
a = 5.0
|
||||
m = LM.build([_raw(1, (0, 0, 0), (a, 0, 0)),
|
||||
_raw(2, (a + tol * 0.4, 0, 0), (a, 0, 9))],
|
||||
node_tol_ft=tol)
|
||||
assert len(m['nodes']) == 3
|
||||
|
||||
|
||||
def test_member_with_no_axis_is_skipped_with_a_reason_not_dropped():
|
||||
m = LM.build([_raw(1, None, None)])
|
||||
assert m['members'] == []
|
||||
assert len(m['skipped']) == 1
|
||||
assert m['skipped'][0]['element_id'] == 1
|
||||
assert m['skipped'][0]['reason']
|
||||
|
||||
|
||||
def test_zero_length_member_is_skipped():
|
||||
m = LM.build([_raw(1, (0, 0, 0), (0, 0, 0.0001))])
|
||||
assert m['members'] == []
|
||||
assert 'zero-length' in m['skipped'][0]['reason']
|
||||
|
||||
|
||||
def test_missing_section_is_never_defaulted():
|
||||
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0), section=None)])
|
||||
mem = m['members'][0]
|
||||
assert mem['section'] is None
|
||||
assert 'section' in mem['unresolved']
|
||||
|
||||
|
||||
def test_unverified_section_counts_as_unresolved():
|
||||
"""A type NAME is not a section property, and must not pass as one."""
|
||||
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0), verified=False)])
|
||||
assert any(u.startswith('verified section')
|
||||
for u in m['members'][0]['unresolved'])
|
||||
|
||||
|
||||
def test_verified_section_is_accepted():
|
||||
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0), verified=True)])
|
||||
assert not any('section' in u for u in m['members'][0]['unresolved'])
|
||||
|
||||
|
||||
def test_missing_material_is_never_defaulted():
|
||||
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0), material=None)])
|
||||
assert m['members'][0]['material'] is None
|
||||
assert 'material' in m['members'][0]['unresolved']
|
||||
|
||||
|
||||
def test_end_releases_are_always_unresolved():
|
||||
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0), verified=True)])
|
||||
assert m['members'][0]['releases'] is None
|
||||
assert 'end releases' in m['members'][0]['unresolved']
|
||||
|
||||
|
||||
def test_free_ends_are_reported_and_not_called_supports():
|
||||
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0))])
|
||||
assert sorted(m['connectivity']['free_ends']) == ['N1', 'N2']
|
||||
|
||||
|
||||
def test_interior_intersections_are_not_searched_for_and_say_so():
|
||||
"""A member ending mid-span on another is NOT connected. Stated, not hidden."""
|
||||
m = LM.build([_raw(1, (0, 0, 0), (20, 0, 0)),
|
||||
_raw(2, (10, 0, 0), (10, 0, 12))])
|
||||
assert m['connectivity']['interior_intersections_checked'] is False
|
||||
assert len(m['nodes']) == 4 # the T is four nodes, not three
|
||||
|
||||
|
||||
def test_near_miss_search_stands_down_on_a_big_model_rather_than_lying():
|
||||
"""Not run is not 'none found'. Revit's UI thread must not be blocked."""
|
||||
raws = [_raw(n, (n * 100.0, 0, 0), (n * 100.0 + 10, 0, 0))
|
||||
for n in range(700)]
|
||||
m = LM.build(raws)
|
||||
assert m['connectivity']['near_miss_checked'] is False
|
||||
assert m['connectivity']['near_miss_total'] is None
|
||||
assert m['connectivity']['near_misses'] == []
|
||||
assert m['connectivity']['node_count'] == 1400
|
||||
|
||||
|
||||
def test_near_miss_search_runs_on_a_small_model():
|
||||
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0)),
|
||||
_raw(2, (10.25, 0, 0), (10.25, 0, 12))])
|
||||
assert m['connectivity']['near_miss_checked'] is True
|
||||
assert m['connectivity']['near_miss_total'] == 1
|
||||
|
||||
|
||||
def test_unresolved_rows_are_element_by_element():
|
||||
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0), section=None, material=None)])
|
||||
rows = LM.unresolved_rows(m)
|
||||
assert {r['missing'] for r in rows} == {'section', 'material',
|
||||
'end releases'}
|
||||
assert all(r['element_id'] == 1 for r in rows)
|
||||
|
||||
|
||||
def test_notes_state_the_absences():
|
||||
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0))])
|
||||
blob = ' '.join(m['notes']).upper()
|
||||
assert 'END RELEASES ARE NOT EXTRACTED' in blob
|
||||
assert 'SUPPORTS ARE NOT EXTRACTED' in blob
|
||||
assert 'LOADS ARE NOT EXTRACTED' in blob
|
||||
|
||||
|
||||
def test_is_analysable_is_false_while_anything_is_missing():
|
||||
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0))])
|
||||
assert LM.is_analysable(m) is False
|
||||
assert LM.is_analysable(LM.build([])) is False
|
||||
|
||||
|
||||
def test_empty_input_is_an_empty_model_not_a_crash():
|
||||
m = LM.build([])
|
||||
assert m['members'] == [] and m['nodes'] == [] and m['skipped'] == []
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the output
|
||||
|
||||
def _model():
|
||||
return LM.build([_raw(1, (0, 0, 0), (10, 0, 0)),
|
||||
_raw(2, (10, 0, 0), (10, 0, 12), section=None,
|
||||
material=None)],
|
||||
model_title='sample_job')
|
||||
|
||||
|
||||
def test_json_round_trips():
|
||||
import json
|
||||
m = _model()
|
||||
back = json.loads(LR.emit_json(m))
|
||||
assert back['schema'] == 'willtools.lateral/1'
|
||||
assert len(back['members']) == 2
|
||||
|
||||
|
||||
def test_nodes_csv_carries_the_unit_on_every_row():
|
||||
rows = LR.emit_nodes_csv(_model()).strip().split('\n')
|
||||
assert rows[0].startswith('label,x,y,z,unit')
|
||||
assert all(',ft,' in r for r in rows[1:])
|
||||
|
||||
|
||||
def test_members_csv_leaves_unknowns_blank_rather_than_filled():
|
||||
text = LR.emit_members_csv(_model())
|
||||
rows = [r.split(',') for r in text.strip().split('\n')]
|
||||
head, body = rows[0], rows[1:]
|
||||
sec = head.index('section')
|
||||
rel = head.index('end_releases')
|
||||
assert body[1][sec] == '' # no section -> blank, not a default
|
||||
assert all(r[rel] == '' for r in body)
|
||||
|
||||
|
||||
def test_members_csv_names_what_is_unresolved_in_words_too():
|
||||
text = LR.emit_members_csv(_model())
|
||||
assert 'end releases' in text
|
||||
|
||||
|
||||
def test_risa_output_is_labelled_a_draft_loudly():
|
||||
text = LR.emit_risa_draft(_model())
|
||||
assert 'DRAFT -- NOT A VERIFIED RISA-3D INPUT FILE' in text
|
||||
assert 'DRAFT' in text.split('\n')[1]
|
||||
|
||||
|
||||
def test_risa_draft_uses_risas_published_section_keywords():
|
||||
text = LR.emit_risa_draft(_model())
|
||||
for kw in ('[RISA-3D_INPUT_DATA]', '[NODES]', '[MEMBERS]',
|
||||
'[.PROJECT_DESCRIPTION]'):
|
||||
assert kw in text
|
||||
assert '[NODES] <3>' in text
|
||||
assert '[MEMBERS] <2>' in text
|
||||
|
||||
|
||||
def test_risa_draft_emits_no_units_line():
|
||||
"""A wrong units line rescales everything invisibly. Better absent."""
|
||||
text = LR.emit_risa_draft(_model())
|
||||
assert '\n[UNITS]' not in text
|
||||
assert '! [UNITS] omitted on purpose' in text
|
||||
|
||||
|
||||
def test_risa_draft_emits_no_supports_and_says_so():
|
||||
text = LR.emit_risa_draft(_model())
|
||||
assert '\n[BOUNDARY_CONDITIONS]' not in text
|
||||
assert 'intentionally absent' in text
|
||||
|
||||
|
||||
def test_risa_draft_uses_an_unresolvable_shape_rather_than_a_default():
|
||||
"""It must fail to analyse, not analyse wrongly."""
|
||||
text = LR.emit_risa_draft(_model())
|
||||
assert LR.UNRESOLVED_SHAPE in text
|
||||
assert LR.UNRESOLVED_MATERIAL in text
|
||||
|
||||
|
||||
def test_risa_draft_records_every_skip_with_its_reason():
|
||||
m = LM.build([_raw(7, None, None)])
|
||||
text = LR.emit_risa_draft(m)
|
||||
assert '! skipped element 7' in text
|
||||
|
||||
|
||||
def test_risa_draft_says_nothing_was_skipped_when_nothing_was():
|
||||
assert 'nothing was skipped' in LR.emit_risa_draft(_model())
|
||||
|
||||
|
||||
def test_file_set_names_the_draft_so_it_cannot_be_opened_by_accident():
|
||||
names = [n for n, _ in LR.file_set(_model(), 'sample_job')]
|
||||
assert names == ['sample_job-lateral.json',
|
||||
'sample_job-lateral-nodes.csv',
|
||||
'sample_job-lateral-members.csv',
|
||||
'sample_job-lateral.r3d.DRAFT.txt']
|
||||
assert not any(n.endswith('.r3d') for n in names)
|
||||
|
||||
|
||||
def test_csv_quotes_a_description_containing_a_comma():
|
||||
m = LM.build([_raw(1, (0, 0, 0), (10, 0, 0),
|
||||
desc='shear wall, lateral-resistent')])
|
||||
assert '"shear wall, lateral-resistent"' in LR.emit_members_csv(m)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,338 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for `vibe.pa_criteria.page` -- the Design Criteria section layout.
|
||||
|
||||
No Revit and no WPF renderer are available here (see `conftest.py`'s
|
||||
docstring for why `vibe` imports this way), so this suite cannot check that
|
||||
the page LOOKS right -- nobody has run this inside Revit and this file does
|
||||
not claim otherwise. What it can and does check mechanically:
|
||||
|
||||
* `page()` never raises, on an empty dict, a filled dict, `None` values,
|
||||
`None` reads, or a `values` object that is not even a dict.
|
||||
* every block returned is a well-formed member of the block vocabulary
|
||||
`page()`'s docstring promises (`heading`, `columns`, `field`, `kv`,
|
||||
`sentence`, `table`, `rule`, `space`) -- an unknown `kind` renders as
|
||||
nothing on the real form, so a typo here would be a silently missing
|
||||
block that only this test would catch.
|
||||
* every `field` block carries a non-empty, plain-English `label` and a
|
||||
non-empty `hint` -- the owner's whole complaint about the old form was
|
||||
that a field carried only its engineering symbol.
|
||||
* a field key with no backing entry in `panelanalysisform.FIELDS` still
|
||||
renders (an empty editor), and is named in `problems` -- never silently
|
||||
dropped.
|
||||
* `values` is never mutated by a call to `page()`.
|
||||
* the block list is stable: calling `page()` twice on the same input
|
||||
produces the same output.
|
||||
"""
|
||||
import copy
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TESTS_DIR = Path(__file__).resolve().parent
|
||||
if str(TESTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(TESTS_DIR))
|
||||
|
||||
from vibe import pa_criteria as pc # noqa: E402
|
||||
|
||||
|
||||
KNOWN_KINDS = set(['heading', 'columns', 'field', 'kv', 'sentence', 'table',
|
||||
'rule', 'space'])
|
||||
KNOWN_EDITORS = set(['text', 'num', 'check', 'choice'])
|
||||
|
||||
|
||||
def _walk(blocks):
|
||||
"""Yield every block in the tree, including nested `columns` children."""
|
||||
for b in blocks:
|
||||
yield b
|
||||
if b.get('kind') == 'columns':
|
||||
for col in b.get('columns') or []:
|
||||
for sub in _walk(col):
|
||||
yield sub
|
||||
|
||||
|
||||
def _assert_well_formed(blocks):
|
||||
for b in blocks:
|
||||
assert isinstance(b, dict), 'block is not a dict: {0!r}'.format(b)
|
||||
kind = b.get('kind')
|
||||
assert kind in KNOWN_KINDS, 'unknown block kind {0!r}'.format(kind)
|
||||
|
||||
if kind == 'heading':
|
||||
assert isinstance(b['text'], str) and b['text'].strip()
|
||||
assert b['level'] in (1, 2, 3)
|
||||
|
||||
elif kind == 'columns':
|
||||
assert isinstance(b['columns'], list) and b['columns']
|
||||
for col in b['columns']:
|
||||
assert isinstance(col, list)
|
||||
_assert_well_formed(col)
|
||||
|
||||
elif kind == 'field':
|
||||
assert isinstance(b['key'], str) and b['key']
|
||||
assert isinstance(b['label'], str) and b['label'].strip(), (
|
||||
'field {0!r} has no plain-English label'.format(b['key']))
|
||||
assert isinstance(b['hint'], str) and b['hint'].strip(), (
|
||||
'field {0!r} has no hint'.format(b['key']))
|
||||
assert b['symbol'] is None or isinstance(b['symbol'], str)
|
||||
assert isinstance(b['required'], bool)
|
||||
assert b['editor'] in KNOWN_EDITORS, (
|
||||
'field {0!r} has unknown editor {1!r}'
|
||||
.format(b['key'], b['editor']))
|
||||
if b['editor'] == 'choice':
|
||||
assert b['choices'], (
|
||||
'choice field {0!r} has no choices'.format(b['key']))
|
||||
else:
|
||||
assert b['choices'] is None
|
||||
|
||||
elif kind == 'kv':
|
||||
assert isinstance(b['rows'], list)
|
||||
for row in b['rows']:
|
||||
assert len(row) == 2
|
||||
|
||||
elif kind == 'sentence':
|
||||
assert isinstance(b['text'], str) and b['text'].strip()
|
||||
|
||||
elif kind == 'table':
|
||||
assert isinstance(b['headers'], list) and b['headers']
|
||||
assert isinstance(b['rows'], list)
|
||||
for row in b['rows']:
|
||||
assert len(row) == len(b['headers']), (
|
||||
'table row {0!r} does not match headers {1!r}'
|
||||
.format(row, b['headers']))
|
||||
for cell in row:
|
||||
if isinstance(cell, dict):
|
||||
assert cell.get('editor') == 'check'
|
||||
assert cell.get('key')
|
||||
else:
|
||||
assert isinstance(cell, str)
|
||||
|
||||
elif kind == 'rule':
|
||||
assert set(b.keys()) == set(['kind'])
|
||||
|
||||
elif kind == 'space':
|
||||
assert isinstance(b['h'], int)
|
||||
|
||||
|
||||
def _field_keys(blocks):
|
||||
return [b['key'] for b in _walk(blocks) if b.get('kind') == 'field']
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- tests
|
||||
|
||||
def test_empty_values_never_raises_and_is_well_formed():
|
||||
blocks, problems = pc.page({})
|
||||
assert isinstance(blocks, list) and blocks
|
||||
assert isinstance(problems, list)
|
||||
_assert_well_formed(blocks)
|
||||
|
||||
|
||||
def test_none_values_and_none_reads_never_raises():
|
||||
blocks, problems = pc.page(None, None)
|
||||
assert isinstance(blocks, list) and blocks
|
||||
_assert_well_formed(blocks)
|
||||
|
||||
|
||||
def test_non_dict_values_degrades_rather_than_raising():
|
||||
blocks, problems = pc.page('not a dict')
|
||||
assert isinstance(blocks, list) and blocks
|
||||
_assert_well_formed(blocks)
|
||||
|
||||
|
||||
def test_filled_values_never_raises_and_is_well_formed():
|
||||
values = {
|
||||
'fc_psi': '6000', 'fci_psi': '3500', 'unit_weight_pcf': '150',
|
||||
'concrete_type': 'Normalweight', 'rebar_fy_ksi': '60',
|
||||
'rebar_es_ksi': '29000', 'ps_fpu_ksi': '270', 'strand_ep_ksi': '28500',
|
||||
'ps_loss_method': 'PCI', 'ps_loss_humidity_pct': '75',
|
||||
'design_criteria.rvalue_calc_enabled': 'yes',
|
||||
'design_criteria.min_reinf_checked': 'true',
|
||||
}
|
||||
blocks, problems = pc.page(values, {'mark': 'sample_mark',
|
||||
'design_number': 'PNL1.0'})
|
||||
assert isinstance(blocks, list) and blocks
|
||||
_assert_well_formed(blocks)
|
||||
|
||||
|
||||
def test_values_dict_is_never_mutated():
|
||||
values = {'fc_psi': '6000'}
|
||||
before = copy.deepcopy(values)
|
||||
pc.page(values, {'mark': 'sample_mark'})
|
||||
assert values == before
|
||||
|
||||
|
||||
def test_reads_dict_is_never_mutated():
|
||||
reads = {'mark': 'sample_mark', 'design_number': 'PNL1.0'}
|
||||
before = copy.deepcopy(reads)
|
||||
pc.page({}, reads)
|
||||
assert reads == before
|
||||
|
||||
|
||||
def test_block_list_is_stable_for_the_same_input():
|
||||
values = {'fc_psi': '6000', 'unit_weight_pcf': '150'}
|
||||
reads = {'mark': 'sample_mark'}
|
||||
a, pa = pc.page(values, reads)
|
||||
b, pb = pc.page(values, reads)
|
||||
assert a == b
|
||||
assert pa == pb
|
||||
|
||||
|
||||
def test_missing_key_degrades_to_a_field_and_names_a_problem():
|
||||
blocks, problems = pc.page({})
|
||||
keys = set(_field_keys(blocks))
|
||||
# A key this file invented because Eriksson's page has no home for it
|
||||
# yet in panelanalysisform.FIELDS.
|
||||
assert 'design_criteria.analysis_beta_d' in keys
|
||||
joined = ' '.join(problems)
|
||||
assert 'design_criteria.analysis_beta_d' in joined
|
||||
|
||||
|
||||
def test_existing_form_key_is_not_reported_as_missing():
|
||||
blocks, problems = pc.page({})
|
||||
joined = ' '.join(problems)
|
||||
# fc_psi is a real, long-standing panelanalysisform.FIELDS key.
|
||||
assert "'fc_psi'" not in joined or 'fc_psi) has no backing' not in joined
|
||||
for p in problems:
|
||||
assert 'fc_psi)' not in p
|
||||
|
||||
|
||||
def test_every_field_key_appears_at_most_once():
|
||||
blocks, _problems = pc.page({})
|
||||
keys = _field_keys(blocks)
|
||||
dupes = [k for k in set(keys) if keys.count(k) > 1]
|
||||
assert not dupes, 'field key(s) rendered more than once: {0}'.format(dupes)
|
||||
|
||||
|
||||
def test_section_heading_is_present():
|
||||
blocks, _problems = pc.page({})
|
||||
headings = [b['text'] for b in blocks if b.get('kind') == 'heading']
|
||||
assert 'Design Criteria' in headings
|
||||
assert headings[0] == 'Design Criteria'
|
||||
|
||||
|
||||
def test_prime_notation_used_not_ascii_apostrophe():
|
||||
blocks, _problems = pc.page({})
|
||||
for b in _walk(blocks):
|
||||
if b.get('kind') != 'field':
|
||||
continue
|
||||
symbol = b.get('symbol') or ''
|
||||
# f'c / f'ci style ASCII apostrophes must never appear; the real
|
||||
# prime character (U+2032) is what the renderer turns into a
|
||||
# subscript.
|
||||
assert "f'c" not in symbol and "f'ci" not in symbol
|
||||
if u"f′" in symbol or u"E′" in symbol:
|
||||
assert True # sanity: the prime character is reachable at all
|
||||
|
||||
|
||||
def test_load_combination_tables_have_include_checkboxes():
|
||||
blocks, problems = pc.page({})
|
||||
tables = [b for b in _walk(blocks) if b.get('kind') == 'table']
|
||||
assert len(tables) == 2
|
||||
for t in tables:
|
||||
assert t['headers'][-1] == 'Include'
|
||||
for row in t['rows']:
|
||||
cell = row[-1]
|
||||
assert isinstance(cell, dict) and cell['editor'] == 'check'
|
||||
joined = ' '.join(problems)
|
||||
assert 'load_combo.strength' in joined
|
||||
assert 'load_combo.service' in joined
|
||||
|
||||
|
||||
def test_soil_blocks_are_sentences_not_editors():
|
||||
blocks, problems = pc.page({})
|
||||
sentences = [b['text'] for b in _walk(blocks) if b.get('kind') == 'sentence']
|
||||
assert any('no exterior soil loads' in s for s in sentences)
|
||||
assert any('no interior soil loads' in s for s in sentences)
|
||||
assert any('soil loads are not collected' in p for p in problems)
|
||||
|
||||
|
||||
def test_prestress_loss_group_is_a_sentence_when_blank():
|
||||
blocks, _problems = pc.page({})
|
||||
sentences = [b['text'] for b in _walk(blocks) if b.get('kind') == 'sentence']
|
||||
assert any('No prestress loss assumptions' in s for s in sentences)
|
||||
# And its fields must NOT be rendered while blank -- shown only once
|
||||
# something in the group is filled in.
|
||||
keys = set(_field_keys(blocks))
|
||||
assert 'ps_loss_method' not in keys
|
||||
|
||||
|
||||
def test_prestress_loss_group_becomes_fields_when_filled():
|
||||
blocks, _problems = pc.page({'ps_loss_method': 'PCI'})
|
||||
keys = set(_field_keys(blocks))
|
||||
assert 'ps_loss_method' in keys
|
||||
assert 'ps_loss_humidity_pct' in keys
|
||||
|
||||
|
||||
def test_filling_one_group_field_does_not_file_a_phantom_problem_for_others():
|
||||
# Regression guard: `_group_or_sentence` must not evaluate the
|
||||
# filled-in branch (and thus call `F(key)`, which files a "missing"
|
||||
# problem) when the sentence branch is the one actually shown.
|
||||
_blocks, problems_blank = pc.page({})
|
||||
joined = ' '.join(problems_blank)
|
||||
assert 'ps_loss_method' not in joined
|
||||
assert 'ps_loss_humidity_pct' not in joined
|
||||
|
||||
|
||||
def test_rvalue_and_min_reinforcement_default_sentences_present():
|
||||
blocks, _problems = pc.page({})
|
||||
sentences = [b['text'] for b in _walk(blocks) if b.get('kind') == 'sentence']
|
||||
assert any('R-value is not being calculated' in s for s in sentences)
|
||||
assert any('Minimum steel is not being checked' in s for s in sentences)
|
||||
|
||||
|
||||
def test_concrete_grid_has_release_and_inplace_strength_fields():
|
||||
blocks, _problems = pc.page({})
|
||||
keys = set(_field_keys(blocks))
|
||||
assert 'fci_psi' in keys
|
||||
assert 'fc_psi' in keys
|
||||
assert 'conc_eci_psi' in keys
|
||||
assert 'conc_ec_psi' in keys
|
||||
|
||||
|
||||
def test_ksi_reconciliation_hint_on_psi_keys():
|
||||
blocks, _problems = pc.page({})
|
||||
for b in _walk(blocks):
|
||||
if b.get('kind') == 'field' and b['key'] in ('fc_psi', 'fci_psi'):
|
||||
assert 'ksi' in b['hint'].lower()
|
||||
|
||||
|
||||
def test_required_fields_match_panelanalysisform_where_it_exists():
|
||||
from vibe import panelanalysisform as pf
|
||||
blocks, _problems = pc.page({})
|
||||
required_here = dict((b['key'], b['required'])
|
||||
for b in _walk(blocks) if b.get('kind') == 'field')
|
||||
for f in pf.FIELDS:
|
||||
key = f['key']
|
||||
if key in required_here:
|
||||
assert required_here[key] == f['required'], (
|
||||
'required flag mismatch for {0!r}'.format(key))
|
||||
|
||||
|
||||
def test_panel_header_kv_shown_only_when_reads_present():
|
||||
blocks_no_reads, _ = pc.page({}, {})
|
||||
kvs_no_reads = [b for b in blocks_no_reads if b.get('kind') == 'kv']
|
||||
blocks_with_reads, _ = pc.page({}, {'mark': 'sample_mark'})
|
||||
kvs_with_reads = [b for b in blocks_with_reads if b.get('kind') == 'kv']
|
||||
assert len(kvs_with_reads) >= len(kvs_no_reads) + 1
|
||||
|
||||
|
||||
def test_page_import_survives_a_broken_panelanalysisform(monkeypatch):
|
||||
"""Simulates the sibling agent's file being mid-edit / unimportable.
|
||||
|
||||
`_existing_keys()` is the one function that reaches for
|
||||
`panelanalysisform`; monkeypatching it directly to behave the way it
|
||||
would if the import raised (returning `None`) exercises the same
|
||||
"could not judge" path `page()` must degrade through, without depending
|
||||
on the interpreter's module-cache mechanics for a package (`vibe`)
|
||||
that other tests in this same process have already imported
|
||||
`panelanalysisform` onto as an attribute.
|
||||
"""
|
||||
monkeypatch.setattr(pc, '_existing_keys', lambda: None)
|
||||
blocks, problems = pc.page({})
|
||||
assert isinstance(blocks, list) and blocks
|
||||
assert any('could not read panelanalysisform' in p for p in problems)
|
||||
|
||||
|
||||
def test_choice_field_has_choices():
|
||||
blocks, _problems = pc.page({})
|
||||
for b in _walk(blocks):
|
||||
if b.get('kind') == 'field' and b['key'] == 'concrete_type':
|
||||
assert b['editor'] == 'choice'
|
||||
assert 'Normalweight' in b['choices']
|
||||
@@ -0,0 +1,338 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Host tests for `vibe.pa_extents` -- the Panel Analysis form's "Concrete
|
||||
Extents" section layout: panel geometry, the openings table, the drawings
|
||||
row, and Section Properties.
|
||||
|
||||
Runs on plain CPython via `tests/conftest.py`'s `vibe` stub -- this module
|
||||
imports no Revit and no `panelsketch` (it only NAMES a `panelsketch` entry
|
||||
point in a `drawing` block's `source`; the renderer, not this module, calls
|
||||
it). What this suite checks, per the task brief: only known block `kind`s
|
||||
come out; every `field` block carries a plain-English label and a hint;
|
||||
`openings_rows` being absent degrades to a `problems` entry rather than a
|
||||
crash; Section Properties never prints a row it did not derive; and `values`
|
||||
is never mutated.
|
||||
"""
|
||||
from vibe import panelanalysisform as paf # noqa: F401 (registers the stub)
|
||||
from vibe import pa_extents as E
|
||||
|
||||
|
||||
ALLOWED_KINDS = {'heading', 'columns', 'field', 'kv', 'sentence', 'table',
|
||||
'drawing', 'rule', 'space'}
|
||||
|
||||
|
||||
def _walk(blocks):
|
||||
"""Yield every block in `blocks`, recursing into `columns`."""
|
||||
for b in blocks:
|
||||
yield b
|
||||
if b.get('kind') == 'columns':
|
||||
for col in b['columns']:
|
||||
for sub in _walk(col):
|
||||
yield sub
|
||||
|
||||
|
||||
def _all_field_blocks(blocks):
|
||||
return [b for b in _walk(blocks) if b.get('kind') == 'field']
|
||||
|
||||
|
||||
def _all_drawing_blocks(blocks):
|
||||
return [b for b in _walk(blocks) if b.get('kind') == 'drawing']
|
||||
|
||||
|
||||
# --------------------------------------------------------------- vocabulary
|
||||
|
||||
def test_empty_values_only_known_kinds_no_crash():
|
||||
blocks, problems = E.page({})
|
||||
assert isinstance(blocks, list)
|
||||
assert isinstance(problems, list)
|
||||
for b in _walk(blocks):
|
||||
assert b['kind'] in ALLOWED_KINDS, 'unknown kind: {0!r}'.format(b)
|
||||
|
||||
|
||||
def test_none_values_does_not_raise():
|
||||
# page() must never raise -- an absent values dict is a valid call.
|
||||
blocks, problems = E.page(None)
|
||||
assert isinstance(blocks, list)
|
||||
assert isinstance(problems, list)
|
||||
|
||||
|
||||
def test_filled_values_only_known_kinds_no_crash():
|
||||
values = {
|
||||
'length_ft': '107', 'width_ft': '9', 'thickness_in': '8',
|
||||
'section_type': 'Solid', 'top_in_form': 'Interior',
|
||||
'top_elevation_ft': '107', 'bottom_elevation_ft': '0',
|
||||
'left_miter': 'no', 'right_miter': 'yes',
|
||||
'openings_rows': [
|
||||
{'type': 'opening', 'pos_along': 8.0 / 12.0,
|
||||
'pos_along_from': 'start', 'length': 6.875,
|
||||
'pos_cross': 2 + 11.5 / 12.0, 'pos_cross_from': 'left',
|
||||
'width': 3 + 4.5 / 12.0, 'pos_vert': None, 'height': None,
|
||||
'label': ''},
|
||||
],
|
||||
}
|
||||
blocks, problems = E.page(values)
|
||||
for b in _walk(blocks):
|
||||
assert b['kind'] in ALLOWED_KINDS
|
||||
|
||||
|
||||
# ------------------------------------------------------------ field quality
|
||||
|
||||
def test_every_field_has_a_plain_label_and_a_hint():
|
||||
blocks, _problems = E.page({})
|
||||
fields = _all_field_blocks(blocks)
|
||||
assert fields, 'expected at least one field block'
|
||||
for f in fields:
|
||||
assert f['label'] and f['label'].strip(), \
|
||||
'field {0!r} has no plain-English label'.format(f['key'])
|
||||
assert f['hint'] and f['hint'].strip(), \
|
||||
'field {0!r} has no hint'.format(f['key'])
|
||||
assert f['editor'] in ('text', 'num', 'check', 'choice')
|
||||
assert isinstance(f['required'], bool)
|
||||
|
||||
|
||||
def test_known_paf_fields_use_panelanalysisform_label_verbatim():
|
||||
# Checked LIVE against panelanalysisform.FIELDS rather than a hardcoded
|
||||
# key list, because a sibling agent is restructuring that module WHILE
|
||||
# this suite runs -- `left_miter` / `right_miter` landed there mid-task
|
||||
# (see the module docstring's "read defensively" discipline). Any key
|
||||
# `_paf_field` finds must be reflected verbatim; any key it does not
|
||||
# find is covered by the MISSING test below instead.
|
||||
blocks, _problems = E.page({})
|
||||
fields = {f['key']: f for f in _all_field_blocks(blocks)}
|
||||
candidates = ('length_ft', 'width_ft', 'thickness_in', 'section_type',
|
||||
'top_in_form', 'left_miter', 'right_miter',
|
||||
'top_elevation_ft', 'bottom_elevation_ft')
|
||||
for key in candidates:
|
||||
if key not in fields:
|
||||
continue
|
||||
if E._paf_field(key) is None:
|
||||
continue # MISSING from panelanalysisform -- not this test's job
|
||||
assert fields[key]['label'] == paf.label_of(key), \
|
||||
'{0} EXISTS in panelanalysisform but label was not taken from it' \
|
||||
.format(key)
|
||||
assert fields[key]['hint'] == (paf.help_of(key) or fields[key]['hint'])
|
||||
|
||||
|
||||
def test_keys_missing_from_panelanalysisform_are_named_in_problems():
|
||||
# Some Eriksson concepts on this page are not yet fields
|
||||
# panelanalysisform.FIELDS declares -- MISSING, per the task brief,
|
||||
# never an error. The page still draws a slot for them (to match
|
||||
# Eriksson's own layout) but must say, in `problems`, that a value
|
||||
# typed there is not wired up yet. Checked live against `_paf_field`
|
||||
# rather than a hardcoded list, since the sibling agent restructuring
|
||||
# panelanalysisform may add any of these keys before this suite is
|
||||
# read again.
|
||||
blocks, problems = E.page({})
|
||||
fields = {f['key']: f for f in _all_field_blocks(blocks)}
|
||||
candidate_keys = ('top_elevation_ft', 'bottom_elevation_ft',
|
||||
'left_miter', 'right_miter', 'top_in_form_check',
|
||||
'top_in_form_width_in', 'top_in_form_height_in')
|
||||
joined = ' '.join(problems)
|
||||
still_missing = [k for k in candidate_keys if E._paf_field(k) is None]
|
||||
assert still_missing, \
|
||||
'expected at least one of {0} to still be MISSING from ' \
|
||||
'panelanalysisform for this test to mean anything'.format(candidate_keys)
|
||||
for key in still_missing:
|
||||
assert key in fields, '{0} should still get a field slot'.format(key)
|
||||
assert repr(key) in joined, \
|
||||
'{0} MISSING from panelanalysisform but not named in problems' \
|
||||
.format(key)
|
||||
|
||||
|
||||
def test_length_ft_is_required_and_matches_paf():
|
||||
blocks, _problems = E.page({})
|
||||
fields = {f['key']: f for f in _all_field_blocks(blocks)}
|
||||
assert fields['length_ft']['required'] is True
|
||||
assert fields['width_ft']['required'] is True
|
||||
assert fields['thickness_in']['required'] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- miters
|
||||
|
||||
def test_miter_blank_prints_no_sentence():
|
||||
blocks, _problems = E.page({})
|
||||
sentences = [b['text'] for b in _walk(blocks) if b['kind'] == 'sentence']
|
||||
assert not any('miter not included' in s for s in sentences)
|
||||
|
||||
|
||||
def test_miter_explicit_no_prints_a_sentence_not_a_blank():
|
||||
blocks, _problems = E.page({'left_miter': 'no'})
|
||||
sentences = [b['text'] for b in _walk(blocks) if b['kind'] == 'sentence']
|
||||
assert any('Left miter not included' in s for s in sentences)
|
||||
|
||||
|
||||
def test_miter_explicit_yes_prints_no_sentence():
|
||||
blocks, _problems = E.page({'right_miter': 'yes'})
|
||||
sentences = [b['text'] for b in _walk(blocks) if b['kind'] == 'sentence']
|
||||
assert not any('Right miter not included' in s for s in sentences)
|
||||
|
||||
|
||||
# -------------------------------------------------------------- openings
|
||||
|
||||
def test_openings_rows_absent_degrades_with_a_problem_not_a_crash():
|
||||
blocks, problems = E.page({'openings': ''})
|
||||
assert any('openings_rows' in p for p in problems)
|
||||
# still lays out a sentence, never a broken table
|
||||
for b in _walk(blocks):
|
||||
assert b['kind'] in ALLOWED_KINDS
|
||||
|
||||
|
||||
def test_openings_rows_absent_but_raw_text_present_is_shown_verbatim():
|
||||
blocks, problems = E.page({'openings': '2, 1, 3, 4, Door'})
|
||||
assert any('openings_rows' in p for p in problems)
|
||||
sentences = [b['text'] for b in _walk(blocks) if b['kind'] == 'sentence']
|
||||
assert any('2, 1, 3, 4, Door' in s for s in sentences)
|
||||
|
||||
|
||||
def test_openings_rows_present_builds_grouped_table():
|
||||
rows = [
|
||||
{'type': 'opening', 'pos_along': 8.0 / 12.0,
|
||||
'pos_along_from': 'start', 'length': 6.0 + 10.5 / 12.0,
|
||||
'pos_cross': 2.0 + 11.5 / 12.0, 'pos_cross_from': 'left',
|
||||
'width': 3.0 + 4.5 / 12.0, 'pos_vert': None, 'height': None,
|
||||
'label': ''},
|
||||
{'type': 'reveal', 'pos_along': None, 'pos_along_from': None,
|
||||
'length': None, 'pos_cross': None, 'pos_cross_from': None,
|
||||
'width': None, 'pos_vert': None, 'height': None, 'label': ''},
|
||||
]
|
||||
blocks, problems = E.page({'openings_rows': rows})
|
||||
assert not any('openings_rows' in p for p in problems)
|
||||
tables = [b for b in _walk(blocks) if b['kind'] == 'table']
|
||||
assert len(tables) == 1
|
||||
table = tables[0]
|
||||
assert table['headers'] == ['Type', 'Position', 'Length',
|
||||
'Horizontal Position', 'Width',
|
||||
'Vertical Position', 'Height']
|
||||
assert table['header_groups'] == [('Position along Length', 2),
|
||||
('Position in Cross Section', 4)]
|
||||
assert len(table['rows']) == 2
|
||||
first = table['rows'][0]
|
||||
assert first[0] == 'Opening'
|
||||
assert first[1] == '8" from Start'
|
||||
assert first[3] == "2'-11 1/2\" from Left"
|
||||
assert first[5] == '-' # pos_vert blank -> dash
|
||||
assert first[6] == '-' # height blank -> dash
|
||||
second = table['rows'][1]
|
||||
assert second[0] == 'Reveal'
|
||||
assert second[1] == '-' # blank position -> plain dash, no "from ..."
|
||||
|
||||
|
||||
def test_openings_rows_empty_list_is_a_real_answer_not_missing():
|
||||
blocks, problems = E.page({'openings_rows': []})
|
||||
assert not any('openings_rows' in p for p in problems)
|
||||
tables = [b for b in _walk(blocks) if b['kind'] == 'table']
|
||||
assert len(tables) == 1
|
||||
assert tables[0]['rows'] == []
|
||||
sentences = [b['text'] for b in _walk(blocks) if b['kind'] == 'sentence']
|
||||
assert any('No openings' in s for s in sentences)
|
||||
|
||||
|
||||
def test_unknown_opening_type_is_echoed_not_invented():
|
||||
rows = [{'type': 'blockout', 'pos_along': None, 'pos_along_from': None,
|
||||
'length': None, 'pos_cross': None, 'pos_cross_from': None,
|
||||
'width': None, 'pos_vert': None, 'height': None, 'label': ''}]
|
||||
blocks, _problems = E.page({'openings_rows': rows})
|
||||
table = [b for b in _walk(blocks) if b['kind'] == 'table'][0]
|
||||
assert table['rows'][0][0] == 'Blockout' # title-cased, not remapped
|
||||
|
||||
|
||||
# ------------------------------------------------------------ section props
|
||||
|
||||
def test_section_properties_never_invents_a_derived_row():
|
||||
blocks, problems = E.page({'width_ft': '9', 'thickness_in': '8'})
|
||||
kvs = [b for b in _walk(blocks) if b['kind'] == 'kv']
|
||||
assert kvs, 'expected a Section Properties kv block once width+thickness given'
|
||||
labels = [row[0] for row in kvs[0]['rows']]
|
||||
assert labels == ['Area, in²', 'Width, in', 'Height, in']
|
||||
forbidden = ('Cx', 'Cy', 'Izz', 'Iyy', 'Izy', 'Sb', 'St')
|
||||
for row_label, _value in kvs[0]['rows']:
|
||||
for bad in forbidden:
|
||||
assert not row_label.startswith(bad)
|
||||
joined = ' '.join(problems)
|
||||
for bad in forbidden:
|
||||
assert bad in joined, \
|
||||
'{0} should be named in problems as refused, not silently dropped'.format(bad)
|
||||
|
||||
|
||||
def test_section_properties_values_are_correct():
|
||||
blocks, _problems = E.page({'width_ft': '9', 'thickness_in': '8'})
|
||||
kv = [b for b in _walk(blocks) if b['kind'] == 'kv'][0]
|
||||
rows = dict(kv['rows'])
|
||||
assert rows['Width, in'] == '108'
|
||||
assert rows['Height, in'] == '8'
|
||||
assert rows['Area, in²'] == '864'
|
||||
|
||||
|
||||
def test_section_properties_missing_inputs_never_prints_a_kv_block():
|
||||
blocks, problems = E.page({})
|
||||
kvs = [b for b in _walk(blocks) if b['kind'] == 'kv']
|
||||
assert kvs == []
|
||||
sentences = [b['text'] for b in _walk(blocks) if b['kind'] == 'sentence']
|
||||
assert any('waiting on' in s for s in sentences)
|
||||
assert any('could not be derived' in p for p in problems)
|
||||
|
||||
|
||||
# -------------------------------------------------------------- drawings
|
||||
|
||||
def test_drawings_use_only_named_panelsketch_entry_points():
|
||||
blocks, _problems = E.page({})
|
||||
drawings = _all_drawing_blocks(blocks)
|
||||
assert drawings, 'expected at least the Cross Section drawing'
|
||||
allowed_sources = {'sketch', 'elevation', 'plan', 'section',
|
||||
'weight_diagram'}
|
||||
for d in drawings:
|
||||
assert d['source'] in allowed_sources
|
||||
assert isinstance(d['w'], int) and d['w'] > 0
|
||||
assert isinstance(d['h'], int) and d['h'] > 0
|
||||
assert d['caption'] and d['caption'].strip()
|
||||
|
||||
|
||||
def test_side_and_front_elevation_limitation_is_named_in_problems():
|
||||
blocks, problems = E.page({})
|
||||
captions = [d['caption'] for d in _all_drawing_blocks(blocks)]
|
||||
assert 'Side Elevation' in captions
|
||||
assert 'Front Elevation' in captions
|
||||
assert 'Cross Section' in captions
|
||||
assert any('same combined image' in p for p in problems)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ no mutation
|
||||
|
||||
def test_values_dict_is_never_mutated():
|
||||
values = {'length_ft': '107', 'width_ft': '9', 'thickness_in': '8',
|
||||
'openings': 'garbage'}
|
||||
before = dict(values)
|
||||
E.page(values)
|
||||
assert values == before
|
||||
|
||||
|
||||
def test_page_never_raises_on_garbage_values():
|
||||
garbage = {
|
||||
'width_ft': 'not-a-number', 'thickness_in': [1, 2, 3],
|
||||
'openings_rows': 'not-a-list', 'left_miter': object(),
|
||||
'openings': None,
|
||||
}
|
||||
blocks, problems = E.page(garbage)
|
||||
assert isinstance(blocks, list)
|
||||
assert isinstance(problems, list)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- utilities
|
||||
|
||||
def test_feet_str_formats_like_eriksson():
|
||||
assert E._feet_str(8.0 / 12.0) == '8"'
|
||||
assert E._feet_str(0.0) == '0"'
|
||||
assert E._feet_str(20.0 + 8.0 / 12.0) == "20'-8\""
|
||||
assert E._feet_str(2.0 + 11.5 / 12.0) == "2'-11 1/2\""
|
||||
|
||||
|
||||
def test_dim_or_dash_passes_through_preformatted_strings():
|
||||
assert E._dim_or_dash("2'-11.5\"") == "2'-11.5\""
|
||||
assert E._dim_or_dash('') == '-'
|
||||
assert E._dim_or_dash(None) == '-'
|
||||
|
||||
|
||||
def test_tri_bool_blank_is_none_not_false():
|
||||
assert E._tri_bool('') is None
|
||||
assert E._tri_bool(None) is None
|
||||
assert E._tri_bool('no') is False
|
||||
assert E._tri_bool('yes') is True
|
||||
@@ -0,0 +1,340 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Host tests for `vibe.pa_handling` -- the Panel Analysis form's Handling
|
||||
section, rendered as generic blocks for the WPF page renderer.
|
||||
|
||||
Loaded via the sibling `conftest.py` trick, same as `test_panelanalysisform.py`
|
||||
-- plain CPython, no Revit, no pyRevit. Covers the things the module's own
|
||||
docstring promises: only the fixed block vocabulary comes out, "does not
|
||||
apply" and "not filled in" render as different cell shapes, an unticked
|
||||
stage never drops or re-gates a value the user already typed, every field and
|
||||
matrix row carries a plain-English label and a hint, a missing
|
||||
`panelanalysisform` accessor degrades to a `problems` sentence instead of
|
||||
raising, and `values` is never mutated.
|
||||
"""
|
||||
import copy
|
||||
|
||||
from vibe import knowledge # noqa: F401 (registers the vibe stub)
|
||||
from vibe import panelanalysisform as paf
|
||||
from vibe import pa_handling
|
||||
|
||||
|
||||
# The fixed vocabulary `pa_handling.page()` may emit. Anything else is a bug
|
||||
# -- the generic renderer draws an unknown `kind` as nothing, so a typo here
|
||||
# is a silently missing section rather than a crash, which is exactly why
|
||||
# this is asserted rather than assumed.
|
||||
_KNOWN_KINDS = set([
|
||||
'heading', 'columns', 'field', 'matrix', 'table', 'legend', 'kv',
|
||||
'sentence', 'drawing', 'rule', 'space',
|
||||
])
|
||||
|
||||
|
||||
def _walk(blocks):
|
||||
"""Flatten every block, including nested 'columns' children."""
|
||||
for b in blocks:
|
||||
yield b
|
||||
if b.get('kind') == 'columns':
|
||||
for col in b.get('columns') or []:
|
||||
for sub in _walk(col):
|
||||
yield sub
|
||||
|
||||
|
||||
def _matrix_blocks(blocks):
|
||||
return [b for b in _walk(blocks) if b.get('kind') == 'matrix']
|
||||
|
||||
|
||||
def _field_blocks(blocks):
|
||||
return [b for b in _walk(blocks) if b.get('kind') == 'field']
|
||||
|
||||
|
||||
# --------------------------------------------------------------- fixtures
|
||||
|
||||
def _some_values():
|
||||
"""A plausible in-progress form: a couple of fields filled, most blank."""
|
||||
values = {}
|
||||
for key in paf.stage_field_keys():
|
||||
values[key] = ''
|
||||
for f in paf.FIELDS:
|
||||
values[f['key']] = ''
|
||||
values['weight_lb'] = '32000'
|
||||
values[paf.stage_field_key('Stripping', 'include')] = 'yes'
|
||||
values[paf.stage_field_key('Stripping', 'load_multiplier')] = '1.4'
|
||||
values[paf.stage_field_key('Primary Erection', 'rigging')] = 'Auto'
|
||||
values[paf.stage_field_key('Primary Erection', 'sling_angle')] = '60'
|
||||
# A stage the user switched OFF but already typed a value into --
|
||||
# exactly the case the "unticked stage keeps values" rule protects.
|
||||
values[paf.stage_field_key('Secondary Erection', 'include')] = 'no'
|
||||
values[paf.stage_field_key('Secondary Erection', 'load_multiplier')] = '2.0'
|
||||
return values
|
||||
|
||||
|
||||
# --------------------------------------------------------------- vocabulary
|
||||
|
||||
def test_every_block_kind_is_known():
|
||||
# `problems` is expected to be non-empty here -- see the module
|
||||
# docstring's list of gaps (no centroid, no top-edge-lifter field, no
|
||||
# dunnage field, no lifting-configuration drawing source). Those are
|
||||
# honest, permanent gaps, not bugs; this test only checks the block
|
||||
# vocabulary itself.
|
||||
blocks, _problems = pa_handling.page(_some_values())
|
||||
for b in _walk(blocks):
|
||||
assert 'kind' in b
|
||||
assert b['kind'] in _KNOWN_KINDS, b
|
||||
|
||||
|
||||
def test_empty_values_still_renders_known_kinds_only():
|
||||
values = dict((k, '') for k in paf.stage_field_keys())
|
||||
for f in paf.FIELDS:
|
||||
values[f['key']] = ''
|
||||
blocks, _problems = pa_handling.page(values)
|
||||
for b in _walk(blocks):
|
||||
assert b['kind'] in _KNOWN_KINDS, b
|
||||
|
||||
|
||||
def test_page_never_raises_on_none_values():
|
||||
# page() must degrade, never raise, even with nothing handed in.
|
||||
blocks, problems = pa_handling.page(None)
|
||||
assert isinstance(blocks, list)
|
||||
assert isinstance(problems, list)
|
||||
|
||||
|
||||
# ---------------------------------------------------- not-applicable vs blank
|
||||
|
||||
def test_stage_matrix_shape():
|
||||
blocks, _problems = pa_handling.page(_some_values())
|
||||
matrices = _matrix_blocks(blocks)
|
||||
assert len(matrices) == 1
|
||||
m = matrices[0]
|
||||
assert m['columns'] == list(paf.STAGES)
|
||||
assert len(m['rows']) == len(paf.STAGE_OPTIONS)
|
||||
for row in m['rows']:
|
||||
assert len(row['cells']) == len(paf.STAGES)
|
||||
|
||||
|
||||
def test_erection_only_option_is_na_for_stripping_and_transportation():
|
||||
blocks, _problems = pa_handling.page(_some_values())
|
||||
m = _matrix_blocks(blocks)[0]
|
||||
columns = m['columns']
|
||||
rigging_row = next(r for r in m['rows']
|
||||
if r['cells'] is not None and
|
||||
'rigging configuration' in r['label'].lower())
|
||||
for stage_name in ('Stripping', 'Transportation'):
|
||||
idx = columns.index(stage_name)
|
||||
assert rigging_row['cells'][idx] == {'na': True}
|
||||
for stage_name in ('Primary Erection', 'Secondary Erection'):
|
||||
idx = columns.index(stage_name)
|
||||
cell = rigging_row['cells'][idx]
|
||||
assert cell != {'na': True}
|
||||
assert cell is not None
|
||||
assert 'editor' in cell
|
||||
|
||||
|
||||
def test_na_cell_and_unfilled_cell_render_differently():
|
||||
blocks, _problems = pa_handling.page(_some_values())
|
||||
m = _matrix_blocks(blocks)[0]
|
||||
columns = m['columns']
|
||||
rigging_row = next(r for r in m['rows']
|
||||
if 'rigging configuration' in r['label'].lower())
|
||||
load_mult_row = next(r for r in m['rows']
|
||||
if 'load multiplier' in r['label'].lower())
|
||||
na_cell = rigging_row['cells'][columns.index('Stripping')]
|
||||
# Load Multiplier applies to every stage, so Stripping's cell there is
|
||||
# an ordinary (currently unfilled, from the caller's point of view)
|
||||
# editable cell -- shape must differ from the NA cell above.
|
||||
unfilled_cell = load_mult_row['cells'][columns.index('Transportation')]
|
||||
assert na_cell == {'na': True}
|
||||
assert 'na' not in unfilled_cell
|
||||
assert unfilled_cell['editor'] == 'num'
|
||||
|
||||
|
||||
def test_include_row_never_na():
|
||||
blocks, _problems = pa_handling.page(_some_values())
|
||||
m = _matrix_blocks(blocks)[0]
|
||||
include_row = next(r for r in m['rows'] if 'include' in r['label'].lower())
|
||||
for cell in include_row['cells']:
|
||||
assert cell != {'na': True}
|
||||
assert cell['editor'] == 'check'
|
||||
|
||||
|
||||
# ------------------------------------------- unticked stage keeps its values
|
||||
|
||||
def test_excluded_stage_cell_still_present_and_editable():
|
||||
"""Secondary Erection is switched off in `_some_values()` but its
|
||||
load_multiplier was typed in; the matrix must still offer that cell as
|
||||
an ordinary editable cell (never NA, never dropped) -- the caller's
|
||||
values dict is what actually decides required-ness at validate() time,
|
||||
not this block, and the cell key must still be there for the renderer
|
||||
to read/write against."""
|
||||
values = _some_values()
|
||||
blocks, _problems = pa_handling.page(values)
|
||||
m = _matrix_blocks(blocks)[0]
|
||||
columns = m['columns']
|
||||
load_mult_row = next(r for r in m['rows'] if 'load multiplier' in
|
||||
r['label'].lower())
|
||||
cell = load_mult_row['cells'][columns.index('Secondary Erection')]
|
||||
assert cell != {'na': True}
|
||||
assert cell is not None
|
||||
assert cell['key'] == paf.stage_field_key('Secondary Erection',
|
||||
'load_multiplier')
|
||||
# And the value the user typed is still sitting in `values`, untouched.
|
||||
assert values[cell['key']] == '2.0'
|
||||
|
||||
|
||||
def test_values_dict_is_never_mutated():
|
||||
values = _some_values()
|
||||
before = copy.deepcopy(values)
|
||||
pa_handling.page(values)
|
||||
assert values == before
|
||||
|
||||
|
||||
# --------------------------------------------------------- labels and hints
|
||||
|
||||
def test_every_field_block_has_label_and_hint():
|
||||
blocks, _problems = pa_handling.page(_some_values())
|
||||
for f in _field_blocks(blocks):
|
||||
assert f.get('label'), f
|
||||
assert f.get('hint'), f
|
||||
|
||||
|
||||
def test_every_matrix_row_has_label_and_hint():
|
||||
blocks, _problems = pa_handling.page(_some_values())
|
||||
m = _matrix_blocks(blocks)[0]
|
||||
for row in m['rows']:
|
||||
assert row.get('label'), row
|
||||
# 'include' is the only row Eriksson gives a very short help line
|
||||
# to, but every STAGE_OPTIONS row does carry one -- assert it rides
|
||||
# through rather than getting dropped in translation.
|
||||
assert row.get('hint'), row
|
||||
|
||||
|
||||
def test_symbol_rides_beside_label_not_instead_of_it():
|
||||
blocks, _problems = pa_handling.page(_some_values())
|
||||
m = _matrix_blocks(blocks)[0]
|
||||
load_mult_row = next(r for r in m['rows'] if 'load multiplier' in
|
||||
r['label'].lower())
|
||||
# The symbol (x self-weight) must be ADDITIONAL to the plain words, per
|
||||
# the em-dash join `_row_label` uses -- never a bare symbol standing in
|
||||
# for the label.
|
||||
assert load_mult_row['label'].lower().startswith('load multiplier')
|
||||
assert u'—' in load_mult_row['label'] # em dash
|
||||
|
||||
|
||||
# --------------------------------------------------- missing-accessor degrade
|
||||
|
||||
def test_missing_stage_accessor_degrades_to_problem_not_exception():
|
||||
# Build the values dict BEFORE removing STAGES -- `stage_field_keys()`
|
||||
# itself reads the module-global `STAGES`, so this must not depend on
|
||||
# the attribute we are about to take away.
|
||||
values = _some_values()
|
||||
had_stages = hasattr(paf, 'STAGES')
|
||||
saved = getattr(paf, 'STAGES', None)
|
||||
try:
|
||||
if had_stages:
|
||||
delattr(paf, 'STAGES')
|
||||
blocks, problems = pa_handling.page(values)
|
||||
assert isinstance(blocks, list)
|
||||
assert any('STAGES' in p for p in problems)
|
||||
# The rest of the page still renders around the hole.
|
||||
assert any(b.get('kind') == 'heading' and b.get('text') == 'Handling'
|
||||
for b in blocks)
|
||||
finally:
|
||||
if had_stages:
|
||||
paf.STAGES = saved
|
||||
|
||||
|
||||
def test_missing_stage_field_key_degrades_to_problem():
|
||||
values = _some_values()
|
||||
had = hasattr(paf, 'stage_field_key')
|
||||
saved = getattr(paf, 'stage_field_key', None)
|
||||
try:
|
||||
if had:
|
||||
delattr(paf, 'stage_field_key')
|
||||
blocks, problems = pa_handling.page(values)
|
||||
assert any('stage_field_key' in p for p in problems)
|
||||
assert not _matrix_blocks(blocks)
|
||||
finally:
|
||||
if had:
|
||||
paf.stage_field_key = saved
|
||||
|
||||
|
||||
def test_missing_weight_field_degrades_to_problem():
|
||||
had = 'FIELDS' in vars(paf)
|
||||
saved = paf.FIELDS
|
||||
try:
|
||||
paf.FIELDS = [f for f in paf.FIELDS if f['key'] != 'weight_lb']
|
||||
blocks, problems = pa_handling.page(_some_values())
|
||||
assert any('weight_lb' in p for p in problems)
|
||||
# kv block still renders (weight row falls back to the "not
|
||||
# entered" sentence), it does not vanish or raise.
|
||||
kvs = [b for b in _walk(blocks) if b['kind'] == 'kv']
|
||||
assert kvs
|
||||
finally:
|
||||
if had:
|
||||
paf.FIELDS = saved
|
||||
|
||||
|
||||
def test_no_top_edge_lifter_or_dunnage_field_is_a_known_gap():
|
||||
"""These are genuinely absent from panelanalysisform today (only
|
||||
face_lifters / edge_lifters exist) -- confirm the page says so rather
|
||||
than inventing a field or silently going blank."""
|
||||
blocks, problems = pa_handling.page(_some_values())
|
||||
assert any('top edge lifters' in p for p in problems)
|
||||
assert any('dunnage' in p for p in problems)
|
||||
sentences = [b['text'] for b in _walk(blocks) if b['kind'] == 'sentence']
|
||||
assert 'Design contains no top edge lifters.' in sentences
|
||||
assert 'Design contains no dunnage entered.' in sentences
|
||||
|
||||
|
||||
# --------------------------------------------------------------- real keys
|
||||
|
||||
def test_matrix_cell_keys_match_panelanalysisform_stage_field_key():
|
||||
blocks, _problems = pa_handling.page(_some_values())
|
||||
m = _matrix_blocks(blocks)[0]
|
||||
for opt, row in zip(paf.STAGE_OPTIONS, m['rows']):
|
||||
opt_key = opt[0]
|
||||
for stage, cell in zip(m['columns'], row['cells']):
|
||||
if cell is None or cell.get('na'):
|
||||
continue
|
||||
assert cell['key'] == paf.stage_field_key(stage, opt_key)
|
||||
|
||||
|
||||
def test_load_factor_and_lifter_fields_use_real_keys():
|
||||
blocks, _problems = pa_handling.page(_some_values())
|
||||
keys = set(f['key'] for f in _field_blocks(blocks))
|
||||
assert 'weight_lb' in keys
|
||||
assert 'self_weight_factor' in keys
|
||||
assert 'face_lifters' in keys
|
||||
assert 'edge_lifters' in keys
|
||||
|
||||
|
||||
def test_drawing_blocks_name_real_sibling_sources_only():
|
||||
blocks, _problems = pa_handling.page(_some_values())
|
||||
drawings = [b for b in _walk(blocks) if b['kind'] == 'drawing']
|
||||
sources = set(d['source'] for d in drawings)
|
||||
# 'plan' and 'weight_diagram' are the two panelsketch entry points the
|
||||
# sibling agent added; pa_handling must never invent a third.
|
||||
assert sources <= set(['plan', 'weight_diagram'])
|
||||
assert 'plan' in sources
|
||||
for d in drawings:
|
||||
assert d.get('caption')
|
||||
assert isinstance(d.get('w'), int)
|
||||
assert isinstance(d.get('h'), int)
|
||||
|
||||
|
||||
def test_legend_groups_match_documented_vocabulary():
|
||||
blocks, _problems = pa_handling.page(_some_values())
|
||||
legends = [b for b in _walk(blocks) if b['kind'] == 'legend']
|
||||
assert len(legends) == 1
|
||||
names = [g[0] for g in legends[0]['groups']]
|
||||
assert names == ['Shapes', 'Fills']
|
||||
|
||||
|
||||
def test_import_failure_degrades_cleanly(monkeypatch):
|
||||
import vibe.pa_handling as mod
|
||||
|
||||
def _boom():
|
||||
return None, 'vibe.panelanalysisform could not be imported (boom)'
|
||||
|
||||
monkeypatch.setattr(mod, '_import_paf', _boom)
|
||||
blocks, problems = mod.page(_some_values())
|
||||
assert problems == ['vibe.panelanalysisform could not be imported (boom)']
|
||||
assert any(b['kind'] == 'heading' for b in blocks)
|
||||
@@ -0,0 +1,278 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for `vibe.pa_reinforcement.page` -- the Reinforcement section of the
|
||||
Panel Analysis input form.
|
||||
|
||||
Runs on plain CPython via `tests/conftest.py`'s import trick (no Revit, no
|
||||
pyRevit, no WPF -- Revit is NOT running for this suite). Covers the doctrine
|
||||
the module docstring commits to:
|
||||
|
||||
* only block kinds from the fixed vocabulary are ever emitted;
|
||||
* every 'field' block carries a plain-English label and a hint;
|
||||
* every optional sub-block (strands / rebar / mesh / interrupts) states an
|
||||
empty-case sentence when it has nothing, never a blank;
|
||||
* a missing `values['strand_rows']` degrades to the raw-text fallback with
|
||||
a `problems` entry, rather than raising;
|
||||
* no summary quantity (area, cgx, ex, ey) is ever invented;
|
||||
* `values` is never mutated.
|
||||
"""
|
||||
import copy
|
||||
|
||||
from vibe import pa_reinforcement as par
|
||||
from vibe import panelanalysisform as pf
|
||||
|
||||
|
||||
_KNOWN_KINDS = set(['heading', 'columns', 'field', 'table', 'kv', 'sentence',
|
||||
'drawing', 'rule', 'space'])
|
||||
|
||||
|
||||
def _walk(blocks):
|
||||
"""Yield every block, recursing into 'columns' children."""
|
||||
for b in blocks:
|
||||
yield b
|
||||
if b.get('kind') == 'columns':
|
||||
for col in b.get('columns') or []:
|
||||
for sub in _walk(col):
|
||||
yield sub
|
||||
|
||||
|
||||
def _fields(blocks):
|
||||
return [b for b in _walk(blocks) if b.get('kind') == 'field']
|
||||
|
||||
|
||||
def _sentences(blocks):
|
||||
return [b['text'] for b in _walk(blocks) if b.get('kind') == 'sentence']
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- shape
|
||||
|
||||
def test_only_known_block_kinds_empty_values():
|
||||
blocks, problems = par.page({})
|
||||
for b in _walk(blocks):
|
||||
assert b.get('kind') in _KNOWN_KINDS, b
|
||||
|
||||
|
||||
def test_only_known_block_kinds_filled_values():
|
||||
values = {
|
||||
'strand_rail_location': 'Left edge', 'strand_rail_offset_in': '0',
|
||||
'ps_count': '4', 'ps_dia_in': '1/2', 'ps_pull_lb': '30000',
|
||||
'ps_loss_pct': '8', 'ps_rows': '2 @ 2\n2 @ 6 @ 70',
|
||||
'mild_as': '#4 @ 12" o.c.', 'mesh': '6x6 W1.4xW1.4',
|
||||
}
|
||||
blocks, problems = par.page(values)
|
||||
for b in _walk(blocks):
|
||||
assert b.get('kind') in _KNOWN_KINDS, b
|
||||
|
||||
|
||||
def test_page_never_raises_on_garbage_values():
|
||||
garbage = {'strand_rows': 'not a list', 'ps_rows': 12345,
|
||||
'ps_dia_in': None, 'mild_as': object()}
|
||||
blocks, problems = par.page(garbage)
|
||||
assert isinstance(blocks, list)
|
||||
assert isinstance(problems, list)
|
||||
|
||||
|
||||
def test_page_never_raises_on_none_values():
|
||||
blocks, problems = par.page(None)
|
||||
assert isinstance(blocks, list)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- field labels
|
||||
|
||||
def test_every_field_has_plain_english_label_and_hint():
|
||||
blocks, _ = par.page({'ps_rows': '2 @ 2'})
|
||||
fields = _fields(blocks)
|
||||
assert fields, 'expected at least one field block'
|
||||
for f in fields:
|
||||
assert f['label'] and isinstance(f['label'], str)
|
||||
# A plain-English label is not just the raw key repeated back.
|
||||
assert f['label'] != f['key']
|
||||
assert f['hint'], 'field {0!r} has no hint'.format(f['key'])
|
||||
|
||||
|
||||
def test_field_blocks_reference_real_panelanalysisform_keys():
|
||||
blocks, problems = par.page({})
|
||||
known_keys = set(f['key'] for f in pf.FIELDS)
|
||||
for f in _fields(blocks):
|
||||
assert f['key'] in known_keys, (
|
||||
'{0!r} is not a real panelanalysisform key'.format(f['key']))
|
||||
|
||||
|
||||
# ---------------------------------------------------------- empty-case text
|
||||
|
||||
def test_strands_blank_prints_no_prestressing_sentence():
|
||||
blocks, _ = par.page({})
|
||||
assert 'Design contains no prestressing' in _sentences(blocks)
|
||||
|
||||
|
||||
def test_rebar_blank_prints_no_rebar_sentence():
|
||||
blocks, _ = par.page({})
|
||||
assert 'Design contains no rebar' in _sentences(blocks)
|
||||
|
||||
|
||||
def test_mesh_blank_prints_no_mesh_sentence():
|
||||
blocks, _ = par.page({})
|
||||
assert 'Design contains no mesh' in _sentences(blocks)
|
||||
|
||||
|
||||
def test_interrupts_always_could_not_judge_never_no_interrupts():
|
||||
"""No field exists for interrupts, so this must never claim "none"."""
|
||||
blocks, problems = par.page({})
|
||||
sentences = _sentences(blocks)
|
||||
assert any('could not judge' in s.lower() for s in sentences)
|
||||
assert 'Design contains no interrupts' not in sentences
|
||||
assert any('interrupts' in p.lower() for p in problems)
|
||||
|
||||
|
||||
def test_rebar_filled_has_no_empty_sentence():
|
||||
blocks, _ = par.page({'mild_as': '#4 @ 12" o.c. each face'})
|
||||
assert 'Design contains no rebar' not in _sentences(blocks)
|
||||
|
||||
|
||||
def test_mesh_filled_has_no_empty_sentence():
|
||||
blocks, _ = par.page({'mesh': '6x6 W1.4xW1.4'})
|
||||
assert 'Design contains no mesh' not in _sentences(blocks)
|
||||
|
||||
|
||||
def test_strands_filled_has_no_empty_sentence_and_has_table():
|
||||
blocks, _ = par.page({'ps_rows': '2 @ 2 @ 70', 'ps_dia_in': '1/2',
|
||||
'ps_pull_lb': '30000'})
|
||||
assert 'Design contains no prestressing' not in _sentences(blocks)
|
||||
tables = [b for b in blocks if b['kind'] == 'table']
|
||||
assert len(tables) == 1
|
||||
assert tables[0]['rows']
|
||||
|
||||
|
||||
# ------------------------------------------------------- strand_rows intake
|
||||
|
||||
def test_structured_strand_rows_used_when_present_no_fallback_problem():
|
||||
values = {
|
||||
'strand_rows': [{'count': 2, 'depth_in': 2.0, 'pull_pct': 70.0}],
|
||||
'ps_dia_in': '1/2', 'ps_pull_lb': '30000',
|
||||
}
|
||||
blocks, problems = par.page(values)
|
||||
table = [b for b in blocks if b['kind'] == 'table'][0]
|
||||
assert table['rows'] == [['1/2', '0.153', '1/2', '2', '70%', '2"']]
|
||||
assert not any('parsed the raw strand-group text' in p for p in problems)
|
||||
|
||||
|
||||
def test_missing_strand_rows_falls_back_to_ps_rows_text_with_problem():
|
||||
values = {'ps_rows': '2 @ 2 @ 70', 'ps_dia_in': '1/2',
|
||||
'ps_pull_lb': '30000'}
|
||||
blocks, problems = par.page(values)
|
||||
table = [b for b in blocks if b['kind'] == 'table'][0]
|
||||
assert table['rows'] and table['rows'][0][3] == '2'
|
||||
assert any('ps_rows' in p for p in problems)
|
||||
|
||||
|
||||
def test_missing_strand_rows_and_blank_ps_rows_is_quiet_no_prestressing():
|
||||
"""Absent structured input AND blank text is a real "nothing typed yet"
|
||||
answer, not an error -- must not appear in `problems`."""
|
||||
blocks, problems = par.page({})
|
||||
assert 'Design contains no prestressing' in _sentences(blocks)
|
||||
assert not any('ps_rows' in p or 'strand_rows' in p for p in problems)
|
||||
|
||||
|
||||
def test_malformed_strand_rows_text_reported_not_silently_dropped():
|
||||
blocks, problems = par.page({'ps_rows': 'garbage line with no at-sign'})
|
||||
assert any('could not' in p.lower() for p in problems)
|
||||
assert 'Design contains no prestressing' in _sentences(blocks)
|
||||
|
||||
|
||||
def test_malformed_structured_strand_rows_entry_skipped_with_problem():
|
||||
values = {'strand_rows': [{'count': 'oops', 'depth_in': 2.0}]}
|
||||
blocks, problems = par.page(values)
|
||||
assert any('skipped it' in p for p in problems)
|
||||
assert 'Design contains no prestressing' in _sentences(blocks)
|
||||
|
||||
|
||||
# --------------------------------------------------------- never fabricated
|
||||
|
||||
def test_unknown_strand_size_refused_not_guessed():
|
||||
values = {'ps_rows': '2 @ 2', 'ps_dia_in': 'made-up-size',
|
||||
'ps_pull_lb': '30000'}
|
||||
blocks, problems = par.page(values)
|
||||
table = [b for b in blocks if b['kind'] == 'table'][0]
|
||||
for row in table['rows']:
|
||||
assert row[1] == '?' # area column never guessed
|
||||
assert any('no section area is known' in p for p in problems)
|
||||
kvs = [b for b in blocks if b['kind'] == 'kv']
|
||||
assert not kvs # no Aps/cgy summary without an area
|
||||
|
||||
|
||||
def test_strand_summary_never_prints_cgx_ex_ey_as_numbers():
|
||||
values = {'ps_rows': '2 @ 2 @ 70\n2 @ 6', 'ps_dia_in': '1/2',
|
||||
'ps_pull_lb': '30000'}
|
||||
blocks, problems = par.page(values)
|
||||
kv = [b for b in blocks if b['kind'] == 'kv'][0]
|
||||
rows_by_label = dict(kv['rows'])
|
||||
assert 'not shown' in rows_by_label['cgx, in']
|
||||
assert 'not shown' in rows_by_label['ex, in']
|
||||
assert 'not shown' in rows_by_label['ey, in']
|
||||
# Aps and cgy ARE real derived numbers, not refused.
|
||||
assert rows_by_label['Aps, in²'].replace('.', '').isdigit()
|
||||
assert any('cgx' in p and 'not computed' in p for p in problems)
|
||||
|
||||
|
||||
def test_aps_and_cgy_derived_correctly_from_entered_rows():
|
||||
values = {'ps_rows': '2 @ 2\n2 @ 6', 'ps_dia_in': '1/2',
|
||||
'ps_pull_lb': '30000'}
|
||||
blocks, _ = par.page(values)
|
||||
kv = [b for b in blocks if b['kind'] == 'kv'][0]
|
||||
rows_by_label = dict(kv['rows'])
|
||||
area = 0.153
|
||||
expected_aps = 4 * area
|
||||
expected_cgy = (2 * 2.0 + 2 * 6.0) / 4.0
|
||||
assert abs(float(rows_by_label['Aps, in²']) - expected_aps) < 1e-6
|
||||
assert abs(float(rows_by_label['cgy, in (depth to CG)']) -
|
||||
expected_cgy) < 1e-6
|
||||
|
||||
|
||||
def test_no_spacing_column_in_strand_table():
|
||||
"""Horizontal strand spacing is not an input on this form -- the table
|
||||
must not imply it is known."""
|
||||
blocks, _ = par.page({'ps_rows': '2 @ 2', 'ps_dia_in': '1/2',
|
||||
'ps_pull_lb': '30000'})
|
||||
table = [b for b in blocks if b['kind'] == 'table'][0]
|
||||
assert not any('spac' in h.lower() for h in table['headers'])
|
||||
|
||||
|
||||
# --------------------------------------------------------------- no mutation
|
||||
|
||||
def test_values_dict_not_mutated():
|
||||
values = {
|
||||
'strand_rail_location': 'Left edge', 'strand_rail_offset_in': '0',
|
||||
'ps_rows': '2 @ 2 @ 70', 'ps_dia_in': '1/2', 'ps_pull_lb': '30000',
|
||||
'mild_as': '', 'mesh': '',
|
||||
}
|
||||
before = copy.deepcopy(values)
|
||||
par.page(values)
|
||||
assert values == before
|
||||
|
||||
|
||||
def test_missing_field_key_reports_problem_not_crash(monkeypatch):
|
||||
"""A field this module asks panelanalysisform for that no longer exists
|
||||
there degrades to a MISSING problem, never a crash."""
|
||||
real_spec = pf._spec
|
||||
|
||||
def _fake_spec(key):
|
||||
if key == 'mesh':
|
||||
return None
|
||||
return real_spec(key)
|
||||
|
||||
monkeypatch.setattr(pf, '_spec', _fake_spec)
|
||||
blocks, problems = par.page({})
|
||||
assert any('mesh' in p and 'MISSING' in p for p in problems)
|
||||
# The section still renders -- heading + empty sentence, just no field.
|
||||
assert 'Design contains no mesh' in _sentences(blocks)
|
||||
|
||||
|
||||
# -------------------------------------------------------------- drawing tag
|
||||
|
||||
def test_drawing_block_names_section_source_never_calls_panelsketch():
|
||||
blocks, _ = par.page({})
|
||||
drawings = [b for b in blocks if b['kind'] == 'drawing']
|
||||
assert len(drawings) == 1
|
||||
assert drawings[0]['source'] == 'section'
|
||||
assert drawings[0]['caption']
|
||||
assert isinstance(drawings[0]['w'], int)
|
||||
assert isinstance(drawings[0]['h'], int)
|
||||
@@ -0,0 +1,312 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for `vibe.pa_structural.page()` -- the Structural Model section.
|
||||
|
||||
Runs on plain host CPython via `conftest.py`'s `vibe` stub; no Revit
|
||||
involved and none was running for this test run. Checks the disciplines
|
||||
`pa_structural.py`'s docstring commits to:
|
||||
|
||||
* only block kinds from the fixed vocabulary are ever emitted;
|
||||
* every field carries a plain-English label and a hint;
|
||||
* every optional block states a sentence for its empty case, and a
|
||||
DIFFERENT sentence when the key is missing from `values` altogether
|
||||
("could not judge" vs "judged, found nothing" must never collapse);
|
||||
* no wind/seismic field ever carries a computed default -- they come
|
||||
back exactly what was typed, or blank;
|
||||
* a key `panelanalysisform` does not (yet) define degrades to a
|
||||
`problems` entry instead of raising or inventing a value;
|
||||
* `values` is never mutated.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from vibe import pa_structural
|
||||
|
||||
|
||||
_BLOCK_KINDS = set(['heading', 'columns', 'field', 'table', 'kv', 'sentence',
|
||||
'drawing', 'rule', 'space'])
|
||||
_EDITOR_KINDS = set(['text', 'num', 'check', 'choice'])
|
||||
|
||||
|
||||
def _walk(blocks):
|
||||
"""Yield every block, recursing into 'columns'."""
|
||||
for b in blocks:
|
||||
yield b
|
||||
if b.get('kind') == 'columns':
|
||||
for col in b['columns']:
|
||||
for sub in _walk(col):
|
||||
yield sub
|
||||
|
||||
|
||||
def _all_field_keys(blocks):
|
||||
return [b['key'] for b in _walk(blocks) if b.get('kind') == 'field']
|
||||
|
||||
|
||||
def _sentences(blocks):
|
||||
return [b['text'] for b in _walk(blocks) if b.get('kind') == 'sentence']
|
||||
|
||||
|
||||
# --------------------------------------------------------------- structure
|
||||
|
||||
def test_only_known_block_kinds():
|
||||
blocks, _problems = pa_structural.page({})
|
||||
for b in _walk(blocks):
|
||||
assert b['kind'] in _BLOCK_KINDS, 'unknown block kind {0!r}'.format(
|
||||
b.get('kind'))
|
||||
|
||||
|
||||
def test_top_level_heading_is_structural_model():
|
||||
blocks, _problems = pa_structural.page({})
|
||||
assert blocks[0] == {'kind': 'heading', 'text': 'Structural Model',
|
||||
'level': 1}
|
||||
|
||||
|
||||
def test_never_raises_on_empty_values():
|
||||
blocks, problems = pa_structural.page({})
|
||||
assert isinstance(blocks, list) and blocks
|
||||
assert isinstance(problems, list)
|
||||
|
||||
|
||||
def test_never_raises_on_none_values():
|
||||
blocks, problems = pa_structural.page(None)
|
||||
assert isinstance(blocks, list) and blocks
|
||||
|
||||
|
||||
def test_never_raises_on_garbage_values():
|
||||
# Wrong types, weird keys -- page() must degrade, not crash.
|
||||
blocks, problems = pa_structural.page({
|
||||
'restraints': 12345,
|
||||
'conc_loads': None,
|
||||
'wind_speed_vult': object(),
|
||||
'supports_ft': '',
|
||||
})
|
||||
assert isinstance(blocks, list) and blocks
|
||||
|
||||
|
||||
def test_values_dict_not_mutated():
|
||||
values = {'restraints': '0, 10, 20', 'conc_loads': ''}
|
||||
before = dict(values)
|
||||
pa_structural.page(values)
|
||||
assert values == before
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ fields
|
||||
|
||||
def test_every_field_has_plain_english_label_and_hint():
|
||||
blocks, _problems = pa_structural.page({})
|
||||
fields = [b for b in _walk(blocks) if b.get('kind') == 'field']
|
||||
assert fields, 'expected at least one field block'
|
||||
for f in fields:
|
||||
assert f['label'], 'field {0!r} has no label'.format(f['key'])
|
||||
# A label that is just the raw key (no spaces, all lower/snake) is
|
||||
# not plain English -- every field must carry a written-out label.
|
||||
assert f['label'] != f['key']
|
||||
assert f['hint'], 'field {0!r} has no hint'.format(f['key'])
|
||||
assert f['editor'] in _EDITOR_KINDS
|
||||
assert isinstance(f['required'], bool)
|
||||
assert 'choices' in f
|
||||
if f['choices'] is not None:
|
||||
assert f['editor'] == 'choice'
|
||||
|
||||
|
||||
def test_symbol_is_beside_label_never_instead_of_it():
|
||||
blocks, _problems = pa_structural.page({})
|
||||
fields = [b for b in _walk(blocks) if b.get('kind') == 'field']
|
||||
by_key = dict((f['key'], f) for f in fields)
|
||||
# wind_speed_vult carries a real engineering symbol (Vult, mph) in
|
||||
# panelanalysisform -- the label must still be plain English, not the
|
||||
# symbol standing in for it.
|
||||
f = by_key['wind_speed_vult']
|
||||
assert f['label'].lower() != (f['symbol'] or '').lower()
|
||||
assert 'wind' in f['label'].lower() or 'speed' in f['label'].lower()
|
||||
|
||||
|
||||
def test_required_bearing_field_present():
|
||||
blocks, _problems = pa_structural.page({})
|
||||
fields = [b for b in _walk(blocks) if b.get('kind') == 'field']
|
||||
by_key = dict((f['key'], f) for f in fields)
|
||||
assert by_key['supports_ft']['required'] is True
|
||||
|
||||
|
||||
# -------------------------------------------------------- wind/seismic gate
|
||||
|
||||
def test_no_wind_or_seismic_field_carries_a_default_value():
|
||||
"""The owner's ruling, 2026-09-02: nothing computes wind yet, so every
|
||||
wind/seismic field is blank -- never a pre-filled pressure, Kz, or
|
||||
exposure guess -- regardless of what else is in `values`.
|
||||
"""
|
||||
blocks, _problems = pa_structural.page({
|
||||
'length_ft': '40', 'width_ft': '8', 'thickness_in': '6',
|
||||
})
|
||||
fields = [b for b in _walk(blocks) if b.get('kind') == 'field']
|
||||
for f in fields:
|
||||
if f['key'].startswith('wind_') or f['key'].startswith('seismic_') \
|
||||
or f['key'].startswith('temp_'):
|
||||
assert 'not yet used' in (f['hint'] or '').lower() or \
|
||||
'nothing computes wind' in (f['hint'] or '').lower()
|
||||
|
||||
|
||||
def test_wind_and_seismic_sections_present():
|
||||
blocks, _problems = pa_structural.page({})
|
||||
keys = _all_field_keys(blocks)
|
||||
for expected in ('wind_speed_vult', 'wind_risk_category',
|
||||
'wind_exposure', 'wind_enclosure', 'wind_asce_version',
|
||||
'wind_cc_chapter', 'wind_mwfrs_chapter',
|
||||
'wind_pressure_psf', 'wind_suction_psf', 'seismic_cs'):
|
||||
assert expected in keys
|
||||
|
||||
|
||||
# ------------------------------------------------------- empty-case sentences
|
||||
|
||||
def test_releases_missing_key_says_unknown_not_none():
|
||||
blocks, problems = pa_structural.page({}) # no 'releases' key at all
|
||||
sentences = _sentences(blocks)
|
||||
assert any('unknown' in s.lower() and 'release' in s.lower()
|
||||
for s in sentences)
|
||||
assert not any(s == 'Design contains no releases.' for s in sentences)
|
||||
assert any('releases' in p for p in problems)
|
||||
|
||||
|
||||
def test_releases_blank_key_says_design_contains_no_releases():
|
||||
blocks, problems = pa_structural.page({'releases': ' '})
|
||||
sentences = _sentences(blocks)
|
||||
assert 'Design contains no releases.' in sentences
|
||||
|
||||
|
||||
def test_releases_filled_key_renders_a_table_not_a_sentence():
|
||||
blocks, _problems = pa_structural.page({'releases': 'Tie 2, sideways'})
|
||||
tables = [b for b in _walk(blocks) if b.get('kind') == 'table']
|
||||
assert any('Release' in t['headers'] for t in tables)
|
||||
|
||||
|
||||
def test_exterior_soil_missing_vs_blank_are_different_sentences():
|
||||
blocks_missing, _ = pa_structural.page({})
|
||||
blocks_blank, _ = pa_structural.page({'ext_soil_loads': ''})
|
||||
sm = _sentences(blocks_missing)
|
||||
sb = _sentences(blocks_blank)
|
||||
assert 'Design contains no exterior soil loads.' in sb
|
||||
assert 'Design contains no exterior soil loads.' not in sm
|
||||
assert any('exterior soil loads' in s.lower() and 'unknown' in s.lower()
|
||||
for s in sm)
|
||||
|
||||
|
||||
def test_interior_soil_missing_vs_blank_are_different_sentences():
|
||||
blocks_missing, _ = pa_structural.page({})
|
||||
blocks_blank, _ = pa_structural.page({'int_soil_loads': ''})
|
||||
sm = _sentences(blocks_missing)
|
||||
sb = _sentences(blocks_blank)
|
||||
assert 'Design contains no interior soil loads.' in sb
|
||||
assert 'Design contains no interior soil loads.' not in sm
|
||||
|
||||
|
||||
def test_design_strips_blank_matches_eriksson_wording():
|
||||
blocks, _problems = pa_structural.page({'design_strips_rows': ''})
|
||||
sentences = _sentences(blocks)
|
||||
assert ('No design strips are defined; the entire cross section will '
|
||||
'be used.') in sentences
|
||||
|
||||
|
||||
def test_additional_self_weight_blank_vs_missing():
|
||||
blocks_missing, _ = pa_structural.page({})
|
||||
blocks_blank, _ = pa_structural.page({'addl_sw_magnitude_plf': ''})
|
||||
sm = _sentences(blocks_missing)
|
||||
sb = _sentences(blocks_blank)
|
||||
assert any('additional self weight' in s.lower() and 'unknown' in s.lower()
|
||||
for s in sm)
|
||||
assert any("panel's own concrete weight" in s.lower() for s in sb)
|
||||
assert sm != sb
|
||||
|
||||
|
||||
def test_concentrated_loads_blank_vs_missing():
|
||||
blocks_missing, _ = pa_structural.page({})
|
||||
blocks_blank, _ = pa_structural.page({'conc_loads': ''})
|
||||
sm = _sentences(blocks_missing)
|
||||
sb = _sentences(blocks_blank)
|
||||
assert any('point loads' in s.lower() and 'unknown' in s.lower()
|
||||
for s in sm)
|
||||
assert 'No concentrated point loads entered -- design assumes none.' \
|
||||
in sb
|
||||
|
||||
|
||||
def test_restraints_table_missing_vs_blank_vs_filled():
|
||||
blocks_missing, _ = pa_structural.page({})
|
||||
blocks_blank, _ = pa_structural.page({'restraints': ''})
|
||||
blocks_filled, _ = pa_structural.page({'restraints': '0, 10, 20'})
|
||||
|
||||
sm = _sentences(blocks_missing)
|
||||
assert any('restraint' in s.lower() and 'unknown' in s.lower()
|
||||
for s in sm)
|
||||
|
||||
sb = _sentences(blocks_blank)
|
||||
assert 'Design contains no restraints.' in sb
|
||||
|
||||
tables = [b for b in _walk(blocks_filled) if b.get('kind') == 'table']
|
||||
restraint_tables = [t for t in tables if 'Elevation (ft)' in t['headers']]
|
||||
assert restraint_tables
|
||||
assert len(restraint_tables[0]['rows']) == 3
|
||||
|
||||
|
||||
def test_restraint_rows_key_preferred_over_plain_restraints():
|
||||
blocks, _problems = pa_structural.page({
|
||||
'restraints': '0, 10',
|
||||
'restraint_rows': "0, Pin, y, y, -\n10'-1, Tieback, n, y, 12",
|
||||
})
|
||||
tables = [b for b in _walk(blocks) if b.get('kind') == 'table']
|
||||
restraint_tables = [t for t in tables if 'Elevation (ft)' in t['headers']]
|
||||
assert len(restraint_tables[0]['rows']) == 2
|
||||
# Type column (index 2) came from restraint_rows, not left blank the
|
||||
# way the plain-elevations fallback would leave it.
|
||||
assert restraint_tables[0]['rows'][0][2] == 'Pin'
|
||||
|
||||
|
||||
# ---------------------------------------------------- missing-key degrading
|
||||
|
||||
def test_missing_panelanalysisform_keys_become_problems_not_exceptions():
|
||||
blocks, problems = pa_structural.page({})
|
||||
assert problems, 'expected at least one problems sentence for the new keys'
|
||||
# every problems entry is a plain sentence, not a traceback fragment
|
||||
for p in problems:
|
||||
assert isinstance(p, str) or isinstance(p, type(u''))
|
||||
assert 'Traceback' not in p
|
||||
|
||||
|
||||
def test_new_keys_still_render_with_a_usable_label():
|
||||
blocks, problems = pa_structural.page({})
|
||||
fields = [b for b in _walk(blocks) if b.get('kind') == 'field']
|
||||
by_key = dict((f['key'], f) for f in fields)
|
||||
# 'temp_ext_f' has no entry in panelanalysisform today -- must still
|
||||
# render with a real label and hint rather than being skipped.
|
||||
f = by_key['temp_ext_f']
|
||||
assert f['label'] == 'Exterior temperature'
|
||||
assert f['hint']
|
||||
|
||||
|
||||
def test_drawing_blocks_name_panelsketch_entry_points_only():
|
||||
blocks, _problems = pa_structural.page({})
|
||||
drawings = [b for b in _walk(blocks) if b.get('kind') == 'drawing']
|
||||
sources = set(d['source'] for d in drawings)
|
||||
assert sources <= set(['elevation', 'plan', 'section', 'weight_diagram'])
|
||||
for d in drawings:
|
||||
assert d['caption']
|
||||
assert isinstance(d['w'], int) and isinstance(d['h'], int)
|
||||
|
||||
|
||||
def test_no_client_or_job_strings_leak_into_the_page():
|
||||
"""Nothing this page emits may carry an identity.
|
||||
|
||||
The obvious version of this test spells the forbidden strings out --
|
||||
and a leak detector that names the secret IS the leak, which is exactly
|
||||
why `.scrub-terms` is gitignored ("so the list of names is not itself
|
||||
the leak"). So this asks about SHAPE instead: an emitted string that
|
||||
looks like a job number, a run date, a source-file name or a licensee
|
||||
line is refused whatever it says.
|
||||
|
||||
The named-terms half of this belongs to `tools/scrub.py`, which reads
|
||||
that private list. This is the half that still works on a machine where
|
||||
the list is absent.
|
||||
"""
|
||||
blocks, problems = pa_structural.page({})
|
||||
text = repr(blocks) + repr(problems)
|
||||
assert not re.search(r'\d{5,}', text) # a job number
|
||||
assert not re.search(r'\d{2}/\d{2}/\d{4}', text) # a run date
|
||||
assert not re.search(r'\.(ewf|rvt)', text, re.I) # a source file
|
||||
assert 'licensee' not in text.lower()
|
||||
@@ -0,0 +1,239 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for `tools/panelanalysis.py` -- the Eriksson-shaped demo page.
|
||||
|
||||
Loaded by path, the same way `test_deckcheck.py` in this folder is: this is
|
||||
host CPython code (it imports `panellift`, dataclasses and typing syntax)
|
||||
sitting in a repository whose extension is IronPython 2.7, and it in turn
|
||||
imports `tools/deckcheck.py` for the off-Revit knowledge merge, which is
|
||||
where the `vibe` stub actually gets registered. Pre-importing `vibe.
|
||||
knowledge` here is what `test_deckcheck.py` does for the same reason: the
|
||||
stub has to exist in `sys.modules` before anything tries `import vibe.*`,
|
||||
and doing it in one place keeps every test file in this folder agreeing on
|
||||
how.
|
||||
|
||||
These are cheap smoke tests, not a full walk of every section -- the four
|
||||
things the brief asked to be inexpensive to check:
|
||||
|
||||
1. the renderer produces the section skeleton from a fixture deck
|
||||
2. populated-vs-absent field states carry the right source chip
|
||||
3. zero CJK on the page (the same guard `panellift`'s own report tests
|
||||
use, `test_checks.py`'s "demo-safe: no CJK" block)
|
||||
4. no crash on a deck with no spec -- degrades to model-only fields,
|
||||
and says so on the page rather than silently
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))))
|
||||
TOOLS = os.path.join(ROOT, 'tools')
|
||||
PANELLIFT_SRC = os.path.join(ROOT, 'panellift', 'src')
|
||||
for p in (TOOLS, PANELLIFT_SRC):
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
from vibe import knowledge # noqa: E402 (registers the vibe stub)
|
||||
from vibe import plantpath # noqa: E402
|
||||
|
||||
pa = pytest.importorskip('panelanalysis')
|
||||
|
||||
CJK = re.compile(u'[一-鿿]')
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ fixture
|
||||
|
||||
def _deck(mark='TESTMK', design_number='ZZ9.0', with_prestress=True,
|
||||
with_blockout=False):
|
||||
panel = {
|
||||
'mark': mark,
|
||||
'design_number': design_number,
|
||||
'length_ft': 20.0,
|
||||
'width_ft': 8.0,
|
||||
'thickness_in': 6.0,
|
||||
'fc_psi': 6000.0,
|
||||
'fci_psi': 4000.0,
|
||||
'unit_weight_pcf': 150.0,
|
||||
'weight_lb': 10800.0,
|
||||
'finish': 'smooth',
|
||||
'product_type': 'flat',
|
||||
'blockouts': ([{'name': 'BO1', 'x0': 8.0, 'y0': 2.0, 'length': 2.0,
|
||||
'width': 2.0}] if with_blockout else []),
|
||||
'recesses': [],
|
||||
'lifters': [
|
||||
{'mark': 'L1', 'family': 'TEST_LIFTER', 'x': 3.0, 'y': 2.0,
|
||||
'capacity_lb': 8000.0},
|
||||
{'mark': 'L2', 'family': 'TEST_LIFTER', 'x': 17.0, 'y': 2.0,
|
||||
'capacity_lb': 8000.0},
|
||||
],
|
||||
}
|
||||
if with_prestress:
|
||||
panel['prestress'] = {
|
||||
'diameter_in': 0.5,
|
||||
'loss_pct': 8.0,
|
||||
'strands': [
|
||||
{'mark': 'S1', 'y': 1.0, 'z_in': 1.0, 'area_in2': 0.153,
|
||||
'initial_pull_lb': 28920.0},
|
||||
{'mark': 'S2', 'y': 4.0, 'z_in': 1.0, 'area_in2': 0.153,
|
||||
'initial_pull_lb': 28920.0},
|
||||
],
|
||||
}
|
||||
return {
|
||||
'schema': 'panellift/1',
|
||||
'panel': panel,
|
||||
'source': {
|
||||
'design': design_number.split('.')[0],
|
||||
'model': 'sample_job',
|
||||
'connectors': {
|
||||
'capacity_shear_lb': None, 'capacity_tension_lb': None,
|
||||
'count': None, 'declared_action': None, 'family': None,
|
||||
'per_row': None, 'spacing_in': None,
|
||||
'stiffness_lb_per_in': None,
|
||||
'why': 'connector recognition is not wired',
|
||||
},
|
||||
},
|
||||
'stages': [
|
||||
{'name': 'stripping', 'fc_psi': 4000.0, 'multiplier': 1.4,
|
||||
'pci_case': 'stripping', 'lifters': ['L1', 'L2']},
|
||||
{'name': 'transportation', 'fc_psi': 6000.0, 'multiplier': 1.5,
|
||||
'pci_case': 'transportation', 'supports': [3.0, 17.0]},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _write_deck(tmp_path, deck, job_root_name='job_root'):
|
||||
job_root = tmp_path / job_root_name
|
||||
jobs_dir = job_root / 'sweep' / 'jobs'
|
||||
jobs_dir.mkdir(parents=True)
|
||||
deck_path = jobs_dir / '{0}.job.json'.format(deck['panel']['mark'])
|
||||
with io.open(str(deck_path), 'w', encoding='utf-8') as fh:
|
||||
json.dump(deck, fh)
|
||||
return job_root, deck_path
|
||||
|
||||
|
||||
def _write_spec(job_root, design, data):
|
||||
specs_dir = job_root / 'specs'
|
||||
specs_dir.mkdir(parents=True, exist_ok=True)
|
||||
with io.open(str(specs_dir / '{0}.yaml'.format(design)), 'w',
|
||||
encoding='utf-8') as fh:
|
||||
yaml.safe_dump(data, fh)
|
||||
|
||||
|
||||
SECTION_TITLES = [
|
||||
'General / Project Information',
|
||||
'Concrete Extents',
|
||||
'Materials',
|
||||
'Prestress / Reinforcement',
|
||||
'Structural Model (supports, bracing, connections)',
|
||||
'Settings',
|
||||
'Loading Summary',
|
||||
'Section Properties',
|
||||
'Results -- Flexure & Shear Capacity',
|
||||
'In-Place Strength / Service (not computed)',
|
||||
'Horizontal Shear Analysis (not computed)',
|
||||
'Everything this demo does not compute yet',
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- tests
|
||||
|
||||
def test_renders_the_section_skeleton_from_a_fixture_deck(tmp_path):
|
||||
deck = _deck()
|
||||
job_root, deck_path = _write_deck(tmp_path, deck)
|
||||
_write_spec(job_root, 'ZZ9', {
|
||||
'design_number': 'ZZ9',
|
||||
'piece_description': 'sample panel',
|
||||
'concrete': {'fc_psi': 6000, 'fci_psi': 4000},
|
||||
'reinforcing': {'strand_groups': [
|
||||
{'qty': 2, 'dia_in': 0.5, 'grade_ksi': 270, 'it_lb': 28920}]},
|
||||
'envelope': {'length': {'min_ft': 5.0, 'max_ft': 30.0},
|
||||
'width': {'min_ft': 4.0, 'max_ft': 10.0}},
|
||||
'source': {'sheet': 'ZZ9.0', 'extractor': 'regex',
|
||||
'extracted': '2026-01-01T00:00:00'},
|
||||
})
|
||||
|
||||
html = pa.build_page(str(deck_path))
|
||||
|
||||
for title in SECTION_TITLES:
|
||||
assert '>{0}<'.format(title) in html, title
|
||||
# Twelve real <details class="section"> blocks, one per title above --
|
||||
# counted this way rather than by a bare substring count, because CSS
|
||||
# comments reused from panellift.report's stylesheet mention the literal
|
||||
# text "<details>" too (see that module's own note beside `_sec()`).
|
||||
assert len(re.findall(r'<details class="section">', html)) == 12
|
||||
assert html.count('<input') > 20
|
||||
assert 'WP242' not in html # this is the fixture, not a real job
|
||||
|
||||
|
||||
def test_populated_vs_absent_field_states(tmp_path):
|
||||
deck = _deck()
|
||||
_job_root, deck_path = _write_deck(tmp_path, deck)
|
||||
|
||||
html = pa.build_page(str(deck_path))
|
||||
|
||||
# A value the deck actually carries: chipped DECK, not absent.
|
||||
assert re.search(r'value="TESTMK"[^>]*>\s*<span[^>]*class="chip '
|
||||
r'chip-deck"', html)
|
||||
# In-place bearing is never read off any model yet, on ANY deck -- see
|
||||
# sec_structural_model's own docstring -- so it always renders empty
|
||||
# and chipped NOT READ, whether or not the deck otherwise carries data.
|
||||
assert 'In-place bearing (not built into any stage yet)' in html
|
||||
absent_bearing = re.search(
|
||||
r'Bearing</th><th>x \(ft\)</th>.*?chip-absent', html, re.S)
|
||||
assert absent_bearing, 'expected an absent-chipped bearing input row'
|
||||
# source.connectors is null on this fixture (mirroring the real jobs) --
|
||||
# every connector field should render NOT READ.
|
||||
assert html.count('chip-absent') >= 8
|
||||
|
||||
|
||||
def test_zero_cjk(tmp_path):
|
||||
deck = _deck()
|
||||
_job_root, deck_path = _write_deck(tmp_path, deck)
|
||||
html = pa.build_page(str(deck_path))
|
||||
m = CJK.search(html)
|
||||
assert not m, 'CJK character found: {0!r}'.format(m.group() if m else '')
|
||||
|
||||
|
||||
def test_no_spec_degrades_to_model_only_fields(tmp_path):
|
||||
"""A deck naming a design nobody wrote a spec for must not crash.
|
||||
|
||||
`load_spec` should come back with `spec=None` and a stated reason, and
|
||||
the page should say so rather than silently omitting the design-extract
|
||||
fields -- the same "could not judge is not silence" discipline the rest
|
||||
of this codebase holds to.
|
||||
"""
|
||||
deck = _deck(design_number='NOSPEC9.0')
|
||||
_job_root, deck_path = _write_deck(tmp_path, deck)
|
||||
|
||||
html = pa.build_page(str(deck_path)) # must not raise
|
||||
|
||||
assert 'no spec file at' in html
|
||||
# The design number field still renders, sourced from the deck itself
|
||||
# rather than a spec that does not exist.
|
||||
assert 'NOSPEC9' in html
|
||||
for title in SECTION_TITLES:
|
||||
assert '>{0}<'.format(title) in html, title
|
||||
|
||||
|
||||
def test_no_prestress_deck_does_not_crash_and_says_so(tmp_path):
|
||||
deck = _deck(with_prestress=False)
|
||||
_job_root, deck_path = _write_deck(tmp_path, deck)
|
||||
|
||||
html = pa.build_page(str(deck_path)) # must not raise
|
||||
|
||||
assert 'carries no prestress block at all' in html
|
||||
assert 'is strand-only by construction' in html
|
||||
|
||||
|
||||
def test_a_blockout_is_reflected_in_section_properties(tmp_path):
|
||||
deck = _deck(with_blockout=True)
|
||||
_job_root, deck_path = _write_deck(tmp_path, deck)
|
||||
html = pa.build_page(str(deck_path))
|
||||
assert '<h3>Blockouts</h3>' in html
|
||||
@@ -0,0 +1,133 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The panelanalysis/1 In-Place SERVICE section (docs sec.6) + the P-Delta note.
|
||||
|
||||
Host CPython, loaded exactly like its siblings: reaches the tool through
|
||||
`tools/` with the `vibe` stub in place. A wired deck (restraints + wind +
|
||||
service inputs) must render the In-Place Service Summary -- tension/cracking,
|
||||
compression, deflection -- and the strength section must carry the P-Delta
|
||||
(2nd-order) magnifier. The could-not-judge discipline is enforced: a deflection
|
||||
with no L/n limit is NOT JUDGED, named, never a fabricated pass; nothing on the
|
||||
page is CJK; nothing is a traceback.
|
||||
|
||||
Everything written at runtime goes under tmp_path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))))
|
||||
TOOLS = os.path.join(ROOT, 'tools')
|
||||
PANELLIFT_SRC = os.path.join(ROOT, 'panellift', 'src')
|
||||
for p in (TOOLS, PANELLIFT_SRC):
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
from vibe import knowledge # noqa: E402,F401 (registers the vibe stub)
|
||||
from vibe import plantpath # noqa: E402,F401
|
||||
|
||||
pa = pytest.importorskip('panelanalysis')
|
||||
schema = pytest.importorskip('panelanalysis_schema')
|
||||
|
||||
CJK = re.compile(u'[一-鿿]')
|
||||
|
||||
|
||||
def _wired_deck(deflection_limit_ratio=150.0, wind=True, class_t=False):
|
||||
"""A mild solid wall pinned top and bottom, with a service wind pressure."""
|
||||
deck = schema.new_deck(length_ft=30.0, width_ft=10.0, thickness_in=12.0,
|
||||
fc_psi=6000.0, strands=[])
|
||||
deck['structural_model']['restraints'] = [
|
||||
{'x_ft': 0.0, 'type': 'pin'}, {'x_ft': 30.0, 'type': 'pin'}]
|
||||
if wind:
|
||||
deck['loading']['wind'] = {'zones': [
|
||||
{'x0_ft': 0.0, 'x1_ft': 30.0, 'pressure_psf': 50.0, 'label': 'W1'}]}
|
||||
svc = {}
|
||||
if deflection_limit_ratio is not None:
|
||||
svc['deflection_limit_ratio'] = deflection_limit_ratio
|
||||
if class_t:
|
||||
svc['cracking_class_t'] = True
|
||||
if svc:
|
||||
deck['loading']['service'] = svc
|
||||
return deck
|
||||
|
||||
|
||||
# ------------------------------------------------------------ the run itself
|
||||
|
||||
def test_wired_deck_produces_a_service_result():
|
||||
deck = _wired_deck()
|
||||
panel = schema.to_panel(deck)
|
||||
result, reason = pa.solve_in_place(deck, panel)
|
||||
assert reason is None and result is not None and not result.refusal
|
||||
svc = result.service
|
||||
assert svc is not None and svc.stations
|
||||
# a wind-loaded wall develops real service tension and compression
|
||||
assert svc.max_tension_psi > 0.0
|
||||
assert svc.max_compression_psi > 0.0
|
||||
# cracking limit is the Class U 7.5 sqrt(6000) = 580.9 psi
|
||||
assert abs(svc.stations[0].cracking_limit_psi - 580.9) < 0.5
|
||||
# deflection judged against L/150 = 2.4 in
|
||||
d = svc.deflection
|
||||
assert d.combo == 'SC5' and d.util is not None
|
||||
assert abs(d.limit_in - 30.0 * 12.0 / 150.0) < 1e-6
|
||||
|
||||
|
||||
def test_class_t_deck_raises_the_cracking_limit():
|
||||
deck = _wired_deck(class_t=True)
|
||||
panel = schema.to_panel(deck)
|
||||
result, _reason = pa.solve_in_place(deck, panel)
|
||||
import math
|
||||
assert abs(result.service.stations[0].cracking_limit_psi
|
||||
- 12.0 * math.sqrt(6000.0)) < 0.5
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the rendering
|
||||
|
||||
def test_page_renders_the_service_summary_and_pdelta():
|
||||
deck = _wired_deck()
|
||||
html = pa.build_analysis_page(deck)
|
||||
assert '>In-Place Service Summary<' in html
|
||||
for needle in ('Tension vs cracking', 'Total compression', 'Deflection',
|
||||
'Cracking (psi)', 'Combination'):
|
||||
assert needle in html, needle
|
||||
# the P-Delta 2nd-order magnifier appears in the strength section
|
||||
assert 'P-Δ (2nd-order' in html
|
||||
assert 'magnifier' in html
|
||||
assert 'Traceback' not in html
|
||||
m = CJK.search(html)
|
||||
assert not m, 'CJK: {0!r}'.format(m.group() if m else '')
|
||||
|
||||
|
||||
def test_deflection_without_limit_is_not_judged_not_faked():
|
||||
deck = _wired_deck(deflection_limit_ratio=None)
|
||||
html = pa.build_analysis_page(deck)
|
||||
assert '>In-Place Service Summary<' in html
|
||||
# the deflection is computed, but the LIMIT check is named NOT JUDGED
|
||||
assert 'Deflection limit NOT' in html or 'NOT JUDGED' in html
|
||||
result, _r = pa.solve_in_place(deck, schema.to_panel(deck))
|
||||
assert result.service.deflection.util is None
|
||||
assert result.service.deflection.why
|
||||
|
||||
|
||||
def test_unwired_deck_keeps_the_explicit_service_placeholder():
|
||||
deck = schema.new_deck(support_xs=(2.0, 22.0)) # no restraints
|
||||
html = pa.build_analysis_page(deck)
|
||||
assert '>In-Place Service (not computed)<' in html
|
||||
assert 'Traceback' not in html
|
||||
|
||||
|
||||
def test_cli_end_to_end_renders_service_under_tmp_path(tmp_path):
|
||||
deck = _wired_deck()
|
||||
deck_path = tmp_path / 'PNL101.pa.json'
|
||||
out_path = tmp_path / 'out.html'
|
||||
with io.open(str(deck_path), 'w', encoding='utf-8') as fh:
|
||||
json.dump(deck, fh)
|
||||
rc = pa.main([str(deck_path), '-o', str(out_path)])
|
||||
assert rc == 0 and out_path.exists()
|
||||
html = out_path.read_text(encoding='utf-8')
|
||||
assert 'In-Place Service Summary' in html
|
||||
@@ -0,0 +1,223 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for the panelanalysis/1 SOLVE path -- the self-weight Handling model.
|
||||
|
||||
Loaded by path, exactly like `test_panelanalysis.py` beside it: this is host
|
||||
CPython (it imports `panellift`, dataclasses, typing syntax) sitting in an
|
||||
IronPython 2.7 extension, and it reaches the tool through `tools/`, which the
|
||||
sibling knowledge merge in `deckcheck.py` puts the `vibe` stub in place for.
|
||||
|
||||
The four things the Stage 0 brief asked these to nail down:
|
||||
|
||||
1. the self-weight solve reactions sum to the load, the moment diagram is
|
||||
real, and the per-station capacity comes from `panellift`;
|
||||
2. editing the deck's supports and re-solving changes the reactions -- the
|
||||
round-trip the owner wants;
|
||||
3. a deck with fewer than two supports is REFUSED loudly (panellift's own
|
||||
"a supported stage needs at least two supports"), surfaced as NOT JUDGED
|
||||
on the page, never a traceback and never a silent OK;
|
||||
4. missing prestress dashes with panellift's reason, never a zero; the
|
||||
In-Place sections are explicit placeholders; zero CJK on the page.
|
||||
|
||||
Everything written at runtime goes under `tmp_path`; nothing touches the
|
||||
working directory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))))
|
||||
TOOLS = os.path.join(ROOT, 'tools')
|
||||
PANELLIFT_SRC = os.path.join(ROOT, 'panellift', 'src')
|
||||
for p in (TOOLS, PANELLIFT_SRC):
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
from vibe import knowledge # noqa: E402,F401 (registers the vibe stub)
|
||||
from vibe import plantpath # noqa: E402,F401
|
||||
|
||||
pa = pytest.importorskip('panelanalysis')
|
||||
schema = pytest.importorskip('panelanalysis_schema')
|
||||
|
||||
CJK = re.compile(u'[一-鿿]')
|
||||
|
||||
IN_PLACE_TITLES = [
|
||||
'In-Place Load Case (not computed)',
|
||||
'In-Place Load Combination (not computed)',
|
||||
'In-Place Strength (not computed)',
|
||||
'In-Place Service (not computed)',
|
||||
]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- helpers
|
||||
|
||||
def _solve(deck):
|
||||
panel = schema.to_panel(deck)
|
||||
return panel, pa.solve_handling(deck, panel)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- solve
|
||||
|
||||
def test_self_weight_reactions_sum_to_the_load():
|
||||
deck = schema.new_deck(support_xs=(2.0, 22.0))
|
||||
panel, (analysis, refusal) = _solve(deck)
|
||||
assert refusal is None
|
||||
total_r = sum(l.reaction_lb for l in analysis.rig.lines)
|
||||
total_load = analysis.profile.total_lb
|
||||
assert total_load > 0
|
||||
# panellift's consistent-load formulation makes this exact; a loose floor.
|
||||
assert abs(total_r - total_load) <= max(1.0, 1e-6 * total_load)
|
||||
|
||||
|
||||
def test_moment_diagram_is_real():
|
||||
deck = schema.new_deck(support_xs=(2.0, 22.0))
|
||||
_panel, (analysis, refusal) = _solve(deck)
|
||||
assert refusal is None
|
||||
assert len(analysis.diagram.moment_lbft) > 10
|
||||
assert max(abs(m) for m in analysis.diagram.moment_lbft) > 0.0
|
||||
# A simply-supported self-weight beam sags between the supports.
|
||||
_x, mmax = analysis.diagram.max_positive
|
||||
assert mmax > 0.0
|
||||
|
||||
|
||||
def test_capacity_stations_come_from_panellift():
|
||||
"""f_se and phi*Mn at mid-span are real numbers from panellift.capacity."""
|
||||
from panellift import capacity
|
||||
deck = schema.new_deck(support_xs=(2.0, 22.0))
|
||||
panel, (analysis, refusal) = _solve(deck)
|
||||
assert refusal is None
|
||||
x = panel.length_ft / 2.0
|
||||
m = analysis.diagram.moment_at(x)
|
||||
v = analysis.diagram.shear_at(x)
|
||||
stage = schema.handling_stage(deck, panel)
|
||||
cap = capacity.capacity_at(panel, stage, x, m, v)
|
||||
# A prestressed piece with developed strand at mid-span: real f_se and a
|
||||
# real phi*Mn / phi*Vn, not None.
|
||||
assert cap.fse_ksi is not None and cap.fse_ksi > 0
|
||||
assert cap.phi_mn_lbft is not None and cap.phi_mn_lbft > 0
|
||||
assert cap.phi_vn_lb is not None and cap.phi_vn_lb > 0
|
||||
|
||||
|
||||
def test_editing_supports_changes_the_reactions():
|
||||
"""The round-trip: move the supports, re-solve, the answer moves."""
|
||||
# Asymmetric supports so the change is unmistakable: a symmetric panel on
|
||||
# supports symmetric about its centroid gives equal reactions either way,
|
||||
# which would hide the very round-trip this asserts.
|
||||
a = schema.new_deck(support_xs=(2.0, 22.0)) # symmetric: half and half
|
||||
b = schema.new_deck(support_xs=(2.0, 14.0)) # right support inboard
|
||||
_pa, (aa, ra) = _solve(a)
|
||||
_pb, (ab, rb) = _solve(b)
|
||||
assert ra is None and rb is None
|
||||
rx_a = [round(l.reaction_lb, 1) for l in aa.rig.lines]
|
||||
rx_b = [round(l.reaction_lb, 1) for l in ab.rig.lines]
|
||||
assert rx_a != rx_b
|
||||
# and the sum is unchanged -- the load did not move, only how it is shared.
|
||||
assert abs(sum(rx_a) - sum(rx_b)) <= 1.0
|
||||
|
||||
|
||||
def test_three_supports_are_indeterminate_and_still_close():
|
||||
deck = schema.new_deck(support_xs=(2.0, 12.0, 22.0))
|
||||
_panel, (analysis, refusal) = _solve(deck)
|
||||
assert refusal is None
|
||||
total_r = sum(l.reaction_lb for l in analysis.rig.lines)
|
||||
total_load = analysis.profile.total_lb
|
||||
assert abs(total_r - total_load) <= max(1.0, 1e-6 * total_load)
|
||||
|
||||
|
||||
# ------------------------------------------------------- the loud refusals
|
||||
|
||||
def test_fewer_than_two_supports_is_refused_loudly():
|
||||
deck = schema.new_deck(support_xs=(5.0,))
|
||||
_panel, (analysis, refusal) = _solve(deck)
|
||||
assert analysis is None
|
||||
assert 'at least two supports' in refusal
|
||||
|
||||
|
||||
def test_one_support_page_says_not_judged_not_a_traceback():
|
||||
deck = schema.new_deck(support_xs=(5.0,))
|
||||
html = pa.build_analysis_page(deck) # must not raise
|
||||
assert 'NOT JUDGED' in html
|
||||
assert 'at least two supports' in html
|
||||
assert 'Traceback' not in html
|
||||
# The In-Place placeholders still render -- the page is whole.
|
||||
for title in IN_PLACE_TITLES:
|
||||
assert '>{0}<'.format(title) in html, title
|
||||
|
||||
|
||||
def test_support_off_the_piece_is_refused():
|
||||
deck = schema.new_deck(length_ft=24.0, support_xs=(2.0, 99.0))
|
||||
_panel, (analysis, refusal) = _solve(deck)
|
||||
assert analysis is None
|
||||
assert 'outside the piece' in refusal
|
||||
|
||||
|
||||
def test_no_prestress_capacity_dashes_with_reason_not_a_zero():
|
||||
deck = schema.new_deck(strands=[])
|
||||
panel = schema.to_panel(deck)
|
||||
assert panel.prestress is None
|
||||
html = pa.build_analysis_page(deck) # solve still runs (dunnage)
|
||||
# panellift.capacity's own words for a strand-only tool with no strand --
|
||||
# a dash carrying this reason, never a fabricated phi*Mn of 0.
|
||||
assert 'no prestressing on this piece' in html
|
||||
# The self-weight solve itself still ran (reactions do not need strand).
|
||||
assert 'Support reactions' in html
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the whole page
|
||||
|
||||
def test_page_has_every_section_and_no_cjk():
|
||||
deck = schema.new_deck(support_xs=(2.0, 22.0))
|
||||
html = pa.build_analysis_page(deck)
|
||||
for title in ['General / Project Information', 'Concrete Extents',
|
||||
'Materials', 'Prestress / Reinforcement',
|
||||
'Section Properties'] + IN_PLACE_TITLES:
|
||||
assert '>{0}<'.format(title) in html, title
|
||||
assert 'Support reactions' in html
|
||||
assert 'Demand vs capacity' in html
|
||||
m = CJK.search(html)
|
||||
assert not m, 'CJK character found: {0!r}'.format(m.group() if m else '')
|
||||
assert 'WP242' not in html # neutral fixture marks only
|
||||
|
||||
|
||||
def test_cli_end_to_end_writes_only_under_tmp_path(tmp_path):
|
||||
deck = schema.new_deck(support_xs=(2.0, 22.0))
|
||||
deck_path = tmp_path / 'PNL101.pa.json'
|
||||
out_path = tmp_path / 'out.html'
|
||||
with io.open(str(deck_path), 'w', encoding='utf-8') as fh:
|
||||
json.dump(deck, fh)
|
||||
rc = pa.main([str(deck_path), '-o', str(out_path)])
|
||||
assert rc == 0
|
||||
assert out_path.exists()
|
||||
html = out_path.read_text(encoding='utf-8')
|
||||
assert 'Support reactions' in html
|
||||
assert 'Handling' in html
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- schema
|
||||
|
||||
def test_unread_fields_carry_an_explicit_reason():
|
||||
deck = schema.new_deck()
|
||||
# in_place is the whole-block placeholder.
|
||||
assert deck['in_place']['value'] is None
|
||||
assert deck['in_place']['reason']
|
||||
# loading.wind / combinations are the file contract's null+reason shape.
|
||||
for key in ('wind', 'combinations'):
|
||||
assert deck['loading'][key]['value'] is None
|
||||
assert deck['loading'][key]['reason']
|
||||
# structural_model restraints/bearing likewise.
|
||||
for key in ('restraints', 'bearing'):
|
||||
assert deck['structural_model'][key]['value'] is None
|
||||
assert deck['structural_model'][key]['reason']
|
||||
|
||||
|
||||
def test_a_foreign_schema_is_refused_by_name():
|
||||
deck = schema.new_deck()
|
||||
deck['schema'] = 'panellift/1'
|
||||
with pytest.raises(schema.DeckError):
|
||||
schema.check_schema(deck)
|
||||
@@ -0,0 +1,308 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tier-1 handling fixes on the Panel Analysis page: staging, the stress check
|
||||
and the governing-check selection.
|
||||
|
||||
Loaded by path like its siblings (host CPython in an IronPython extension; it
|
||||
reaches the tool through `tools/`). What the three fixes owe, asserted here:
|
||||
|
||||
FIX 1 the extreme-fibre STRESS check is computed and rendered, and its
|
||||
tension/compression utilisation uses the deck's OWN stress limits
|
||||
(settings.tension_limit_sqrt_fc / compression_limit_ratio), which
|
||||
nothing read before.
|
||||
FIX 2 the default handling STAGES are Stripping x1.4 at f'ci and
|
||||
Transportation x1.5 at f'c, overridable from the deck.
|
||||
FIX 3 phi is RAMPED, not flat 0.90, so the flexure utilisation near the
|
||||
ends rises.
|
||||
|
||||
and the tool names the GOVERNING check -- the highest utilisation across
|
||||
flexure / shear / tension-stress / compression-stress.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))))
|
||||
TOOLS = os.path.join(ROOT, 'tools')
|
||||
PANELLIFT_SRC = os.path.join(ROOT, 'panellift', 'src')
|
||||
for p in (TOOLS, PANELLIFT_SRC):
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
from vibe import knowledge # noqa: E402,F401 (registers the vibe stub)
|
||||
from vibe import plantpath # noqa: E402,F401
|
||||
|
||||
pa = pytest.importorskip('panelanalysis')
|
||||
schema = pytest.importorskip('panelanalysis_schema')
|
||||
capacity = pytest.importorskip('panellift.capacity')
|
||||
prestress = pytest.importorskip('panellift.prestress')
|
||||
|
||||
CJK = re.compile(u'[一-鿿]')
|
||||
|
||||
|
||||
# ------------------------------------------------------- FIX 2: staging
|
||||
|
||||
def test_default_stages_are_strip_1p4_fci_transport_1p5_erect_1p2_fc():
|
||||
deck = schema.new_deck(fci_psi=4000.0, fc_psi=6000.0)
|
||||
panel = schema.to_panel(deck)
|
||||
stages = schema.handling_stages(deck, panel)
|
||||
assert [s.name for s in stages] == ['Stripping', 'Transportation',
|
||||
'Erection']
|
||||
strip, trans, erect = stages
|
||||
assert strip.multiplier == pytest.approx(1.4)
|
||||
assert strip.fc_psi == pytest.approx(4000.0) # f'ci
|
||||
assert trans.multiplier == pytest.approx(1.5)
|
||||
assert trans.fc_psi == pytest.approx(6000.0) # f'c
|
||||
assert erect.multiplier == pytest.approx(1.2) # erection factor
|
||||
assert erect.fc_psi == pytest.approx(6000.0) # f'c
|
||||
|
||||
|
||||
def test_a_deck_self_weight_factor_drives_the_stripping_multiplier():
|
||||
"""A deck hand-tuned to 1.6 still drives stripping; the harness set 1.4 by
|
||||
hand and this is where that lives now."""
|
||||
deck = schema.new_deck(self_weight_factor=1.6)
|
||||
panel = schema.to_panel(deck)
|
||||
strip = schema.handling_stages(deck, panel)[0]
|
||||
assert strip.multiplier == pytest.approx(1.6)
|
||||
|
||||
|
||||
def test_handling_block_overrides_the_multipliers():
|
||||
deck = schema.new_deck()
|
||||
deck['handling'] = {'stripping_multiplier': 1.1, 'transport_multiplier': 1.7,
|
||||
'erection_multiplier': 1.3}
|
||||
panel = schema.to_panel(deck)
|
||||
strip, trans, erect = schema.handling_stages(deck, panel)
|
||||
assert strip.multiplier == pytest.approx(1.1)
|
||||
assert trans.multiplier == pytest.approx(1.7)
|
||||
assert erect.multiplier == pytest.approx(1.3)
|
||||
|
||||
|
||||
def test_handling_stages_list_replaces_the_defaults():
|
||||
deck = schema.new_deck()
|
||||
deck['handling'] = {'stages': [
|
||||
{'name': 'Yard', 'pci_case': 'yard', 'multiplier': 1.2,
|
||||
'strength': '28day'}]}
|
||||
panel = schema.to_panel(deck)
|
||||
stages = schema.handling_stages(deck, panel)
|
||||
assert len(stages) == 1
|
||||
assert stages[0].name == 'Yard'
|
||||
assert stages[0].multiplier == pytest.approx(1.2)
|
||||
assert stages[0].fc_psi == pytest.approx(panel.fc_psi)
|
||||
|
||||
|
||||
# ------------------------------------------ FIX 1: settings wired into stages
|
||||
|
||||
def test_stage_carries_the_decks_stress_limits_not_the_defaults():
|
||||
deck = schema.new_deck(tension_limit_sqrt_fc=7.5,
|
||||
compression_limit_ratio=0.55)
|
||||
panel = schema.to_panel(deck)
|
||||
for st in schema.handling_stages(deck, panel):
|
||||
assert st.tension_limit_sqrt_fc == pytest.approx(7.5)
|
||||
assert st.tension_limit_source == 'job'
|
||||
assert st.compression_limit_ratio == pytest.approx(0.55)
|
||||
assert st.compression_limit_source == 'job'
|
||||
|
||||
|
||||
def test_absent_settings_fall_back_to_the_panellift_default():
|
||||
deck = schema.new_deck()
|
||||
del deck['settings']['tension_limit_sqrt_fc']
|
||||
del deck['settings']['compression_limit_ratio']
|
||||
panel = schema.to_panel(deck)
|
||||
st = schema.handling_stage(deck, panel)
|
||||
assert st.tension_limit_sqrt_fc == pytest.approx(5.0)
|
||||
assert st.tension_limit_source == 'default'
|
||||
assert st.compression_limit_ratio == pytest.approx(0.6)
|
||||
assert st.compression_limit_source == 'default'
|
||||
|
||||
|
||||
# ----------------------------------- FIX 1 + governing: the selection is real
|
||||
|
||||
def _results(deck):
|
||||
panel = schema.to_panel(deck)
|
||||
stage = schema.handling_stages(deck, panel)[0]
|
||||
analysis, refusal = pa.solve_stage(panel, stage)
|
||||
assert refusal is None
|
||||
xs = pa.key_stations(panel)
|
||||
return pa._stage_station_results(analysis, panel, stage, xs)
|
||||
|
||||
|
||||
def test_governing_is_the_highest_util_across_all_four_checks():
|
||||
deck = schema.new_deck(support_xs=(2.0, 22.0), tension_limit_sqrt_fc=7.5)
|
||||
panel = schema.to_panel(deck)
|
||||
stage = schema.handling_stages(deck, panel)[0]
|
||||
analysis, refusal = pa.solve_stage(panel, stage)
|
||||
assert refusal is None
|
||||
xs = pa.key_stations(panel)
|
||||
rows, gov = pa._stage_station_results(analysis, panel, stage, xs)
|
||||
assert gov is not None
|
||||
name, util, x = gov
|
||||
# Rebuild every candidate utilisation independently and confirm gov is the
|
||||
# max of them -- the selection is not a function of column order. Shear's
|
||||
# candidate is the DENSE full-station scan (FIX 6), not the coarse-grid
|
||||
# rows, because that grid can miss the governing shear station -- see
|
||||
# `_dense_shear_governing`'s own docstring.
|
||||
cands = []
|
||||
for r in rows:
|
||||
if r['flex_ratio'] is not None:
|
||||
cands.append(r['flex_ratio'])
|
||||
sp = r['stress']
|
||||
if sp.tension_util is not None and sp.tension_psi > 0:
|
||||
cands.append(sp.tension_util)
|
||||
if sp.compression_util is not None and sp.compression_psi > 0:
|
||||
cands.append(sp.compression_util)
|
||||
_shear_x, shear_pt = pa._dense_shear_governing(analysis, panel, stage)
|
||||
if shear_pt is not None and shear_pt.v_over_phi_vn is not None:
|
||||
cands.append(shear_pt.v_over_phi_vn)
|
||||
assert util == pytest.approx(max(cands))
|
||||
assert 'stress' in name or 'M/' in name or 'V/' in name
|
||||
|
||||
|
||||
def test_compression_stress_is_a_first_class_computed_result():
|
||||
"""The heart of FIX 1: the stress check is COMPUTED at every station and
|
||||
joins flexure/shear as a governing candidate. Whether it wins is
|
||||
deck-dependent (on a heavily prestressed piece it does -- see the WPA2.0
|
||||
parity case); here it is enough that it is real, positive, and eligible."""
|
||||
deck = schema.new_deck(support_xs=(2.0, 22.0))
|
||||
rows, gov = _results(deck)
|
||||
comps = [r['stress'].compression_util for r in rows
|
||||
if r['stress'].compression_util is not None]
|
||||
assert comps and max(comps) > 0.0
|
||||
# the stress limits are the stage's own, so the util is a real ratio.
|
||||
for r in rows:
|
||||
sp = r['stress']
|
||||
if sp.compression_util is not None:
|
||||
assert sp.compression_util == pytest.approx(
|
||||
sp.compression_psi / sp.allowable_compression_psi)
|
||||
|
||||
|
||||
# -------------------------- FIX 6: the detailed Vci/Vcw shear is now WIRED IN
|
||||
|
||||
def test_shear_rows_are_the_detailed_vci_vcw_method_not_the_simplified_one():
|
||||
"""Panel Analysis's own shear result is `capacity.detailed_shear_at`, not
|
||||
`capacity_at`'s simplified V_c = 2*sqrt(f'c)*bw*d -- the wiring this fix
|
||||
adds. `DetailedShearPoint` carries `vci_lb`/`vcw_lb`/`governed_by`, which
|
||||
the simplified `CapacityPoint` does not, and its value at each row must be
|
||||
exactly what calling the detailed method directly produces at that same
|
||||
station -- not a value inherited from `r['cap']`.
|
||||
"""
|
||||
deck = schema.new_deck(support_xs=(2.0, 22.0))
|
||||
panel = schema.to_panel(deck)
|
||||
stage = schema.handling_stages(deck, panel)[0]
|
||||
analysis, refusal = pa.solve_stage(panel, stage)
|
||||
assert refusal is None
|
||||
xs = pa.key_stations(panel)
|
||||
rows, _gov = pa._stage_station_results(analysis, panel, stage, xs)
|
||||
judged = [r for r in rows if r['shear'].v_over_phi_vn is not None]
|
||||
assert judged, 'expected at least one judged shear station'
|
||||
for r in judged:
|
||||
assert isinstance(r['shear'], capacity.DetailedShearPoint)
|
||||
assert r['shear'].governed_by in ('Vci', 'Vcw', 'Vc', 'Vc_min')
|
||||
direct = capacity.detailed_shear_at(panel, stage, r['x'], r['m'], r['v'])
|
||||
assert r['shear'].v_over_phi_vn == pytest.approx(direct.v_over_phi_vn)
|
||||
assert r['shear'].governed_by == direct.governed_by
|
||||
# The simplified capacity_at shear stays untouched beside it -- both
|
||||
# methods are real, independent numbers, not one overwriting the other.
|
||||
assert r['cap'].phi_vn_lb is not None
|
||||
|
||||
|
||||
def test_dense_shear_scan_can_govern_and_names_which_of_vci_vcw(monkeypatch):
|
||||
"""A station off the L/20 grid, found only by the dense diagram-station
|
||||
scan (`_dense_shear_governing`), can still govern the whole stage's
|
||||
verdict -- and when it does, the governing-check name says which of
|
||||
Vci/Vcw drove it (the parity finding: a coarse grid can miss the station
|
||||
where shear actually governs). The fixtures here do not naturally develop
|
||||
a shear-governed station (flexure/stress dominate a thin prestressed
|
||||
piece), so this pins the SELECTION logic with monkeypatch rather than
|
||||
hunting for a particular geometry's numbers.
|
||||
"""
|
||||
deck = schema.new_deck(support_xs=(2.0, 22.0))
|
||||
panel = schema.to_panel(deck)
|
||||
stage = schema.handling_stages(deck, panel)[0]
|
||||
analysis, refusal = pa.solve_stage(panel, stage)
|
||||
assert refusal is None
|
||||
xs = pa.key_stations(panel)
|
||||
|
||||
fake = capacity.DetailedShearPoint(
|
||||
x=13.0, moment_lbft=0.0, shear_lb=0.0, governed_by='Vci',
|
||||
v_over_phi_vn=999.0)
|
||||
monkeypatch.setattr(pa, '_dense_shear_governing',
|
||||
lambda an, pnl, stg: (13.0, fake))
|
||||
rows, gov = pa._stage_station_results(analysis, panel, stage, xs)
|
||||
assert gov is not None
|
||||
name, util, x = gov
|
||||
assert util == pytest.approx(999.0)
|
||||
assert x == pytest.approx(13.0)
|
||||
assert 'shear' in name and 'Vci' in name
|
||||
|
||||
|
||||
def test_page_renders_the_shear_governs_column_and_detailed_shear_note():
|
||||
deck = schema.new_deck(support_xs=(2.0, 22.0))
|
||||
html = pa.build_analysis_page(deck)
|
||||
assert 'Shear governs' in html
|
||||
assert 'detailed Vci/Vcw method' in html
|
||||
assert 'Traceback' not in html
|
||||
|
||||
|
||||
# ------------------------- FIX 3: strain-based phi never rewards, phi <= 0.90
|
||||
|
||||
def test_strain_phi_stays_within_the_table_21p2p2_bounds():
|
||||
"""phi from Table 21.2.2 is bounded: 0.65 (prestressed compression floor)
|
||||
to 0.90 (tension-controlled). That invariant is unchanged.
|
||||
|
||||
UPDATED 2026-09-02 for the fps POWER-CURVE Mn (docs sec.4.2). This test used
|
||||
to also assert `flex_ratio >= capacity_at.m_over_phi_mn` -- that the
|
||||
strain-based flexure ratio never DROPS below capacity_at's flat-0.90 ratio.
|
||||
That held only while `handling_flexure_at` changed nothing but phi (phi<=0.90
|
||||
-> smaller phi*Mn -> higher util). It no longer does: the handling flexure
|
||||
path now reads the PCI 15.2.3 power-curve M_n, which is a genuinely larger
|
||||
(more exact) capacity than the ACI-approximate M_n capacity_at carries, so
|
||||
where phi is at 0.90 the higher M_n makes flex_ratio slightly LOWER than
|
||||
m_over_phi_mn (measured 0.477 vs 0.479 at x=3.6 ft on this fixture). That is
|
||||
the correction working -- capacity_at's own m_over_phi_mn is UNCHANGED (Panel
|
||||
Lift Check unaffected); only the additive Panel Analysis path moved. The
|
||||
"never lowers" claim is retired because the two ratios no longer share an
|
||||
M_n. The transition-region behaviour is pinned in the next test."""
|
||||
deck = schema.new_deck(support_xs=(2.0, 22.0))
|
||||
rows, _gov = _results(deck)
|
||||
seen = False
|
||||
for r in rows:
|
||||
if r['phi_r'] is not None:
|
||||
assert r['phi_r'] <= 0.90 + 1e-9
|
||||
assert r['phi_r'] >= 0.65 - 1e-9
|
||||
if r['flex_ratio'] is not None:
|
||||
seen = True
|
||||
assert r['flex_ratio'] > 0.0
|
||||
assert seen, 'expected at least one judged flexure station'
|
||||
|
||||
|
||||
def test_strain_phi_transition_region_raises_the_util_when_it_occurs():
|
||||
"""Where a station IS in the strain transition (phi<0.90) the strain-based
|
||||
ratio is STRICTLY larger than the flat one. Asserted only if such a station
|
||||
exists on the fixture -- these thin panels are mostly tension-controlled,
|
||||
so this is a conditional check, not a required one."""
|
||||
deck = schema.new_deck(support_xs=(2.0, 22.0))
|
||||
rows, _gov = _results(deck)
|
||||
for r in rows:
|
||||
if (r['phi_r'] is not None and r['phi_r'] < 0.90 - 1e-9
|
||||
and r['flex_ratio'] is not None
|
||||
and r['cap'].m_over_phi_mn is not None):
|
||||
assert r['flex_ratio'] > r['cap'].m_over_phi_mn
|
||||
|
||||
|
||||
# -------------------------------------------------- the whole page still whole
|
||||
|
||||
def test_page_renders_both_stages_stress_and_governing_no_cjk():
|
||||
deck = schema.new_deck(support_xs=(2.0, 22.0), tension_limit_sqrt_fc=7.5)
|
||||
html = pa.build_analysis_page(deck)
|
||||
assert 'Support reactions' in html
|
||||
assert 'Demand vs capacity' in html
|
||||
assert 'Extreme-fibre stress' in html
|
||||
assert 'Governing handling check' in html
|
||||
assert 'Stripping' in html and 'Transportation' in html
|
||||
assert 'Traceback' not in html
|
||||
m = CJK.search(html)
|
||||
assert not m, 'CJK: {0!r}'.format(m.group() if m else '')
|
||||
@@ -0,0 +1,202 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The ASCE C&C wind engine wired into Panel Analysis (solve side).
|
||||
|
||||
`panellift.windload` is CPython (dataclasses + typing) and the Panel Analysis
|
||||
form is IronPython 2.7, so the pressure is COMPUTED here, on the CPython
|
||||
deck-solve side (`panelanalysis_schema` / `panelanalysis`), from the ASCE inputs
|
||||
the form collects -- not in the form. These tests pin:
|
||||
|
||||
* the computed C&C pressure matches `windload` directly (no second opinion);
|
||||
* the direct > computed > blank precedence that decides what the in-place
|
||||
wind case runs on;
|
||||
* the NOT-JUDGED pass-through (h>60 ft): a reason, never a fabricated pressure;
|
||||
* the enclosure -> GCpi value that reaches the page;
|
||||
* a wired deck actually RUNS the in-place wind case on the computed pressure,
|
||||
and the page renders the pressure + GCpi with no traceback and no CJK.
|
||||
|
||||
Host CPython, loaded like its `test_panelanalysis*` siblings. Generic ASCE
|
||||
example numbers only (the textbook low-rise wall from test_windload); no client
|
||||
data.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
TOOLS = os.path.join(ROOT, 'tools')
|
||||
PANELLIFT_SRC = os.path.join(ROOT, 'panellift', 'src')
|
||||
for p in (TOOLS, PANELLIFT_SRC):
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
from vibe import knowledge # noqa: E402,F401 (registers the vibe stub)
|
||||
|
||||
pa = pytest.importorskip('panelanalysis')
|
||||
schema = pytest.importorskip('panelanalysis_schema')
|
||||
windload = pytest.importorskip('panellift.windload')
|
||||
|
||||
CJK = re.compile(u'[一-鿿]')
|
||||
|
||||
|
||||
def _asce_deck(wind_over=None, restraints=True):
|
||||
"""A mild solid wall pinned top/bottom with ASCE C&C wind inputs on it."""
|
||||
deck = schema.new_deck(length_ft=30.0, width_ft=10.0, thickness_in=12.0,
|
||||
fc_psi=6000.0, strands=[])
|
||||
if restraints:
|
||||
deck['structural_model']['restraints'] = [
|
||||
{'x_ft': 0.0, 'type': 'pin'}, {'x_ft': 30.0, 'type': 'pin'}]
|
||||
wind = {'asce': {
|
||||
'v_ult_mph': 115, 'risk_category': 'II', 'exposure': 'C',
|
||||
'enclosure': 'enclosed', 'code_version': '7-16',
|
||||
'mean_roof_height_ft': 30, 'effective_wind_area_ft2': 100,
|
||||
'cc_chapter': 30}}
|
||||
if wind_over is not None:
|
||||
wind = wind_over
|
||||
deck['loading']['wind'] = wind
|
||||
return deck
|
||||
|
||||
|
||||
def _windload_governing(**kw):
|
||||
"""The governing |p| windload reports directly, for the same inputs."""
|
||||
res = windload.compute(
|
||||
v_ult_mph=115.0, risk_category='II', exposure='C',
|
||||
enclosure='enclosed', code_version='7-16',
|
||||
mean_roof_height_ft=30.0, effective_wind_area_ft2=100.0, **kw)
|
||||
mags = [abs(z.positive_psf) for z in res.zones] + \
|
||||
[abs(z.negative_psf) for z in res.zones]
|
||||
return max(mags)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the decision
|
||||
|
||||
def test_computed_pressure_matches_windload_directly():
|
||||
deck = _asce_deck()
|
||||
info = schema.resolve_wind(deck)
|
||||
assert info['source'] == 'computed'
|
||||
assert info['pressure_psf'] == pytest.approx(_windload_governing(), abs=0.01)
|
||||
# the figure-read uncertainty and the ASCE cite travel with it
|
||||
assert info['uncertainty_psf'] > 0.0
|
||||
assert info['cite']
|
||||
# enclosure -> GCpi reaches the page (enclosed = 0.18)
|
||||
assert info['gcpi'] == 0.18
|
||||
|
||||
|
||||
def test_direct_entry_overrides_the_computed_pressure():
|
||||
deck = _asce_deck()
|
||||
deck['loading']['wind']['direct_pressure_psf'] = 45.0
|
||||
info = schema.resolve_wind(deck)
|
||||
assert info['source'] == 'direct'
|
||||
assert info['pressure_psf'] == 45.0
|
||||
# the computed values are still carried for reference
|
||||
assert info['result'] is not None and not info['result'].not_judged
|
||||
|
||||
|
||||
def test_direct_suction_alone_still_drives_the_case():
|
||||
deck = _asce_deck(wind_over={'direct_suction_psf': 40.0})
|
||||
info = schema.resolve_wind(deck)
|
||||
assert info['source'] == 'direct'
|
||||
assert info['pressure_psf'] == 40.0
|
||||
|
||||
|
||||
def test_h_over_60_is_not_judged_never_a_fabricated_pressure():
|
||||
deck = _asce_deck()
|
||||
deck['loading']['wind']['asce']['mean_roof_height_ft'] = 80
|
||||
info = schema.resolve_wind(deck)
|
||||
assert info['source'] == 'not_judged'
|
||||
assert info['pressure_psf'] is None
|
||||
assert 'NOT JUDGED' in info['reason'] and '60' in info['reason']
|
||||
|
||||
|
||||
def test_unknown_version_is_not_judged():
|
||||
deck = _asce_deck()
|
||||
deck['loading']['wind']['asce']['code_version'] = '7-05'
|
||||
info = schema.resolve_wind(deck)
|
||||
assert info['source'] == 'not_judged'
|
||||
assert info['pressure_psf'] is None
|
||||
|
||||
|
||||
def test_incomplete_asce_is_not_judged_with_what_is_missing():
|
||||
deck = _asce_deck(wind_over={'asce_incomplete': 'still need Vult (mph)'})
|
||||
info = schema.resolve_wind(deck)
|
||||
assert info['source'] == 'not_judged'
|
||||
assert 'Vult' in info['reason']
|
||||
|
||||
|
||||
# ------------------------------------------------------------- feed to inplace
|
||||
|
||||
def test_wind_zones_builds_one_zone_over_the_panel_length():
|
||||
deck = _asce_deck()
|
||||
panel = schema.to_panel(deck)
|
||||
zones, trib, reason = schema.wind_zones(deck, panel)
|
||||
assert reason is None and len(zones) == 1
|
||||
z = zones[0]
|
||||
assert z.x0_ft == 0.0 and z.x1_ft == pytest.approx(panel.length_ft)
|
||||
assert z.pressure_psf == pytest.approx(_windload_governing(), abs=0.01)
|
||||
|
||||
|
||||
def test_explicit_given_zones_still_work_untouched():
|
||||
deck = _asce_deck(wind_over={'zones': [
|
||||
{'x0_ft': 0.0, 'x1_ft': 30.0, 'pressure_psf': 50.0, 'label': 'W1'}]})
|
||||
panel = schema.to_panel(deck)
|
||||
zones, _t, reason = schema.wind_zones(deck, panel)
|
||||
assert reason is None and zones[0].pressure_psf == 50.0
|
||||
# the ASCE decision is not taken for the given-pressure path
|
||||
assert schema.resolve_wind(deck) is None
|
||||
|
||||
|
||||
def test_in_place_runs_on_the_computed_wind_pressure():
|
||||
deck = _asce_deck()
|
||||
panel = schema.to_panel(deck)
|
||||
result, reason = pa.solve_in_place(deck, panel)
|
||||
assert reason is None and result is not None and not result.refusal
|
||||
w = result.cases.get('W')
|
||||
assert w is not None and w.built # wind case is real
|
||||
assert max(abs(v) for v in w.moment_lbft) > 0.0 # it bends the wall
|
||||
|
||||
|
||||
def test_no_restraints_still_refuses_but_wind_is_computed_for_display():
|
||||
deck = _asce_deck(restraints=False)
|
||||
panel = schema.to_panel(deck)
|
||||
result, reason = pa.solve_in_place(deck, panel)
|
||||
# in-place needs >=2 restraints; the pressure is still computable for show
|
||||
assert result is None and reason
|
||||
assert schema.resolve_wind(deck)['source'] == 'computed'
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the rendering
|
||||
|
||||
def test_page_renders_the_wind_section_pressure_and_gcpi():
|
||||
deck = _asce_deck()
|
||||
html = pa.build_analysis_page(deck)
|
||||
assert 'In-Place Wind (ASCE C&C, computed)' in html
|
||||
assert 'Computed C&C wall pressures' in html
|
||||
assert 'GCpi' in html and '0.18' in html
|
||||
assert 'Traceback' not in html
|
||||
m = CJK.search(html)
|
||||
assert not m, 'CJK: {0!r}'.format(m.group() if m else '')
|
||||
|
||||
|
||||
def test_page_shows_not_judged_reason_and_no_pressure_for_tall_wall():
|
||||
deck = _asce_deck()
|
||||
deck['loading']['wind']['asce']['mean_roof_height_ft'] = 80
|
||||
html = pa.build_analysis_page(deck)
|
||||
assert 'NOT JUDGED' in html
|
||||
assert 'Traceback' not in html
|
||||
|
||||
|
||||
def test_cli_end_to_end_renders_wind_under_tmp_path(tmp_path):
|
||||
deck = _asce_deck()
|
||||
deck_path = tmp_path / 'PNL101.pa.json'
|
||||
out_path = tmp_path / 'out.html'
|
||||
with io.open(str(deck_path), 'w', encoding='utf-8') as fh:
|
||||
json.dump(deck, fh)
|
||||
rc = pa.main([str(deck_path), '-o', str(out_path)])
|
||||
assert rc == 0 and out_path.exists()
|
||||
html = io.open(str(out_path), encoding='utf-8').read()
|
||||
assert 'In-Place Wind' in html and 'Traceback' not in html
|
||||
@@ -0,0 +1,700 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Host tests for `vibe.panelanalysisform` -- the Panel Analysis input form's
|
||||
pure logic: pre-fill, the completeness gate, the deck build, the Analyze-All
|
||||
batch plan, the plain-language section/group structure, the Handling stage
|
||||
matrix, and the openings / strand-group row parsers.
|
||||
|
||||
The WPF form itself (the accordion, the per-panel Run Analysis buttons, the
|
||||
live geometry read off the framing element) is owner-must-verify in Revit;
|
||||
everything below the form boundary is here. Loaded by path like the sibling
|
||||
`test_panelanalysis*` files -- host CPython reaching `tools/` for the schema so
|
||||
a deck this module builds can be fed straight back through the real
|
||||
`panelanalysis/1` reader and solve.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
TOOLS = os.path.join(ROOT, 'tools')
|
||||
PANELLIFT_SRC = os.path.join(ROOT, 'panellift', 'src')
|
||||
for p in (TOOLS, PANELLIFT_SRC):
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
from vibe import knowledge # noqa: E402,F401 (registers the vibe stub)
|
||||
from vibe import panelanalysisform as F # noqa: E402
|
||||
|
||||
schema = pytest.importorskip('panelanalysis_schema')
|
||||
statics = pytest.importorskip('panellift.statics')
|
||||
|
||||
|
||||
# A model-read for a plain solid panel: geometry + materials the framing
|
||||
# element supplied, supports blank (the user types those). Generic values.
|
||||
READS = {
|
||||
'mark': 'PNL101',
|
||||
'design_number': 'PNL1',
|
||||
'length_ft': 24.0,
|
||||
'width_ft': 8.0,
|
||||
'thickness_in': 8.0,
|
||||
'fc_psi': 6000.0,
|
||||
'fci_psi': 3500.0,
|
||||
'unit_weight_pcf': 150.0,
|
||||
}
|
||||
|
||||
|
||||
def _complete_values():
|
||||
"""A fully-fillable form: prefilled reads + the user's supports."""
|
||||
values = F.prefill(READS)
|
||||
values['supports_ft'] = '2, 22'
|
||||
return values
|
||||
|
||||
|
||||
def _with_prestress(values=None, count='4', dia='1/2', pull='28900',
|
||||
rows='4 @ 2'):
|
||||
values = dict(values or _complete_values())
|
||||
values['ps_count'] = count
|
||||
values['ps_dia_in'] = dia
|
||||
values['ps_pull_lb'] = pull
|
||||
values['ps_rows'] = rows
|
||||
return values
|
||||
|
||||
|
||||
# --------------------------------------------------------------- structure
|
||||
|
||||
def test_sections_are_the_five_eriksson_names_and_cover_every_field():
|
||||
names = [name for name, _groups in F.sections()]
|
||||
assert names == list(F.SECTION_ORDER)
|
||||
assert names == ['Design Criteria', 'Concrete Extents',
|
||||
'Structural Model', 'Handling', 'Reinforcement']
|
||||
covered = set()
|
||||
for _name, groups in F.sections():
|
||||
for _group_name, fields in groups:
|
||||
for f in fields:
|
||||
covered.add(f['key'])
|
||||
field_keys = set(f['key'] for f in F.FIELDS)
|
||||
assert covered == field_keys
|
||||
|
||||
|
||||
def test_sections_group_fields_and_every_group_is_named_or_none():
|
||||
for _name, groups in F.sections():
|
||||
group_names = [g for g, _fields in groups]
|
||||
# at most one ungrouped (None) bucket, and it comes first if present
|
||||
assert group_names.count(None) <= 1
|
||||
if None in group_names:
|
||||
assert group_names[0] is None
|
||||
# every named group is non-empty
|
||||
for g, fields in groups:
|
||||
assert fields
|
||||
if g is not None:
|
||||
assert isinstance(g, str) and g
|
||||
|
||||
|
||||
def test_design_criteria_has_the_four_named_groups():
|
||||
by_section = dict(F.sections())
|
||||
dc_groups = [g for g, _f in by_section['Design Criteria']]
|
||||
for want in ('Material Properties', 'Prestress Losses',
|
||||
'Load Combinations', 'Stress Limits & Factors'):
|
||||
assert want in dc_groups
|
||||
|
||||
|
||||
def test_every_field_declares_a_known_section_group_and_drives_flag():
|
||||
for f in F.FIELDS:
|
||||
assert f['section'] in F.SECTION_ORDER, f['key']
|
||||
assert f['drives'] in ('solve', 'stage3', 'info'), f['key']
|
||||
assert f['kind'] in ('text', 'num', 'int', 'supports', 'openings',
|
||||
'strands'), f['key']
|
||||
assert isinstance(f['label'], str) and f['label']
|
||||
assert isinstance(f['help'], str) and f['help']
|
||||
# the plain label carries no raw prime/subscript math -- that lives
|
||||
# in `symbol` instead (the owner's "太丑了" ruling)
|
||||
assert u'′' not in f['label'], f['key']
|
||||
|
||||
|
||||
def test_wind_fields_are_present_blank_and_collected_not_yet_driving():
|
||||
wind = [f for f in F.FIELDS if f['key'].startswith('wind_')]
|
||||
keys = set(f['key'] for f in wind)
|
||||
for want in ('wind_speed_vult', 'wind_risk_category', 'wind_exposure',
|
||||
'wind_enclosure', 'wind_asce_version', 'wind_cc_chapter',
|
||||
'wind_mwfrs_chapter', 'wind_pressure_psf', 'wind_suction_psf'):
|
||||
assert want in keys
|
||||
for f in wind:
|
||||
assert f['section'] == 'Structural Model'
|
||||
assert f['group'] == 'Wind'
|
||||
assert f['drives'] == 'stage3' # collected, not yet driving
|
||||
values = F.prefill(READS)
|
||||
for k in keys:
|
||||
assert values[k] == ''
|
||||
|
||||
|
||||
def test_strip_transport_erection_sling_fields_are_gone():
|
||||
# migrated INTO the stage matrix, not left sitting beside it
|
||||
keys = set(f['key'] for f in F.FIELDS)
|
||||
for gone in ('strip_mult', 'transport_mult', 'erection_mult',
|
||||
'sling_angle', 'ps_ecc_in'):
|
||||
assert gone not in keys
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- prefill
|
||||
|
||||
def test_prefill_fills_readable_and_blanks_the_rest():
|
||||
values = F.prefill(READS)
|
||||
assert values['length_ft'] == '24'
|
||||
assert values['width_ft'] == '8'
|
||||
assert values['thickness_in'] == '8'
|
||||
assert values['fc_psi'] == '6000'
|
||||
assert values['fci_psi'] == '3500'
|
||||
assert values['mark'] == 'PNL101'
|
||||
# supports and every stage3 / load field come up blank for the user
|
||||
assert values['supports_ft'] == ''
|
||||
assert values['self_weight_factor'] == ''
|
||||
assert values['ps_count'] == ''
|
||||
assert values['ps_rows'] == ''
|
||||
assert values['openings'] == ''
|
||||
|
||||
|
||||
def test_prefill_blanks_a_field_the_model_could_not_read():
|
||||
reads = dict(READS)
|
||||
reads['fci_psi'] = None
|
||||
del reads['unit_weight_pcf']
|
||||
values = F.prefill(reads)
|
||||
assert values['fci_psi'] == ''
|
||||
assert values['unit_weight_pcf'] == ''
|
||||
|
||||
|
||||
def test_prefill_blanks_every_stage_matrix_cell():
|
||||
values = F.prefill(READS)
|
||||
for key in F.stage_field_keys():
|
||||
assert values[key] == ''
|
||||
|
||||
|
||||
# -------------------------------------------------------------- validation
|
||||
|
||||
def test_complete_form_validates_and_parses():
|
||||
errors, parsed = F.validate(_complete_values())
|
||||
assert errors == []
|
||||
assert parsed['supports'] == [2.0, 22.0]
|
||||
assert parsed['length_ft'] == 24.0
|
||||
assert parsed['fc_psi'] == 6000.0
|
||||
assert parsed['openings_rows'] == []
|
||||
assert parsed['strand_rows'] == []
|
||||
|
||||
|
||||
def test_blank_required_field_blocks_and_is_named():
|
||||
values = _complete_values()
|
||||
values['fc_psi'] = ''
|
||||
errors, parsed = F.validate(values)
|
||||
assert parsed is None
|
||||
# The label now reads plain English; the symbol (f′c) still rides beside
|
||||
# it via `display_label`. What is pinned here is that the message names
|
||||
# the field, in BOTH vocabularies.
|
||||
assert any('Concrete strength at 28 days' in e and 'required' in e
|
||||
for e in errors)
|
||||
assert any(u'f′c' in e for e in errors)
|
||||
|
||||
|
||||
def test_fewer_than_two_supports_is_refused_by_name():
|
||||
values = _complete_values()
|
||||
values['supports_ft'] = '2'
|
||||
errors, parsed = F.validate(values)
|
||||
assert parsed is None
|
||||
assert any('at least two support' in e for e in errors)
|
||||
assert not F.is_complete(values)
|
||||
|
||||
|
||||
def test_no_supports_is_refused():
|
||||
values = _complete_values()
|
||||
values['supports_ft'] = ''
|
||||
errors, parsed = F.validate(values)
|
||||
assert parsed is None
|
||||
assert any('support' in e.lower() for e in errors)
|
||||
|
||||
|
||||
def test_garbage_support_token_is_named():
|
||||
values = _complete_values()
|
||||
values['supports_ft'] = '2, banana'
|
||||
errors, parsed = F.validate(values)
|
||||
assert parsed is None
|
||||
assert any('banana' in e for e in errors)
|
||||
|
||||
|
||||
def test_stage3_field_never_blocks_the_run():
|
||||
values = _complete_values()
|
||||
# a wind value typed in but the solve does not use it -> still runnable,
|
||||
# and even a non-numeric stray in a stage3 field must not block
|
||||
values['wind_speed_vult'] = 'not a number'
|
||||
values['wind_risk_category'] = 'II'
|
||||
assert F.is_complete(values)
|
||||
|
||||
|
||||
def test_bad_number_in_a_solve_field_blocks():
|
||||
values = _complete_values()
|
||||
values['unit_weight_pcf'] = 'heavy'
|
||||
errors, parsed = F.validate(values)
|
||||
assert parsed is None
|
||||
assert any('Concrete unit weight' in e for e in errors)
|
||||
|
||||
|
||||
# -------------------------------------------------------------- prestress
|
||||
|
||||
def test_no_prestress_fields_means_no_prestress_block():
|
||||
_errors, parsed = F.validate(_complete_values())
|
||||
assert parsed['prestress'] is None
|
||||
assert parsed['strand_rows'] == []
|
||||
|
||||
|
||||
def test_full_prestress_group_builds_strands():
|
||||
values = _with_prestress(rows='4 @ 2')
|
||||
errors, parsed = F.validate(values)
|
||||
assert errors == []
|
||||
ps = parsed['prestress']
|
||||
assert len(ps['strands']) == 4
|
||||
assert all(s['area_in2'] == 0.153 for s in ps['strands'])
|
||||
assert all(s['initial_pull_lb'] == 28900.0 for s in ps['strands'])
|
||||
assert all(s['z_in'] == 2.0 for s in ps['strands'])
|
||||
|
||||
|
||||
def test_prestress_group_with_multiple_rows_and_per_row_pull_pct():
|
||||
values = _with_prestress(count='8', rows='4 @ 2 @ 75\n4 @ 9.25 @ 56')
|
||||
values['ps_fpu_ksi'] = '270'
|
||||
errors, parsed = F.validate(values)
|
||||
assert errors == []
|
||||
ps = parsed['prestress']
|
||||
assert len(ps['strands']) == 8
|
||||
shallow = [s for s in ps['strands'] if s['z_in'] == 2.0]
|
||||
deep = [s for s in ps['strands'] if s['z_in'] == 9.25]
|
||||
assert len(shallow) == 4 and len(deep) == 4
|
||||
# pull = pct/100 * fpu(ksi)*1000 * area, not the default ps_pull_lb
|
||||
assert all(abs(s['initial_pull_lb'] - 0.75 * 270.0 * 1000.0 * 0.153) < 1
|
||||
for s in shallow)
|
||||
assert all(abs(s['initial_pull_lb'] - 0.56 * 270.0 * 1000.0 * 0.153) < 1
|
||||
for s in deep)
|
||||
|
||||
|
||||
def test_partial_prestress_group_is_refused_field_by_field():
|
||||
values = _complete_values()
|
||||
values['ps_count'] = '4' # started, but no size/pull/rows
|
||||
errors, parsed = F.validate(values)
|
||||
assert parsed is None
|
||||
assert any('diameter' in e for e in errors)
|
||||
assert any('pull' in e for e in errors)
|
||||
assert any('Strand groups' in e for e in errors)
|
||||
|
||||
|
||||
def test_unknown_strand_size_is_refused_not_guessed():
|
||||
values = _with_prestress(count='2', dia='0.99', rows='2 @ 2')
|
||||
errors, parsed = F.validate(values)
|
||||
assert parsed is None
|
||||
assert any('section area' in e for e in errors)
|
||||
|
||||
|
||||
def test_ps_count_disagreement_with_rows_is_reported_not_resolved():
|
||||
values = _with_prestress(count='5', rows='4 @ 2') # 5 != 4
|
||||
errors, parsed = F.validate(values)
|
||||
assert parsed is None
|
||||
assert any('does not match the sum' in e for e in errors)
|
||||
|
||||
|
||||
def test_ps_count_blank_skips_the_cross_check():
|
||||
values = _with_prestress(count='', rows='4 @ 2')
|
||||
errors, parsed = F.validate(values)
|
||||
assert errors == []
|
||||
assert len(parsed['prestress']['strands']) == 4
|
||||
|
||||
|
||||
def test_garbage_strand_row_line_is_named_by_line_number():
|
||||
values = _with_prestress(rows='4 @ 2\nbanana')
|
||||
errors, parsed = F.validate(values)
|
||||
assert parsed is None
|
||||
assert any('line 2' in e for e in errors)
|
||||
|
||||
|
||||
def test_strand_rows_are_kept_in_parsed_even_when_group_incomplete():
|
||||
values = _complete_values()
|
||||
values['ps_rows'] = '4 @ 2' # rows typed, but no dia/pull -> refused
|
||||
errors, parsed = F.validate(values)
|
||||
assert parsed is None # the whole panel is refused (as before)
|
||||
# but strand_rows / ps_rows are not something to re-derive here; the
|
||||
# raw text is preserved off `values` regardless, and validate() does not
|
||||
# crash trying to reach into it.
|
||||
|
||||
|
||||
# ------------------------------------------------------------- openings
|
||||
|
||||
def test_blank_openings_is_no_openings():
|
||||
values = _complete_values()
|
||||
values['openings'] = ''
|
||||
errors, parsed = F.validate(values)
|
||||
assert errors == []
|
||||
assert parsed['openings_rows'] == []
|
||||
assert parsed['openings'] == ''
|
||||
|
||||
|
||||
def test_one_opening_per_line_parses_the_new_grammar():
|
||||
rows, problems = F.parse_openings(
|
||||
'Opening, 2, start, 3, 4, left, 5, 1, 6, Door\n'
|
||||
'Reveal, 1, end, 2, 1, right, 2, 0, 1')
|
||||
assert problems == []
|
||||
assert len(rows) == 2
|
||||
door = rows[0]
|
||||
assert door == {'type': 'Opening', 'pos_along': 2.0,
|
||||
'pos_along_from': 'start', 'length': 3.0,
|
||||
'pos_cross': 4.0, 'pos_cross_from': 'left',
|
||||
'width': 5.0, 'pos_vert': 1.0, 'height': 6.0,
|
||||
'label': 'Door'}
|
||||
reveal = rows[1]
|
||||
assert reveal['type'] == 'Reveal'
|
||||
assert reveal['pos_along_from'] == 'end'
|
||||
assert reveal['pos_cross_from'] == 'right'
|
||||
assert reveal['label'] == ''
|
||||
|
||||
|
||||
def test_openings_bad_type_is_named_by_line_number():
|
||||
rows, problems = F.parse_openings('Doorhole, 2, start, 3, 4, left, 5, 1, 6')
|
||||
assert rows == []
|
||||
assert any('line 1' in p and 'type' in p for p in problems)
|
||||
|
||||
|
||||
def test_openings_bad_datum_word_is_named():
|
||||
rows, problems = F.parse_openings(
|
||||
'Opening, 2, sideways, 3, 4, left, 5, 1, 6')
|
||||
assert rows == []
|
||||
assert any('pos_along_from' in p for p in problems)
|
||||
|
||||
|
||||
def test_openings_all_or_nothing_across_lines():
|
||||
rows, problems = F.parse_openings(
|
||||
'Opening, 2, start, 3, 4, left, 5, 1, 6\nbanana')
|
||||
assert rows == [] # the good line is not kept either
|
||||
assert len(problems) == 1
|
||||
assert 'line 2' in problems[0]
|
||||
|
||||
|
||||
def test_openings_row_reaches_validate_parsed_output():
|
||||
values = _complete_values()
|
||||
values['openings'] = 'Opening, 2, start, 3, 4, left, 5, 1, 6, Door'
|
||||
errors, parsed = F.validate(values)
|
||||
assert errors == []
|
||||
assert parsed['openings_rows'] == [{
|
||||
'type': 'Opening', 'pos_along': 2.0, 'pos_along_from': 'start',
|
||||
'length': 3.0, 'pos_cross': 4.0, 'pos_cross_from': 'left',
|
||||
'width': 5.0, 'pos_vert': 1.0, 'height': 6.0, 'label': 'Door'}]
|
||||
assert parsed['openings'] == values['openings']
|
||||
|
||||
|
||||
def test_bad_openings_line_blocks_the_run_and_names_the_line():
|
||||
values = _complete_values()
|
||||
values['openings'] = 'banana'
|
||||
errors, parsed = F.validate(values)
|
||||
assert parsed is None
|
||||
assert any('line 1' in e for e in errors)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ stage matrix
|
||||
|
||||
def test_stage_matrix_shape():
|
||||
m = F.stage_matrix()
|
||||
assert m['stages'] == ['Stripping', 'Transportation', 'Primary Erection',
|
||||
'Secondary Erection']
|
||||
opt_keys = [r['opt_key'] for r in m['rows']]
|
||||
assert opt_keys == ['include', 'load_multiplier', 'lift_type',
|
||||
'concrete_strength', 'stress_limits', 'rigging',
|
||||
'sling_angle', 'ground_reaction', 'min_tilt_deg',
|
||||
'max_tilt_deg', 'top_crane_carries']
|
||||
for row in m['rows']:
|
||||
assert set(row['cells'].keys()) == set(m['stages'])
|
||||
for stage, key in row['cells'].items():
|
||||
assert key == F.stage_field_key(stage, row['opt_key'])
|
||||
|
||||
|
||||
def test_stage_matrix_offers_choices_but_never_a_default():
|
||||
m = F.stage_matrix()
|
||||
by_key = dict((r['opt_key'], r) for r in m['rows'])
|
||||
assert by_key['lift_type']['choices'] == ('Static Lift', 'Rolling Block')
|
||||
assert by_key['include']['choices'] is None
|
||||
values = F.prefill(READS)
|
||||
for key in F.stage_field_keys():
|
||||
assert values[key] == '' # nothing pre-selected
|
||||
|
||||
|
||||
def test_stage_matrix_load_multiplier_rides_into_deck():
|
||||
values = _complete_values()
|
||||
values[F.stage_field_key('Stripping', 'load_multiplier')] = '1.4'
|
||||
values[F.stage_field_key('Transportation', 'load_multiplier')] = '1.5'
|
||||
values[F.stage_field_key('Primary Erection', 'load_multiplier')] = '1.2'
|
||||
errors, parsed = F.validate(values)
|
||||
assert errors == []
|
||||
deck = F.build_deck(parsed)
|
||||
assert deck['handling']['stripping_multiplier'] == 1.4
|
||||
assert deck['handling']['transport_multiplier'] == 1.5
|
||||
assert deck['handling']['erection_multiplier'] == 1.2
|
||||
|
||||
|
||||
def test_excluded_stage_does_not_gate_the_run_on_its_other_fields():
|
||||
values = _complete_values()
|
||||
values[F.stage_field_key('Secondary Erection', 'include')] = 'no'
|
||||
values[F.stage_field_key('Secondary Erection', 'load_multiplier')] = \
|
||||
'not a number'
|
||||
assert F.is_complete(values)
|
||||
|
||||
|
||||
def test_excluded_stage_keeps_the_typed_value_rather_than_dropping_it():
|
||||
values = _complete_values()
|
||||
values[F.stage_field_key('Secondary Erection', 'include')] = 'no'
|
||||
values[F.stage_field_key('Secondary Erection', 'load_multiplier')] = '9.9'
|
||||
errors, parsed = F.validate(values)
|
||||
assert errors == []
|
||||
row = parsed['stage_matrix']['Secondary Erection']
|
||||
assert row['include'] is False
|
||||
assert row['load_multiplier'] == 9.9 # kept, just not required/used
|
||||
|
||||
|
||||
def test_excluded_stage_multiplier_does_not_reach_the_deck():
|
||||
values = _complete_values()
|
||||
values[F.stage_field_key('Stripping', 'include')] = 'no'
|
||||
values[F.stage_field_key('Stripping', 'load_multiplier')] = '1.4'
|
||||
errors, parsed = F.validate(values)
|
||||
assert errors == []
|
||||
deck = F.build_deck(parsed)
|
||||
assert 'stripping_multiplier' not in deck['handling']
|
||||
|
||||
|
||||
def test_included_stage_bad_load_multiplier_blocks_and_names_the_stage():
|
||||
values = _complete_values()
|
||||
values[F.stage_field_key('Stripping', 'load_multiplier')] = 'heavy'
|
||||
errors, parsed = F.validate(values)
|
||||
assert parsed is None
|
||||
assert any('Stripping' in e and 'Load multiplier' in e for e in errors)
|
||||
|
||||
|
||||
def test_bad_include_answer_is_named():
|
||||
values = _complete_values()
|
||||
values[F.stage_field_key('Stripping', 'include')] = 'maybe'
|
||||
errors, parsed = F.validate(values)
|
||||
assert parsed is None
|
||||
assert any('Stripping' in e for e in errors)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- deck build
|
||||
|
||||
def _deck_from(values):
|
||||
errors, parsed = F.validate(values)
|
||||
assert errors == [], errors
|
||||
return F.build_deck(parsed)
|
||||
|
||||
|
||||
def test_build_deck_is_the_panelanalysis_schema():
|
||||
deck = _deck_from(_complete_values())
|
||||
assert deck['schema'] == F.SCHEMA == schema.SCHEMA
|
||||
schema.check_schema(deck) # does not raise
|
||||
panel = schema.to_panel(deck)
|
||||
assert panel.mark == 'PNL101'
|
||||
assert panel.length_ft == 24.0
|
||||
assert panel.fc_psi == 6000.0
|
||||
|
||||
|
||||
def test_deck_supports_reach_the_schema():
|
||||
deck = _deck_from(_complete_values())
|
||||
assert schema.supports(deck) == [(2.0, 'support'), (22.0, 'support')]
|
||||
|
||||
|
||||
def test_deck_solves_and_reactions_sum_to_load():
|
||||
deck = _deck_from(_complete_values())
|
||||
panel = schema.to_panel(deck)
|
||||
stage = schema.handling_stage(deck, panel)
|
||||
analysis = statics.analyse(panel, stage, schema.handling_rigging())
|
||||
total_r = sum(line.reaction_lb for line in analysis.rig.lines)
|
||||
total_load = analysis.profile.total_lb
|
||||
# the closure that says the solve is real: reactions sum to the load
|
||||
assert abs(total_r - total_load) <= max(1.0, 1e-6 * abs(total_load))
|
||||
assert total_load > 0
|
||||
|
||||
|
||||
def test_unbuilt_blocks_are_explicit_unread_not_fabricated():
|
||||
deck = _deck_from(_complete_values())
|
||||
for block in (deck['in_place'], deck['loading']['wind'],
|
||||
deck['structural_model']['restraints']):
|
||||
assert block['value'] is None
|
||||
assert block['reason']
|
||||
|
||||
|
||||
def test_openings_never_reach_the_deck_yet():
|
||||
values = _complete_values()
|
||||
values['openings'] = 'Opening, 2, start, 3, 4, left, 5, 1, 6, Door'
|
||||
deck = _deck_from(values)
|
||||
assert deck['concrete_extents']['blockouts'] == []
|
||||
|
||||
|
||||
def test_prestress_deck_reads_as_a_prestress_panel():
|
||||
values = _with_prestress(count='2', rows='2 @ 2')
|
||||
deck = _deck_from(values)
|
||||
panel = schema.to_panel(deck)
|
||||
assert panel.prestress is not None
|
||||
assert len(panel.prestress.strands) == 2
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ batch
|
||||
|
||||
# ------------------------------------------------------------------- wind
|
||||
|
||||
def test_new_asce_fields_are_present_blank_and_stage3():
|
||||
keys = set(f['key'] for f in F.FIELDS)
|
||||
for want in ('wind_mean_roof_height_ft', 'wind_effective_wind_area_ft2'):
|
||||
assert want in keys
|
||||
spec = F._spec(want)
|
||||
assert spec['section'] == 'Structural Model'
|
||||
assert spec['drives'] == 'stage3'
|
||||
values = F.prefill(READS)
|
||||
assert values['wind_mean_roof_height_ft'] == ''
|
||||
assert values['wind_effective_wind_area_ft2'] == ''
|
||||
|
||||
|
||||
def test_wind_gcpi_matches_windload_table_exactly():
|
||||
# The form's GCpi table is a mirror of windload's; a host test pins it so
|
||||
# the two published-code tables can never drift.
|
||||
windload = pytest.importorskip('panellift.windload')
|
||||
for key, val in windload._GCPI.items():
|
||||
got, cite = F.wind_gcpi(key)
|
||||
assert got == val, key
|
||||
assert cite
|
||||
|
||||
|
||||
def test_wind_gcpi_display_for_each_enclosure_and_spellings():
|
||||
assert F.wind_gcpi('enclosed')[0] == 0.18
|
||||
assert F.wind_gcpi('partially_enclosed')[0] == 0.55
|
||||
assert F.wind_gcpi('Partially Enclosed')[0] == 0.55 # spacing/case
|
||||
assert F.wind_gcpi('partial-open')[0] == 0.18 # alias + dash
|
||||
assert F.wind_gcpi('open')[0] == 0.00
|
||||
|
||||
|
||||
def test_wind_gcpi_blank_and_unknown_are_could_not_judge_not_zero():
|
||||
val, why = F.wind_gcpi('')
|
||||
assert val is None and why # not 0.0
|
||||
val, why = F.wind_gcpi('mostly closed')
|
||||
assert val is None and 'not recognized' in why
|
||||
|
||||
|
||||
def test_resolve_wind_pressure_direct_beats_computed():
|
||||
d = F.resolve_wind_pressure(31.0, 28.3)
|
||||
assert d['source'] == 'direct' and d['pressure_psf'] == 31.0
|
||||
|
||||
|
||||
def test_resolve_wind_pressure_computed_when_no_direct():
|
||||
d = F.resolve_wind_pressure(None, 28.3)
|
||||
assert d['source'] == 'computed' and d['pressure_psf'] == 28.3
|
||||
|
||||
|
||||
def test_resolve_wind_pressure_not_judged_passes_reason_through():
|
||||
d = F.resolve_wind_pressure(None, None, 'NOT JUDGED: h > 60 ft ...')
|
||||
assert d['source'] == 'not_judged'
|
||||
assert d['pressure_psf'] is None # never fabricated
|
||||
assert 'NOT JUDGED' in d['reason']
|
||||
|
||||
|
||||
def test_resolve_wind_pressure_direct_wins_even_over_not_judged():
|
||||
d = F.resolve_wind_pressure(31.0, None, 'NOT JUDGED: h > 60 ft ...')
|
||||
assert d['source'] == 'direct' and d['pressure_psf'] == 31.0
|
||||
|
||||
|
||||
def test_resolve_wind_pressure_blank_when_nothing_supplied():
|
||||
d = F.resolve_wind_pressure(None, None, None)
|
||||
assert d['source'] == 'blank' and d['pressure_psf'] is None
|
||||
assert d['reason']
|
||||
|
||||
|
||||
def _wind_values():
|
||||
values = _complete_values()
|
||||
values['wind_speed_vult'] = '115'
|
||||
values['wind_risk_category'] = 'II'
|
||||
values['wind_exposure'] = 'C'
|
||||
values['wind_enclosure'] = 'enclosed'
|
||||
values['wind_asce_version'] = '7-16'
|
||||
values['wind_cc_chapter'] = '30'
|
||||
values['wind_mean_roof_height_ft'] = '30'
|
||||
values['wind_effective_wind_area_ft2'] = '100'
|
||||
return values
|
||||
|
||||
|
||||
def test_build_deck_carries_asce_wind_inputs_when_supplied():
|
||||
deck = _deck_from(_wind_values())
|
||||
wind = deck['loading']['wind']
|
||||
assert 'value' not in wind # no longer unread
|
||||
a = wind['asce']
|
||||
assert a['v_ult_mph'] == 115.0
|
||||
assert a['exposure'] == 'C'
|
||||
assert a['enclosure'] == 'enclosed'
|
||||
assert a['code_version'] == '7-16'
|
||||
assert a['mean_roof_height_ft'] == 30.0
|
||||
assert a['effective_wind_area_ft2'] == 100.0
|
||||
|
||||
|
||||
def test_build_deck_carries_direct_pressure_override():
|
||||
values = _wind_values()
|
||||
values['wind_pressure_psf'] = '45'
|
||||
values['wind_suction_psf'] = '50'
|
||||
deck = _deck_from(values)
|
||||
wind = deck['loading']['wind']
|
||||
assert wind['direct_pressure_psf'] == 45.0
|
||||
assert wind['direct_suction_psf'] == 50.0
|
||||
|
||||
|
||||
def test_partial_asce_without_the_required_numbers_is_not_computed():
|
||||
values = _complete_values()
|
||||
values['wind_exposure'] = 'C' # something typed, but no Vult/h/area
|
||||
deck = _deck_from(values)
|
||||
wind = deck['loading']['wind']
|
||||
assert 'asce' not in wind
|
||||
assert 'asce_incomplete' in wind # says what is still needed
|
||||
|
||||
|
||||
def test_no_wind_inputs_keeps_the_explicit_unread_placeholder():
|
||||
deck = _deck_from(_complete_values())
|
||||
assert deck['loading']['wind']['value'] is None
|
||||
assert deck['loading']['wind']['reason']
|
||||
|
||||
|
||||
def test_restraints_field_reaches_the_deck_when_filled():
|
||||
values = _wind_values()
|
||||
values['restraints'] = '0, 30'
|
||||
deck = _deck_from(values)
|
||||
r = deck['structural_model']['restraints']
|
||||
assert r == [{'x_ft': 0.0, 'type': 'restraint'},
|
||||
{'x_ft': 30.0, 'type': 'restraint'}]
|
||||
|
||||
|
||||
def test_no_restraints_keeps_the_explicit_unread_placeholder():
|
||||
deck = _deck_from(_complete_values())
|
||||
assert deck['structural_model']['restraints']['value'] is None
|
||||
|
||||
|
||||
def test_batch_plan_runs_complete_and_skips_incomplete_with_reasons():
|
||||
good = _complete_values()
|
||||
bad = _complete_values()
|
||||
bad['supports_ft'] = '5' # only one support
|
||||
worse = _complete_values()
|
||||
worse['fc_psi'] = '' # missing material
|
||||
named = [('PNL101', good), ('PNL102', bad), ('PNL103', worse)]
|
||||
to_run, skipped = F.batch_plan(named)
|
||||
run_labels = [lbl for lbl, _p in to_run]
|
||||
skip_labels = [lbl for lbl, _r in skipped]
|
||||
assert run_labels == ['PNL101']
|
||||
assert set(skip_labels) == {'PNL102', 'PNL103'}
|
||||
reasons = dict((lbl, errs) for lbl, errs in skipped)
|
||||
assert any('support' in e for e in reasons['PNL102'])
|
||||
assert any(u'f′c' in e for e in reasons['PNL103'])
|
||||
|
||||
|
||||
# ------------------------------------------------------------ display_label
|
||||
|
||||
def test_display_label_carries_both_label_and_symbol():
|
||||
d = F.display_label('fc_psi')
|
||||
assert 'Concrete strength at 28 days' in d
|
||||
assert u'f′c' in d
|
||||
|
||||
|
||||
def test_display_label_falls_back_to_label_when_no_symbol():
|
||||
d = F.display_label('concrete_type')
|
||||
assert d == 'Concrete type'
|
||||
@@ -0,0 +1,814 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Host tests for `vibe.panelsketch` -- the panel elevation drawing geometry
|
||||
behind the Panel Analysis form's picture. The WPF `Canvas` painting is the
|
||||
button's job and out of scope here (see the module docstring); this pins the
|
||||
primitive list `sketch()` produces: scale-true letterboxing, support
|
||||
placement, the missing-input degrade, and the openings non-drawing rule.
|
||||
|
||||
Loaded the way the sibling `test_panelanalysisform.py` is -- `conftest.py`
|
||||
registers a bare `vibe` package pointed at `lib/vibe` so this imports on plain
|
||||
host CPython with no Revit and no `pyrevit` on the path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
from vibe import panelsketch as S
|
||||
|
||||
|
||||
def _values(**over):
|
||||
base = {
|
||||
'length_ft': '24',
|
||||
'width_ft': '8',
|
||||
'thickness_in': '8',
|
||||
'supports_ft': '2, 22',
|
||||
'openings': '',
|
||||
'mark': 'PNL101',
|
||||
'design_number': 'PNL1',
|
||||
}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
def _rect(primitives):
|
||||
rects = [p for p in primitives if p['kind'] == 'rect' and p['role'] == 'panel']
|
||||
assert len(rects) == 1
|
||||
return rects[0]
|
||||
|
||||
|
||||
def _within_box(primitives, width_px, height_px, tol=1e-6):
|
||||
for p in primitives:
|
||||
if p['kind'] == 'rect':
|
||||
assert -tol <= p['x'] <= width_px + tol, p
|
||||
assert -tol <= p['y'] <= height_px + tol, p
|
||||
assert -tol <= p['x'] + p['w'] <= width_px + tol, p
|
||||
assert -tol <= p['y'] + p['h'] <= height_px + tol, p
|
||||
elif p['kind'] == 'line':
|
||||
for kx, ky in (('x1', 'y1'), ('x2', 'y2')):
|
||||
assert -tol <= p[kx] <= width_px + tol, p
|
||||
assert -tol <= p[ky] <= height_px + tol, p
|
||||
else:
|
||||
if 'x' in p:
|
||||
assert -tol <= p['x'] <= width_px + tol, p
|
||||
if 'y' in p:
|
||||
assert -tol <= p['y'] <= height_px + tol, p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- aspect
|
||||
|
||||
def test_tall_panel_preserves_aspect_ratio():
|
||||
primitives, problems = S.sketch(
|
||||
_values(length_ft='10', width_ft='30', supports_ft='2, 8'), 400, 400)
|
||||
assert problems == []
|
||||
rect = _rect(primitives)
|
||||
assert abs((rect['w'] / rect['h']) - (10.0 / 30.0)) < 1e-6
|
||||
|
||||
|
||||
def test_square_panel_preserves_aspect_ratio():
|
||||
primitives, problems = S.sketch(
|
||||
_values(length_ft='20', width_ft='20', supports_ft='2, 18'), 500, 300)
|
||||
assert problems == []
|
||||
rect = _rect(primitives)
|
||||
assert abs((rect['w'] / rect['h']) - 1.0) < 1e-6
|
||||
|
||||
|
||||
def test_wide_panel_preserves_aspect_ratio():
|
||||
primitives, problems = S.sketch(
|
||||
_values(length_ft='40', width_ft='8'), 300, 500)
|
||||
assert problems == []
|
||||
rect = _rect(primitives)
|
||||
assert abs((rect['w'] / rect['h']) - (40.0 / 8.0)) < 1e-6
|
||||
|
||||
|
||||
def test_letterboxed_panel_does_not_fill_the_box_when_aspect_differs():
|
||||
# a very wide panel in a tall box must not stretch to fill the height
|
||||
primitives, _problems = S.sketch(
|
||||
_values(length_ft='40', width_ft='8'), 200, 800)
|
||||
rect = _rect(primitives)
|
||||
assert rect['h'] < 800
|
||||
|
||||
|
||||
# ------------------------------------------------------------- inside box
|
||||
|
||||
def test_everything_stays_inside_a_normal_box():
|
||||
primitives, _problems = S.sketch(_values(), 640, 480)
|
||||
_within_box(primitives, 640, 480)
|
||||
|
||||
|
||||
def test_everything_stays_inside_a_small_box():
|
||||
primitives, _problems = S.sketch(_values(), 60, 60)
|
||||
_within_box(primitives, 60, 60)
|
||||
|
||||
|
||||
def test_everything_stays_inside_a_very_small_box():
|
||||
primitives, _problems = S.sketch(_values(), 20, 15)
|
||||
_within_box(primitives, 20, 15)
|
||||
|
||||
|
||||
def test_out_of_range_support_still_stays_inside_the_box():
|
||||
primitives, _problems = S.sketch(
|
||||
_values(supports_ft='2, 999'), 400, 300)
|
||||
_within_box(primitives, 400, 300)
|
||||
|
||||
|
||||
# --------------------------------------------------------- missing input
|
||||
|
||||
def test_missing_length_returns_no_primitives_and_names_the_field():
|
||||
primitives, problems = S.sketch(_values(length_ft=''), 400, 300)
|
||||
assert primitives == []
|
||||
assert any('Length' in p for p in problems)
|
||||
|
||||
|
||||
def test_missing_width_returns_no_primitives_and_names_the_field():
|
||||
primitives, problems = S.sketch(_values(width_ft=''), 400, 300)
|
||||
assert primitives == []
|
||||
assert any('Width' in p for p in problems)
|
||||
|
||||
|
||||
def test_zero_length_is_treated_as_missing():
|
||||
primitives, problems = S.sketch(_values(length_ft='0'), 400, 300)
|
||||
assert primitives == []
|
||||
assert any('Length' in p for p in problems)
|
||||
|
||||
|
||||
def test_negative_width_is_treated_as_missing():
|
||||
primitives, problems = S.sketch(_values(width_ft='-4'), 400, 300)
|
||||
assert primitives == []
|
||||
assert any('Width' in p for p in problems)
|
||||
|
||||
|
||||
def test_non_numeric_length_is_treated_as_missing():
|
||||
primitives, problems = S.sketch(_values(length_ft='tall'), 400, 300)
|
||||
assert primitives == []
|
||||
assert any('Length' in p for p in problems)
|
||||
|
||||
|
||||
def test_both_missing_reports_both_fields_not_just_one():
|
||||
_primitives, problems = S.sketch(_values(length_ft='', width_ft=''),
|
||||
400, 300)
|
||||
assert any('Length' in p for p in problems)
|
||||
assert any('Width' in p for p in problems)
|
||||
|
||||
|
||||
def test_missing_thickness_does_not_block_the_drawing_but_is_noted():
|
||||
primitives, problems = S.sketch(_values(thickness_in=''), 400, 300)
|
||||
assert primitives != []
|
||||
assert any('Thickness' in p for p in problems)
|
||||
notes = [p for p in primitives if p['role'] == 'annotation']
|
||||
assert any('not entered' in n['text'] for n in notes)
|
||||
|
||||
|
||||
def test_missing_thickness_never_fakes_a_value():
|
||||
primitives, _problems = S.sketch(_values(thickness_in=''), 400, 300)
|
||||
notes = [p for p in primitives if p['role'] == 'annotation']
|
||||
for n in notes:
|
||||
assert 'in' not in n['text'].split('not entered')[0] or \
|
||||
'not entered' in n['text']
|
||||
|
||||
|
||||
# --------------------------------------------------------------- supports
|
||||
|
||||
def test_supports_land_at_the_true_proportional_position():
|
||||
primitives, problems = S.sketch(
|
||||
_values(length_ft='40', supports_ft='10, 30'), 500, 300)
|
||||
assert problems == []
|
||||
rect = _rect(primitives)
|
||||
markers = [p for p in primitives if p['kind'] == 'support']
|
||||
assert len(markers) == 2
|
||||
xs = sorted(m['x'] for m in markers)
|
||||
# 10/40 and 30/40 of the way along the drawn width, from the rect origin
|
||||
expect_1 = rect['x'] + (10.0 / 40.0) * rect['w']
|
||||
expect_2 = rect['x'] + (30.0 / 40.0) * rect['w']
|
||||
assert abs(xs[0] - expect_1) < 1e-6
|
||||
assert abs(xs[1] - expect_2) < 1e-6
|
||||
for m in markers:
|
||||
assert m['flagged'] is False
|
||||
|
||||
|
||||
def test_support_at_each_end_is_not_flagged():
|
||||
primitives, problems = S.sketch(
|
||||
_values(length_ft='24', supports_ft='0, 24'), 400, 300)
|
||||
assert not any('outside the panel length' in p for p in problems)
|
||||
markers = [p for p in primitives if p['kind'] == 'support']
|
||||
assert all(m['flagged'] is False for m in markers)
|
||||
|
||||
|
||||
def test_out_of_range_support_is_flagged_and_still_drawn():
|
||||
primitives, problems = S.sketch(
|
||||
_values(length_ft='24', supports_ft='2, 40'), 400, 300)
|
||||
assert any('outside the panel length' in p for p in problems)
|
||||
markers = [p for p in primitives if p['kind'] == 'support']
|
||||
assert len(markers) == 2
|
||||
flagged = [m for m in markers if m['flagged']]
|
||||
assert len(flagged) == 1
|
||||
# clamped to the panel end, not dropped
|
||||
rect = _rect(primitives)
|
||||
assert abs(flagged[0]['x'] - (rect['x'] + rect['w'])) < 1e-6
|
||||
|
||||
|
||||
def test_negative_support_is_flagged_and_clamped_to_the_start():
|
||||
primitives, problems = S.sketch(
|
||||
_values(length_ft='24', supports_ft='-5, 12'), 400, 300)
|
||||
assert any('outside the panel length' in p for p in problems)
|
||||
rect = _rect(primitives)
|
||||
markers = [p for p in primitives if p['kind'] == 'support']
|
||||
flagged = [m for m in markers if m['flagged']]
|
||||
assert len(flagged) == 1
|
||||
assert abs(flagged[0]['x'] - rect['x']) < 1e-6
|
||||
|
||||
|
||||
def test_no_supports_entered_is_reported_but_still_draws_the_panel():
|
||||
primitives, problems = S.sketch(_values(supports_ft=''), 400, 300)
|
||||
assert primitives != []
|
||||
assert any('No support positions' in p for p in problems)
|
||||
assert not any(p['kind'] == 'support' for p in primitives)
|
||||
|
||||
|
||||
def test_pre_parsed_supports_list_is_honoured_over_raw_text():
|
||||
values = _values(supports_ft='999') # would be out of range if read
|
||||
values['supports'] = [4.0, 20.0]
|
||||
primitives, problems = S.sketch(values, 400, 300)
|
||||
assert not any('999' in p for p in problems)
|
||||
markers = [p for p in primitives if p['kind'] == 'support']
|
||||
assert len(markers) == 2
|
||||
assert all(m['flagged'] is False for m in markers)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- openings
|
||||
|
||||
def test_blank_openings_produces_no_note():
|
||||
primitives, problems = S.sketch(_values(openings=''), 400, 300)
|
||||
assert not any(p['role'] == 'openings-note' for p in primitives)
|
||||
assert not any('opening' in p.lower() for p in problems)
|
||||
|
||||
|
||||
def test_unparsed_openings_prose_produces_a_note_not_a_rectangle():
|
||||
primitives, problems = S.sketch(
|
||||
_values(openings='one door near the south end'), 400, 300)
|
||||
notes = [p for p in primitives if p['role'] == 'openings-note']
|
||||
assert len(notes) == 1
|
||||
assert 'not drawn' in notes[0]['text']
|
||||
# never a rectangle standing in for the opening
|
||||
rects = [p for p in primitives if p['kind'] == 'rect']
|
||||
assert len(rects) == 1 # only the panel itself
|
||||
assert any('not drawn' in p for p in problems)
|
||||
|
||||
|
||||
def test_openings_with_a_leading_count_is_still_only_a_note():
|
||||
primitives, _problems = S.sketch(
|
||||
_values(openings='2 openings, door and window'), 400, 300)
|
||||
notes = [p for p in primitives if p['role'] == 'openings-note']
|
||||
assert len(notes) == 1
|
||||
assert '2 opening' in notes[0]['text']
|
||||
rects = [p for p in primitives if p['kind'] == 'rect']
|
||||
assert len(rects) == 1
|
||||
|
||||
|
||||
# ------------------------------------------------------------- dimensions
|
||||
|
||||
def test_length_and_width_dimension_text_carries_value_and_units():
|
||||
primitives, _problems = S.sketch(
|
||||
_values(length_ft='24', width_ft='8'), 400, 300)
|
||||
dim_texts = [p['text'] for p in primitives
|
||||
if p['kind'] == 'text' and p['role'] == 'dimension']
|
||||
assert any('24.00 ft' in t for t in dim_texts)
|
||||
assert any('8.00 ft' in t for t in dim_texts)
|
||||
|
||||
|
||||
def test_title_carries_mark_when_given():
|
||||
primitives, _problems = S.sketch(_values(mark='PNL101'), 400, 300)
|
||||
titles = [p for p in primitives if p['role'] == 'title']
|
||||
assert len(titles) == 1
|
||||
assert 'PNL101' in titles[0]['text']
|
||||
|
||||
|
||||
def test_no_title_when_mark_and_design_number_are_blank():
|
||||
primitives, _problems = S.sketch(
|
||||
_values(mark='', design_number=''), 400, 300)
|
||||
assert not any(p['role'] == 'title' for p in primitives)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ no mutation
|
||||
|
||||
def test_sketch_never_mutates_the_values_dict():
|
||||
values = _values(supports_ft='2, 22')
|
||||
before = copy.deepcopy(values)
|
||||
S.sketch(values, 400, 300)
|
||||
assert values == before
|
||||
|
||||
|
||||
def test_sketch_never_mutates_a_values_dict_that_fails_the_gate():
|
||||
values = _values(length_ft='')
|
||||
before = copy.deepcopy(values)
|
||||
S.sketch(values, 400, 300)
|
||||
assert values == before
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# elevation() / plan() / section() / weight_diagram() -- the four new entry
|
||||
# points. `sketch()` above is untouched and re-tested above; these pin the
|
||||
# additive contract: scale/aspect fidelity, containment, the missing-input
|
||||
# degrade, out-of-range flag-but-draw, and non-mutation, for each function.
|
||||
# ===========================================================================
|
||||
|
||||
def _rows(primitives, kind, role=None):
|
||||
return [p for p in primitives
|
||||
if p['kind'] == kind and (role is None or p.get('role') == role)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------- elevation
|
||||
|
||||
def test_elevation_missing_length_or_width_returns_no_primitives():
|
||||
primitives, problems = S.elevation(_values(length_ft=''), 400, 300)
|
||||
assert primitives == []
|
||||
assert any('Length' in p for p in problems)
|
||||
|
||||
primitives, problems = S.elevation(_values(width_ft=''), 400, 300)
|
||||
assert primitives == []
|
||||
assert any('Width' in p for p in problems)
|
||||
|
||||
|
||||
def test_elevation_front_panel_is_scale_true():
|
||||
primitives, problems = S.elevation(
|
||||
_values(length_ft='10', width_ft='30', supports_ft='2, 8'), 500, 400)
|
||||
assert not any('Length' in p or 'Width' in p for p in problems)
|
||||
rects = _rows(primitives, 'rect', 'panel')
|
||||
assert len(rects) == 1
|
||||
rect = rects[0]
|
||||
assert abs((rect['w'] / rect['h']) - (10.0 / 30.0)) < 1e-6
|
||||
|
||||
|
||||
def test_elevation_side_view_matches_front_width_scale():
|
||||
primitives, _problems = S.elevation(
|
||||
_values(length_ft='24', width_ft='8', thickness_in='12'), 640, 480)
|
||||
front = _rows(primitives, 'rect', 'panel')[0]
|
||||
side = _rows(primitives, 'rect', 'panel-side')
|
||||
assert len(side) == 1
|
||||
side = side[0]
|
||||
# same px/ft scale on the shared width_ft axis -> equal drawn heights
|
||||
assert abs(side['h'] - front['h']) < 1e-6
|
||||
# thickness (1 ft) drawn to the same scale as the front's width axis
|
||||
scale = front['h'] / 8.0
|
||||
assert abs(side['w'] - 1.0 * scale) < 1e-6
|
||||
|
||||
|
||||
def test_elevation_missing_thickness_degrades_side_view_without_blocking_front():
|
||||
primitives, problems = S.elevation(_values(thickness_in=''), 400, 300)
|
||||
assert _rows(primitives, 'rect', 'panel')
|
||||
assert not _rows(primitives, 'rect', 'panel-side')
|
||||
assert not _rows(primitives, 'text', 'section-property')
|
||||
assert not _rows(primitives, 'rect', 'panel-crosssection')
|
||||
assert any('Thickness' in p and 'waiting on it' in p for p in problems)
|
||||
|
||||
|
||||
def test_elevation_openings_rows_are_drawn_to_scale_and_placed_correctly():
|
||||
values = _values(length_ft='40', width_ft='10',
|
||||
openings_rows=[{'type': 'Opening', 'pos_along': 10,
|
||||
'pos_along_from': 'start', 'length': 4,
|
||||
'pos_vert': 2, 'height': 3,
|
||||
'label': 'DOOR'}])
|
||||
primitives, problems = S.elevation(values, 640, 480)
|
||||
assert not any('DOOR' in p and 'outside' in p for p in problems)
|
||||
openings = _rows(primitives, 'rect', 'opening')
|
||||
assert len(openings) == 1
|
||||
op = openings[0]
|
||||
front = _rows(primitives, 'rect', 'panel')[0]
|
||||
expect_x = front['x'] + (10.0 / 40.0) * front['w']
|
||||
expect_w = (4.0 / 40.0) * front['w']
|
||||
assert abs(op['x'] - expect_x) < 1e-6
|
||||
assert abs(op['w'] - expect_w) < 1e-6
|
||||
assert op['flagged'] is False
|
||||
labels = _rows(primitives, 'text', 'opening-label')
|
||||
assert any('DOOR' in l['text'] for l in labels)
|
||||
|
||||
|
||||
def test_elevation_opening_position_from_end_is_resolved_correctly():
|
||||
# pos_along measured from the far end -> x0 = length - pos_along - length
|
||||
values = _values(length_ft='24', width_ft='8',
|
||||
openings_rows=[{'type': 'Opening', 'pos_along': 2,
|
||||
'pos_along_from': 'end', 'length': 4,
|
||||
'pos_vert': 1, 'height': 3,
|
||||
'label': 'WIN'}])
|
||||
primitives, problems = S.elevation(values, 640, 480)
|
||||
assert not any('outside' in p for p in problems)
|
||||
op = _rows(primitives, 'rect', 'opening')[0]
|
||||
front = _rows(primitives, 'rect', 'panel')[0]
|
||||
# x0 = 24 - 2 - 4 = 18
|
||||
expect_x = front['x'] + (18.0 / 24.0) * front['w']
|
||||
assert abs(op['x'] - expect_x) < 1e-6
|
||||
|
||||
|
||||
def test_elevation_out_of_range_opening_is_flagged_and_still_drawn():
|
||||
values = _values(length_ft='24', width_ft='8',
|
||||
openings_rows=[{'type': 'Opening', 'pos_along': 20,
|
||||
'pos_along_from': 'start', 'length': 10,
|
||||
'pos_vert': 0, 'height': 4,
|
||||
'label': ''}])
|
||||
primitives, problems = S.elevation(values, 400, 300)
|
||||
assert any('outside the panel' in p for p in problems)
|
||||
openings = _rows(primitives, 'rect', 'opening')
|
||||
assert len(openings) == 1
|
||||
assert openings[0]['flagged'] is True
|
||||
|
||||
|
||||
def test_elevation_opening_entirely_outside_is_dropped_not_moved():
|
||||
values = _values(length_ft='24', width_ft='8',
|
||||
openings_rows=[{'type': 'Opening', 'pos_along': 100,
|
||||
'pos_along_from': 'start', 'length': 2,
|
||||
'pos_vert': 100, 'height': 2,
|
||||
'label': ''}])
|
||||
primitives, problems = S.elevation(values, 400, 300)
|
||||
assert any('entirely outside' in p for p in problems)
|
||||
assert not _rows(primitives, 'rect', 'opening')
|
||||
|
||||
|
||||
def test_elevation_opening_missing_along_position_is_never_drawn():
|
||||
values = _values(length_ft='24', width_ft='8',
|
||||
openings_rows=[{'type': 'Opening', 'length': 4,
|
||||
'pos_vert': 0, 'height': 4,
|
||||
'label': 'X'}])
|
||||
primitives, problems = S.elevation(values, 400, 300)
|
||||
assert not _rows(primitives, 'rect', 'opening')
|
||||
assert any('along-length' in p for p in problems)
|
||||
|
||||
|
||||
def test_elevation_opening_missing_vertical_position_is_reported_not_guessed():
|
||||
values = _values(length_ft='24', width_ft='8',
|
||||
openings_rows=[{'type': 'Opening', 'pos_along': 2,
|
||||
'pos_along_from': 'start', 'length': 4,
|
||||
'label': 'X'}])
|
||||
primitives, problems = S.elevation(values, 400, 300)
|
||||
assert not _rows(primitives, 'rect', 'opening')
|
||||
assert any('no vertical position' in p for p in problems)
|
||||
|
||||
|
||||
def test_elevation_reveal_is_drawn_with_a_distinct_role():
|
||||
values = _values(length_ft='24', width_ft='8',
|
||||
openings_rows=[{'type': 'Reveal', 'pos_along': 2,
|
||||
'pos_along_from': 'start', 'length': 4,
|
||||
'pos_vert': 0, 'height': 8,
|
||||
'label': ''}])
|
||||
primitives, _problems = S.elevation(values, 400, 300)
|
||||
assert _rows(primitives, 'rect', 'reveal')
|
||||
assert not _rows(primitives, 'rect', 'opening')
|
||||
|
||||
|
||||
def test_elevation_falls_back_to_openings_prose_note_when_no_rows():
|
||||
values = _values(openings='one door near the south end')
|
||||
primitives, problems = S.elevation(values, 400, 300)
|
||||
notes = _rows(primitives, 'text', 'openings-note')
|
||||
assert len(notes) == 1
|
||||
assert 'not drawn' in notes[0]['text']
|
||||
assert any('not drawn' in p for p in problems)
|
||||
|
||||
|
||||
def test_elevation_stays_inside_box_various_sizes():
|
||||
values = _values(
|
||||
openings_rows=[{'type': 'Opening', 'pos_along': 2, 'pos_along_from': 'start',
|
||||
'length': 3, 'pos_vert': 1, 'height': 3, 'label': 'X'}],
|
||||
supports_ft='2, 999')
|
||||
for w, h in ((640, 480), (60, 60), (20, 15)):
|
||||
primitives, _problems = S.elevation(values, w, h)
|
||||
_within_box(primitives, w, h)
|
||||
|
||||
|
||||
def test_elevation_never_mutates_values():
|
||||
values = _values(openings_rows=[{'type': 'Opening', 'pos_along': 2, 'pos_along_from': 'start',
|
||||
'length': 3, 'pos_vert': 1, 'height': 3,
|
||||
'label': 'X'}])
|
||||
before = copy.deepcopy(values)
|
||||
S.elevation(values, 400, 300)
|
||||
assert values == before
|
||||
|
||||
|
||||
def test_sketch_alias_untouched_by_elevation_addition():
|
||||
# sketch() keeps its original contract -- no side view, no scale-true
|
||||
# openings, prose-only openings note.
|
||||
values = _values(openings='2 openings, door and window')
|
||||
primitives, _problems = S.sketch(values, 400, 300)
|
||||
assert not _rows(primitives, 'rect', 'panel-side')
|
||||
assert _rows(primitives, 'text', 'openings-note')
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- plan
|
||||
|
||||
def test_plan_missing_length_or_width_returns_no_primitives():
|
||||
primitives, problems = S.plan(_values(length_ft='', width_ft=''),
|
||||
400, 300)
|
||||
assert primitives == []
|
||||
assert any('Length' in p for p in problems)
|
||||
assert any('Width' in p for p in problems)
|
||||
|
||||
|
||||
def test_plan_outline_is_scale_true():
|
||||
primitives, _problems = S.plan(
|
||||
_values(length_ft='40', width_ft='8'), 300, 500)
|
||||
rect = _rows(primitives, 'rect', 'panel-plan')[0]
|
||||
assert abs((rect['w'] / rect['h']) - (40.0 / 8.0)) < 1e-6
|
||||
|
||||
|
||||
def test_plan_centroid_at_geometric_center_with_no_openings():
|
||||
primitives, _problems = S.plan(
|
||||
_values(length_ft='20', width_ft='10'), 400, 300)
|
||||
rect = _rows(primitives, 'rect', 'panel-plan')[0]
|
||||
cg = _rows(primitives, 'marker', 'centroid')[0]
|
||||
assert abs(cg['x'] - (rect['x'] + rect['w'] / 2.0)) < 1e-6
|
||||
assert abs(cg['y'] - (rect['y'] + rect['h'] / 2.0)) < 1e-6
|
||||
|
||||
|
||||
def test_plan_centroid_shifts_away_from_a_large_opening():
|
||||
values = _values(length_ft='20', width_ft='10',
|
||||
openings_rows=[{'type': 'Opening', 'pos_along': 0,
|
||||
'pos_along_from': 'start', 'length': 8,
|
||||
'pos_vert': 0, 'height': 10,
|
||||
'label': ''}])
|
||||
primitives, _problems = S.plan(values, 400, 300)
|
||||
rect = _rows(primitives, 'rect', 'panel-plan')[0]
|
||||
cg = _rows(primitives, 'marker', 'centroid')[0]
|
||||
# material removed from the left end -> centroid moves right of center
|
||||
assert cg['x'] > rect['x'] + rect['w'] / 2.0
|
||||
|
||||
|
||||
def test_plan_weight_missing_is_reported_and_shows_not_entered():
|
||||
primitives, problems = S.plan(_values(weight_lb=''), 400, 300)
|
||||
texts = [p['text'] for p in _rows(primitives, 'text', 'annotation')]
|
||||
assert any('not entered' in t for t in texts)
|
||||
assert any('Weight' in p for p in problems)
|
||||
|
||||
|
||||
def test_plan_weight_present_is_printed():
|
||||
primitives, _problems = S.plan(_values(weight_lb='12000'), 400, 300)
|
||||
texts = [p['text'] for p in _rows(primitives, 'text', 'annotation')]
|
||||
assert any('12.0 k' in t for t in texts)
|
||||
|
||||
|
||||
def test_plan_centroid_block_has_all_three_axes():
|
||||
primitives, _problems = S.plan(_values(weight_lb='12000'), 400, 300)
|
||||
texts = [p['text'] for p in _rows(primitives, 'text', 'annotation')]
|
||||
assert any(t.startswith('X:') for t in texts)
|
||||
assert any(t.startswith('Y:') for t in texts)
|
||||
assert any(t.startswith('Z:') for t in texts)
|
||||
|
||||
|
||||
def test_plan_legend_always_present_even_with_no_lifter_data():
|
||||
primitives, _problems = S.plan(_values(), 400, 300)
|
||||
swatches = _rows(primitives, 'swatch', 'legend-fill')
|
||||
assert len(swatches) == len(S.STAGE_FILLS)
|
||||
fills = set(s['fill'] for s in swatches)
|
||||
assert fills == set(S.STAGE_FILLS)
|
||||
glyphs = [p['text'] for p in _rows(primitives, 'text', 'legend-glyph')]
|
||||
assert any('Face Lifter' in g for g in glyphs)
|
||||
assert any('Dunnage' in g for g in glyphs)
|
||||
|
||||
|
||||
def test_plan_lifter_text_produces_a_note_never_a_marker_with_a_guessed_position():
|
||||
values = _values(face_lifters='2 face lifters', edge_lifters='1 edge')
|
||||
primitives, problems = S.plan(values, 400, 300)
|
||||
notes = _rows(primitives, 'text', 'lifters-note')
|
||||
assert len(notes) == 1
|
||||
assert 'not entered' in notes[0]['text'] or 'not drawn' in notes[0]['text']
|
||||
# no marker kind was invented at a fake position
|
||||
markers = [p for p in primitives if p['kind'] == 'marker'
|
||||
and p['role'] != 'centroid']
|
||||
assert markers == []
|
||||
assert any('Lifter positions are not entered' in p for p in problems)
|
||||
|
||||
|
||||
def test_plan_no_lifter_text_produces_no_note():
|
||||
primitives, _problems = S.plan(
|
||||
_values(face_lifters='', edge_lifters=''), 400, 300)
|
||||
assert not _rows(primitives, 'text', 'lifters-note')
|
||||
|
||||
|
||||
def test_plan_stages_list_is_printed_as_text_when_present():
|
||||
values = _values(stages=['Stripping', 'Erection'])
|
||||
primitives, _problems = S.plan(values, 400, 300)
|
||||
notes = _rows(primitives, 'text', 'note')
|
||||
assert any('Stripping' in n['text'] and 'Erection' in n['text']
|
||||
for n in notes)
|
||||
|
||||
|
||||
def test_plan_stages_absent_degrades_quietly():
|
||||
primitives, _problems = S.plan(_values(), 400, 300)
|
||||
notes = _rows(primitives, 'text', 'note')
|
||||
assert not any('Stages:' in n['text'] for n in notes)
|
||||
|
||||
|
||||
def test_plan_stays_inside_box():
|
||||
values = _values(
|
||||
openings_rows=[{'type': 'Opening', 'pos_along': 2, 'pos_along_from': 'start',
|
||||
'length': 3, 'pos_vert': 1, 'height': 3, 'label': 'X'}],
|
||||
face_lifters='2', stages=['Stripping'])
|
||||
for w, h in ((640, 480), (60, 60), (20, 15)):
|
||||
primitives, _problems = S.plan(values, w, h)
|
||||
_within_box(primitives, w, h)
|
||||
|
||||
|
||||
def test_plan_never_mutates_values():
|
||||
values = _values(openings_rows=[{'type': 'Opening', 'pos_along': 2, 'pos_along_from': 'start',
|
||||
'length': 3, 'pos_vert': 1, 'height': 3,
|
||||
'label': 'X'}],
|
||||
face_lifters='2', stages=['Stripping'])
|
||||
before = copy.deepcopy(values)
|
||||
S.plan(values, 400, 300)
|
||||
assert values == before
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- section
|
||||
|
||||
def _section_values(**over):
|
||||
base = {
|
||||
'length_ft': '24', 'width_ft': '8', 'thickness_in': '8',
|
||||
'strand_rows': [{'count': 4, 'depth_in': 2, 'pull_pct': 100},
|
||||
{'count': 2, 'depth_in': 6, 'pull_pct': None}],
|
||||
'mark': 'PNL101', 'design_number': 'PNL1',
|
||||
}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
def test_section_missing_width_or_thickness_returns_no_primitives():
|
||||
primitives, problems = S.section(
|
||||
_section_values(width_ft='', thickness_in=''), 400, 300)
|
||||
assert primitives == []
|
||||
assert any('Width' in p for p in problems)
|
||||
assert any('Thickness' in p for p in problems)
|
||||
|
||||
|
||||
def test_section_outline_is_scale_true():
|
||||
primitives, _problems = S.section(
|
||||
_section_values(width_ft='8', thickness_in='12'), 500, 400)
|
||||
rect = _rows(primitives, 'rect', 'panel-section')[0]
|
||||
assert abs((rect['w'] / rect['h']) - (8.0 / 1.0)) < 1e-6
|
||||
|
||||
|
||||
def test_section_strand_depth_is_true_to_scale_from_the_bottom():
|
||||
primitives, problems = S.section(
|
||||
_section_values(strand_rows=[{'count': 4, 'depth_in': 4}]),
|
||||
500, 400)
|
||||
assert not any('outside' in p for p in problems)
|
||||
rect = _rows(primitives, 'rect', 'panel-section')[0]
|
||||
dots = _rows(primitives, 'dot')
|
||||
assert len(dots) == 4
|
||||
# depth_in=4 of thickness_in=8 -> half way up from the bottom
|
||||
expect_y = rect['y'] + rect['h'] - 0.5 * rect['h']
|
||||
for d in dots:
|
||||
assert abs(d['y'] - expect_y) < 1e-6
|
||||
|
||||
|
||||
def test_section_strands_are_centred_not_spread_across_the_width():
|
||||
primitives, _problems = S.section(
|
||||
_section_values(strand_rows=[{'count': 4, 'depth_in': 2}]),
|
||||
500, 400)
|
||||
rect = _rows(primitives, 'rect', 'panel-section')[0]
|
||||
dots = _rows(primitives, 'dot')
|
||||
xs = [d['x'] for d in dots]
|
||||
center = rect['x'] + rect['w'] / 2.0
|
||||
assert all(abs(x - center) < rect['w'] * 0.2 for x in xs)
|
||||
|
||||
|
||||
def test_section_out_of_range_strand_is_flagged_and_clamped():
|
||||
primitives, problems = S.section(
|
||||
_section_values(strand_rows=[{'count': 2, 'depth_in': 99}]),
|
||||
400, 300)
|
||||
assert any('outside the panel thickness' in p for p in problems)
|
||||
dots = _rows(primitives, 'dot')
|
||||
assert all(d['flagged'] is True for d in dots)
|
||||
rect = _rows(primitives, 'rect', 'panel-section')[0]
|
||||
for d in dots:
|
||||
assert abs(d['y'] - rect['y']) < 1e-6 # clamped to top (max depth)
|
||||
|
||||
|
||||
def test_section_no_strand_rows_still_draws_outline():
|
||||
primitives, problems = S.section(
|
||||
_section_values(strand_rows=[]), 400, 300)
|
||||
assert _rows(primitives, 'rect', 'panel-section')
|
||||
assert any('No strand data' in p for p in problems)
|
||||
|
||||
|
||||
def test_section_missing_length_skips_full_length_view_only():
|
||||
primitives, problems = S.section(
|
||||
_section_values(length_ft=''), 400, 300)
|
||||
assert _rows(primitives, 'rect', 'panel-section')
|
||||
assert not _rows(primitives, 'rect', 'panel-elevation-thin')
|
||||
assert any('full-length' in p for p in problems)
|
||||
|
||||
|
||||
def test_section_full_length_view_reports_debond_not_entered():
|
||||
primitives, problems = S.section(_section_values(), 640, 480)
|
||||
assert _rows(primitives, 'rect', 'panel-elevation-thin')
|
||||
assert any('Debond' in p for p in problems)
|
||||
lines = _rows(primitives, 'line', 'strand-line')
|
||||
assert len(lines) == 2
|
||||
|
||||
|
||||
def test_section_horizontal_spacing_not_entered_is_reported():
|
||||
_primitives, problems = S.section(_section_values(), 400, 300)
|
||||
assert any('horizontal spacing was not entered' in p for p in problems)
|
||||
|
||||
|
||||
def test_section_stays_inside_box():
|
||||
for w, h in ((640, 480), (60, 60), (20, 15)):
|
||||
primitives, _problems = S.section(_section_values(), w, h)
|
||||
_within_box(primitives, w, h)
|
||||
|
||||
|
||||
def test_section_never_mutates_values():
|
||||
values = _section_values()
|
||||
before = copy.deepcopy(values)
|
||||
S.section(values, 400, 300)
|
||||
assert values == before
|
||||
|
||||
|
||||
# ------------------------------------------------------------ weight_diagram
|
||||
|
||||
def test_weight_diagram_missing_length_returns_no_primitives():
|
||||
primitives, problems = S.weight_diagram(_values(length_ft=''), 400, 300)
|
||||
assert primitives == []
|
||||
assert any('Length' in p for p in problems)
|
||||
|
||||
|
||||
def test_weight_diagram_beam_spans_the_scaled_length():
|
||||
primitives, _problems = S.weight_diagram(_values(length_ft='24'), 640, 480)
|
||||
beams = _rows(primitives, 'line', 'beam')
|
||||
assert len(beams) == 1
|
||||
|
||||
|
||||
def test_weight_diagram_supports_at_true_proportional_position():
|
||||
primitives, problems = S.weight_diagram(
|
||||
_values(length_ft='40', supports_ft='10, 30'), 500, 300)
|
||||
assert not any('outside' in p for p in problems)
|
||||
beam = _rows(primitives, 'line', 'beam')[0]
|
||||
markers = _rows(primitives, 'support')
|
||||
assert len(markers) == 2
|
||||
xs = sorted(m['x'] for m in markers)
|
||||
expect_1 = beam['x1'] + (10.0 / 40.0) * (beam['x2'] - beam['x1'])
|
||||
expect_2 = beam['x1'] + (30.0 / 40.0) * (beam['x2'] - beam['x1'])
|
||||
assert abs(xs[0] - expect_1) < 1e-6
|
||||
assert abs(xs[1] - expect_2) < 1e-6
|
||||
|
||||
|
||||
def test_weight_diagram_out_of_range_support_is_flagged_and_still_drawn():
|
||||
primitives, problems = S.weight_diagram(
|
||||
_values(length_ft='24', supports_ft='2, 40'), 400, 300)
|
||||
assert any('outside the panel length' in p for p in problems)
|
||||
markers = _rows(primitives, 'support')
|
||||
flagged = [m for m in markers if m['flagged']]
|
||||
assert len(flagged) == 1
|
||||
|
||||
|
||||
def test_weight_diagram_cg_defaults_to_mid_length_with_no_openings():
|
||||
primitives, _problems = S.weight_diagram(
|
||||
_values(length_ft='40', width_ft='10'), 500, 300)
|
||||
beam = _rows(primitives, 'line', 'beam')[0]
|
||||
cg = _rows(primitives, 'marker', 'cg')[0]
|
||||
expect_x = beam['x1'] + 0.5 * (beam['x2'] - beam['x1'])
|
||||
assert abs(cg['x'] - expect_x) < 1e-6
|
||||
|
||||
|
||||
def test_weight_diagram_cg_shifts_with_a_large_opening():
|
||||
values = _values(length_ft='40', width_ft='10',
|
||||
openings_rows=[{'type': 'Opening', 'pos_along': 0,
|
||||
'pos_along_from': 'start', 'length': 16,
|
||||
'pos_vert': 0, 'height': 10,
|
||||
'label': ''}])
|
||||
primitives, _problems = S.weight_diagram(values, 500, 300)
|
||||
beam = _rows(primitives, 'line', 'beam')[0]
|
||||
cg = _rows(primitives, 'marker', 'cg')[0]
|
||||
mid = beam['x1'] + 0.5 * (beam['x2'] - beam['x1'])
|
||||
assert cg['x'] > mid
|
||||
|
||||
|
||||
def test_weight_diagram_weight_missing_is_reported():
|
||||
primitives, problems = S.weight_diagram(_values(weight_lb=''), 400, 300)
|
||||
texts = [p['text'] for p in _rows(primitives, 'text', 'annotation')]
|
||||
assert any('not entered' in t for t in texts)
|
||||
assert any('Weight' in p for p in problems)
|
||||
|
||||
|
||||
def test_weight_diagram_moment_arms_are_dimensioned_per_support():
|
||||
primitives, _problems = S.weight_diagram(
|
||||
_values(length_ft='40', supports_ft='10, 30'), 500, 300)
|
||||
arms = _rows(primitives, 'line', 'moment-arm')
|
||||
labels = _rows(primitives, 'text', 'moment-arm-label')
|
||||
assert len(arms) == 2
|
||||
assert len(labels) == 2
|
||||
|
||||
|
||||
def test_weight_diagram_stays_inside_box():
|
||||
values = _values(
|
||||
openings_rows=[{'type': 'Opening', 'pos_along': 2, 'pos_along_from': 'start',
|
||||
'length': 3, 'pos_vert': 1, 'height': 3, 'label': 'X'}],
|
||||
supports_ft='2, 999')
|
||||
for w, h in ((640, 480), (60, 60), (20, 15)):
|
||||
primitives, _problems = S.weight_diagram(values, w, h)
|
||||
_within_box(primitives, w, h)
|
||||
|
||||
|
||||
def test_weight_diagram_never_mutates_values():
|
||||
values = _values(openings_rows=[{'type': 'Opening', 'pos_along': 2, 'pos_along_from': 'start',
|
||||
'length': 3, 'pos_vert': 1, 'height': 3,
|
||||
'label': 'X'}])
|
||||
before = copy.deepcopy(values)
|
||||
S.weight_diagram(values, 400, 300)
|
||||
assert values == before
|
||||
@@ -0,0 +1,107 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for the double itself -- because a kind double is a blindfold.
|
||||
|
||||
`pyrevit_yaml_fake` stands in for pyRevit's YAML reader, and every guard in
|
||||
this repository that reads YAML is tested through it. Twice now the double
|
||||
has been KINDER than the thing it doubles and the suite was green while the
|
||||
button was broken:
|
||||
|
||||
* it TYPED its scalars while YamlDotNet returns every one as a string --
|
||||
the master checklist reached the button with the word 'null' in 37 of 39
|
||||
reason slots, on the owner's screen;
|
||||
* it took the LAST of two duplicate keys while YamlDotNet raises
|
||||
`Duplicate key` and returns nothing -- one nested duplicate discarded a
|
||||
whole 255KB plant layer inside Revit on 2026-09-01, and nothing on the
|
||||
host could reproduce it.
|
||||
|
||||
Both properties are the double's PRODUCT, so they are tested here rather than
|
||||
assumed by the files that use it.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
pytest.importorskip('yaml')
|
||||
from pyrevit_yaml_fake import ( # noqa: E402
|
||||
DuplicateKeyError, raise_on_duplicate_keys, stringify_like_yamldotnet,
|
||||
strings_only_yaml_reader)
|
||||
|
||||
|
||||
def _write(tmp_path, text):
|
||||
path = tmp_path / 'layer.yaml'
|
||||
path.write_text(text, encoding='utf-8')
|
||||
return str(path)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- duplicate keys
|
||||
|
||||
def test_a_duplicate_key_at_the_top_level_raises(tmp_path):
|
||||
path = _write(tmp_path, u'a: 1\nb: 2\na: 3\n')
|
||||
with pytest.raises(DuplicateKeyError) as caught:
|
||||
strings_only_yaml_reader()(path)
|
||||
assert 'Duplicate key' in str(caught.value)
|
||||
|
||||
|
||||
def test_a_duplicate_key_NESTED_raises_too(tmp_path):
|
||||
"""The shape the incident actually had. A top-level-only check would have
|
||||
read that file happily and thrown the layer away in production."""
|
||||
path = _write(tmp_path, u'checks:\n'
|
||||
u' rules:\n'
|
||||
u' cap_label_mismatch_is_view_specific: true\n'
|
||||
u' other: 1\n'
|
||||
u' cap_label_mismatch_is_view_specific: false\n')
|
||||
with pytest.raises(DuplicateKeyError) as caught:
|
||||
strings_only_yaml_reader()(path)
|
||||
said = str(caught.value)
|
||||
assert 'cap_label_mismatch_is_view_specific' in said
|
||||
assert 'line 5' in said # the SECOND statement: the one to delete
|
||||
|
||||
|
||||
def test_a_duplicate_inside_a_list_item_raises(tmp_path):
|
||||
path = _write(tmp_path, u'items:\n - name: a\n size: 1\n name: b\n')
|
||||
with pytest.raises(DuplicateKeyError):
|
||||
strings_only_yaml_reader()(path)
|
||||
|
||||
|
||||
def test_the_same_key_in_two_different_mappings_is_fine(tmp_path):
|
||||
"""Not a duplicate. A checker that called this one would be unusable --
|
||||
every knowledge file names `note:` under a dozen different parents."""
|
||||
path = _write(tmp_path, u'a:\n note: x\nb:\n note: y\n')
|
||||
assert strings_only_yaml_reader()(path) == {'a': {'note': 'x'},
|
||||
'b': {'note': 'y'}}
|
||||
|
||||
|
||||
def test_a_clean_file_still_reads_and_still_comes_back_as_strings(tmp_path):
|
||||
path = _write(tmp_path, u'n: 1.5\nflag: true\nempty: null\n')
|
||||
assert strings_only_yaml_reader()(path) == {'n': '1.5', 'flag': 'true',
|
||||
'empty': 'null'}
|
||||
|
||||
|
||||
def test_a_document_pyyaml_cannot_parse_is_not_judged_for_duplicates():
|
||||
"""Stated out loud rather than left to be discovered: this detector has
|
||||
exactly one question, and it answers `no duplicate found` for a document
|
||||
it could not read at all. `tools/knowledgelint.py` is the check that does
|
||||
refuse those, and it is the one the landing agents run."""
|
||||
raise_on_duplicate_keys(u'a:\n - [1, 2\n') # does not raise
|
||||
|
||||
|
||||
def test_the_message_leads_with_the_words_pyrevit_uses():
|
||||
"""The string in the message is what a person sees in Self Check and in
|
||||
`config_problems()`, and what they will search for."""
|
||||
with pytest.raises(DuplicateKeyError) as caught:
|
||||
raise_on_duplicate_keys(u'a: 1\na: 2\n', path='C:/plant/k.yaml')
|
||||
said = str(caught.value)
|
||||
assert said.startswith('Duplicate key')
|
||||
assert 'C:/plant/k.yaml' in said
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the strings
|
||||
|
||||
def test_every_scalar_comes_back_as_text():
|
||||
"""The other way this double was once kinder than production."""
|
||||
assert stringify_like_yamldotnet(
|
||||
{'a': 1.5, 'b': True, 'c': None, 'd': [1, {'e': False}]}) == \
|
||||
{'a': '1.5', 'b': 'true', 'c': 'null', 'd': ['1', {'e': 'false'}]}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,884 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The reinforcing-suggestion matcher: set cover over opaque feature records.
|
||||
|
||||
Every panel here is invented. Marks are `PNL...`, which is the project's
|
||||
placeholder convention -- no real panel mark, job or client name appears in
|
||||
this file, including in the case rebuilt from the worked example, where the
|
||||
three real marks are replaced by PNL501 (target) and PNL502 / PNL503 (donors).
|
||||
|
||||
Two kinds of test live here. Most use bare rectangles and the module's own
|
||||
reference `rect_distance`, because the point of the seam is that the selection
|
||||
logic can be exercised with no feature schema at all. A handful inject a
|
||||
hand-written `covers` that returns canned numbers, because the ranking rules
|
||||
are about the NUMBERS and it is clearer to state them directly than to derive
|
||||
them from geometry.
|
||||
"""
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
import pytest
|
||||
|
||||
from vibe import reinforcematch as rm
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- fixtures
|
||||
|
||||
def canned(table):
|
||||
"""A `covers` built from {(donor_feature, target_feature): distance}.
|
||||
|
||||
Anything not in the table is not covered. Feature records are plain
|
||||
strings; the matcher never looks at them, which is the whole point.
|
||||
"""
|
||||
def covers(donor_feature, target_feature):
|
||||
return table.get((donor_feature, target_feature))
|
||||
return covers
|
||||
|
||||
|
||||
# Rebuilt from the worked example, with invented marks. Rectangles are
|
||||
# (x0, x1, y0, y1) in absolute feet from the start of the piece -- absolute,
|
||||
# never a fraction of length, which is the thing the measurement settled.
|
||||
# The three pieces are three different lengths and the big openings still land
|
||||
# at the identical feet-from-start rectangle.
|
||||
|
||||
PNL501 = [ # target, 27.625 ft long, six features
|
||||
(24.250, 27.625, 6.250, 11.500), # 0 right-end blockout
|
||||
(6.500, 10.583, 0.354, 4.479), # 1 big opening
|
||||
(14.344, 18.427, 0.354, 4.479), # 2 big opening
|
||||
(0.000, 0.875, 1.125, 3.000), # 3 left notch
|
||||
(24.042, 25.875, 1.125, 3.000), # 4 small right notch
|
||||
(0.000, 0.667, 11.292, 11.500), # 5 left top-corner recess
|
||||
]
|
||||
|
||||
PNL502 = [ # donor, 24.917 ft: the two big openings
|
||||
(6.500, 10.583, 0.354, 4.479),
|
||||
(14.344, 18.427, 0.354, 4.479),
|
||||
(0.000, 0.875, 1.125, 3.000),
|
||||
(24.042, 24.917, 1.125, 3.000),
|
||||
(0.000, 0.667, 11.292, 11.500),
|
||||
(24.250, 24.917, 11.292, 11.500),
|
||||
]
|
||||
|
||||
PNL503 = [ # donor, 27.208 ft: the blockout end
|
||||
(24.250, 27.208, 6.917, 11.500),
|
||||
(0.000, 0.875, 1.125, 3.000),
|
||||
(24.042, 25.875, 1.125, 3.000),
|
||||
(0.000, 0.667, 11.292, 11.500),
|
||||
]
|
||||
|
||||
|
||||
# ------------------------------------------------- the reference comparison
|
||||
|
||||
def test_rect_distance_is_zero_for_an_identical_rectangle():
|
||||
r = (6.5, 10.583, 0.354, 4.479)
|
||||
assert rm.rect_distance(r, r) == 0.0
|
||||
|
||||
|
||||
def test_rect_distance_grows_with_the_corner_offsets():
|
||||
target = (24.250, 27.625, 6.250, 11.500)
|
||||
donor = (24.250, 27.208, 6.917, 11.500)
|
||||
got = rm.rect_distance(donor, target)
|
||||
assert got == pytest.approx(0.417 + 0.667, abs=0.001)
|
||||
|
||||
|
||||
def test_rect_distance_refuses_a_feature_in_a_different_band():
|
||||
"""A single-axis check calls these the same feature. They are not.
|
||||
|
||||
Both sit near the right end of the piece and share x-range, but one is a
|
||||
full-height blockout and the other a shallow top-corner recess.
|
||||
"""
|
||||
blockout = (24.250, 27.625, 6.250, 11.500)
|
||||
recess = (24.250, 24.917, 11.292, 11.500)
|
||||
assert rm.rect_distance(recess, blockout) is None
|
||||
|
||||
|
||||
def test_rect_distance_is_not_proportional():
|
||||
"""Same absolute rectangle on two panels of different length is exact.
|
||||
|
||||
Nothing here divides by a length, so there is nothing for the panels'
|
||||
different lengths to change.
|
||||
"""
|
||||
assert rm.rect_distance(PNL502[0], PNL501[1]) == 0.0
|
||||
assert rm.rect_distance(PNL502[1], PNL501[2]) == 0.0
|
||||
|
||||
|
||||
def test_rect_distance_returns_none_for_unreadable_input():
|
||||
assert rm.rect_distance(None, (0, 1, 0, 1)) is None
|
||||
assert rm.rect_distance(('a', 'b', 'c', 'd'), (0, 1, 0, 1)) is None
|
||||
|
||||
|
||||
# --------------------------------------------------- could not judge vs none
|
||||
|
||||
def test_no_target_features_is_could_not_judge():
|
||||
"""None, not an empty result. There is nothing to cover, so no judgement
|
||||
was made -- and that must not read like 'searched, found nothing'."""
|
||||
assert rm.suggest([], [('PNL502', PNL502)], rm.rect_distance) is None
|
||||
assert rm.suggest(None, [('PNL502', PNL502)], rm.rect_distance) is None
|
||||
|
||||
|
||||
def test_no_donors_reads_differently_from_no_coverage():
|
||||
nothing_offered = rm.suggest(PNL501, [], rm.rect_distance)
|
||||
assert nothing_offered.status == rm.NO_DONORS
|
||||
assert nothing_offered.proposals == ()
|
||||
assert nothing_offered.donor_count == 0
|
||||
assert nothing_offered.donors_needed is None
|
||||
assert 'nothing was judged' in nothing_offered.summary()
|
||||
|
||||
useless = [('PNL900', [(90.0, 91.0, 0.0, 1.0)])]
|
||||
judged = rm.suggest(PNL501, useless, rm.rect_distance)
|
||||
assert judged.status == rm.NO_COVERAGE
|
||||
assert judged.proposals == ()
|
||||
assert judged.donor_count == 1
|
||||
assert judged.uncovered == (0, 1, 2, 3, 4, 5)
|
||||
assert 'not one of them covers' in judged.summary()
|
||||
|
||||
assert nothing_offered.summary() != judged.summary()
|
||||
|
||||
|
||||
def test_a_covers_that_blows_up_is_not_swallowed():
|
||||
"""An exception in the comparison must not become 'covers nothing'."""
|
||||
def boom(donor_feature, target_feature):
|
||||
raise ZeroDivisionError('bad geometry')
|
||||
with pytest.raises(ZeroDivisionError):
|
||||
rm.suggest(PNL501, [('PNL502', PNL502)], boom)
|
||||
|
||||
|
||||
def test_contract_violations_are_raised_not_absorbed():
|
||||
with pytest.raises(ValueError):
|
||||
rm.suggest(['t'], [('D', ['d'])], canned({('d', 't'): -1.0}))
|
||||
with pytest.raises(ValueError):
|
||||
rm.suggest(['t'], [('D', ['d'])], canned({('d', 't'): 'close'}))
|
||||
with pytest.raises(ValueError):
|
||||
rm.suggest(['t'], [('D', ['d'])], covers=None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------- single-donor cover
|
||||
|
||||
def test_one_donor_covering_everything_exactly():
|
||||
target = ['a', 'b', 'c']
|
||||
donors = [('PNL601', ['a', 'b', 'c'])]
|
||||
table = dict(((f, f), 0.0) for f in target)
|
||||
got = rm.suggest(target, donors, canned(table))
|
||||
assert got.status == rm.COMPLETE
|
||||
assert got.donors_needed == 1
|
||||
assert got.best.donors == ('PNL601',)
|
||||
assert got.best.is_exact
|
||||
assert got.best.exact_count == 3
|
||||
assert got.best.total_distance == 0.0
|
||||
assert got.uncovered == ()
|
||||
|
||||
|
||||
def test_a_single_exact_donor_outranks_a_single_loose_one():
|
||||
target = ['a', 'b']
|
||||
donors = [('PNL_LOOSE', ['x', 'y']), ('PNL_EXACT', ['a', 'b'])]
|
||||
table = {('x', 'a'): 1.0, ('y', 'b'): 1.0,
|
||||
('a', 'a'): 0.0, ('b', 'b'): 0.0}
|
||||
got = rm.suggest(target, donors, canned(table))
|
||||
assert got.donors_needed == 1
|
||||
assert got.cover_count == 2
|
||||
assert [p.donors[0] for p in got.proposals] == ['PNL_EXACT', 'PNL_LOOSE']
|
||||
|
||||
|
||||
# ------------------------------------------------------------ two-donor cover
|
||||
|
||||
def test_two_donors_are_needed_when_neither_alone_will_do():
|
||||
target = ['a', 'b', 'c', 'd']
|
||||
donors = [('PNL701', ['a', 'b']), ('PNL702', ['c', 'd'])]
|
||||
table = {('a', 'a'): 0.0, ('b', 'b'): 0.0,
|
||||
('c', 'c'): 0.0, ('d', 'd'): 0.0}
|
||||
got = rm.suggest(target, donors, canned(table))
|
||||
assert got.status == rm.COMPLETE
|
||||
assert got.donors_needed == 2
|
||||
assert got.best.donors == ('PNL701', 'PNL702')
|
||||
|
||||
|
||||
def test_fewest_donors_wins_even_when_more_donors_would_be_more_exact():
|
||||
"""Fewest donors is the objective; exactness only breaks ties.
|
||||
|
||||
PNL802+PNL803 would be exact on both features. PNL801 alone is loose on
|
||||
both. One donor still wins -- that is the owner's stated objective and the
|
||||
matcher does not get to relitigate it.
|
||||
"""
|
||||
target = ['a', 'b']
|
||||
donors = [('PNL801', ['w']), ('PNL802', ['a']), ('PNL803', ['b'])]
|
||||
table = {('w', 'a'): 3.0, ('w', 'b'): 3.0,
|
||||
('a', 'a'): 0.0, ('b', 'b'): 0.0}
|
||||
got = rm.suggest(target, donors, canned(table))
|
||||
assert got.donors_needed == 1
|
||||
assert got.best.donors == ('PNL801',)
|
||||
|
||||
|
||||
def test_overlap_between_donors_is_not_penalised():
|
||||
"""Both donors carry feature 'a'. That is expected, not a conflict."""
|
||||
target = ['a', 'b', 'c']
|
||||
donors = [('PNL811', ['a', 'b']), ('PNL812', ['a', 'c'])]
|
||||
table = {('a', 'a'): 0.0, ('b', 'b'): 0.0, ('c', 'c'): 0.0}
|
||||
got = rm.suggest(target, donors, canned(table))
|
||||
assert got.status == rm.COMPLETE
|
||||
assert got.donors_needed == 2
|
||||
assert got.best.exact_count == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------- partial coverage
|
||||
|
||||
def test_a_feature_nothing_covers_still_returns_the_cover_of_the_rest():
|
||||
"""'Nothing found' and 'here is everything but feature 3' are different
|
||||
answers, and only the second tells the engineer what to detail by hand."""
|
||||
target = ['a', 'b', 'c', 'orphan']
|
||||
donors = [('PNL901', ['a', 'b']), ('PNL902', ['c'])]
|
||||
table = {('a', 'a'): 0.0, ('b', 'b'): 0.0, ('c', 'c'): 0.0}
|
||||
got = rm.suggest(target, donors, canned(table))
|
||||
assert got.status == rm.PARTIAL
|
||||
assert got.uncovered == (3,)
|
||||
assert got.best.donors == ('PNL901', 'PNL902')
|
||||
assert got.best.covered_count == 3
|
||||
assert got.best.uncovered == (3,)
|
||||
assert got.best.assignment[3] is None
|
||||
assert 'no donor covers at all' in got.summary()
|
||||
|
||||
|
||||
def test_partial_and_complete_summaries_do_not_read_alike():
|
||||
target = ['a', 'orphan']
|
||||
complete = rm.suggest(['a'], [('PNL911', ['a'])],
|
||||
canned({('a', 'a'): 0.0}))
|
||||
partial = rm.suggest(target, [('PNL911', ['a'])],
|
||||
canned({('a', 'a'): 0.0}))
|
||||
assert complete.status == rm.COMPLETE
|
||||
assert partial.status == rm.PARTIAL
|
||||
assert complete.summary() != partial.summary()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ ranking
|
||||
|
||||
def test_exactness_breaks_a_tie_between_equal_sized_covers():
|
||||
target = ['a', 'b']
|
||||
donors = [('PNL_NEAR', ['n1', 'n2']), ('PNL_SAME', ['a', 'b'])]
|
||||
table = {('n1', 'a'): 0.25, ('n2', 'b'): 0.25,
|
||||
('a', 'a'): 0.0, ('b', 'b'): 0.0}
|
||||
got = rm.suggest(target, donors, canned(table))
|
||||
assert [p.donors[0] for p in got.proposals] == ['PNL_SAME', 'PNL_NEAR']
|
||||
|
||||
|
||||
def test_exactness_beats_evenness_and_a_sum_would_have_tied_them():
|
||||
"""The documented consequence of ranking on the sorted distance vector.
|
||||
|
||||
Both covers total 2.0, so a sum calls them equal. The owner's ruling is
|
||||
that the exact shapes go in front, so the cover with four exact features
|
||||
wins even though its fifth is far off.
|
||||
"""
|
||||
target = ['a', 'b', 'c', 'd', 'e']
|
||||
donors = [
|
||||
('PNL_EVEN', ['v0', 'v1', 'v2', 'v3', 'v4']),
|
||||
('PNL_SHARP', ['w0', 'w1', 'w2', 'w3', 'w4']),
|
||||
]
|
||||
table = {}
|
||||
for i, f in enumerate(target):
|
||||
table[('v{0}'.format(i), f)] = 0.4
|
||||
table[('w{0}'.format(i), f)] = 0.0 if i < 4 else 2.0
|
||||
got = rm.suggest(target, donors, canned(table))
|
||||
assert got.proposals[0].total_distance == pytest.approx(2.0)
|
||||
assert got.proposals[1].total_distance == pytest.approx(2.0)
|
||||
assert got.proposals[0].donors == ('PNL_SHARP',)
|
||||
assert got.proposals[0].exact_count == 4
|
||||
assert got.proposals[1].exact_count == 0
|
||||
|
||||
|
||||
def test_the_best_donor_feature_within_a_donor_wins():
|
||||
"""A donor carrying two features that both cover one target feature
|
||||
contributes its closer one, not whichever happens to be looked at first."""
|
||||
target = ['a']
|
||||
donors = [('PNL_TWO', ['far', 'near'])]
|
||||
got = rm.suggest(target, donors,
|
||||
canned({('far', 'a'): 5.0, ('near', 'a'): 0.5}))
|
||||
assert got.best.assignment[0][2] == 0.5
|
||||
assert got.best.donor_for(0) == ('PNL_TWO', 1, 0.5)
|
||||
|
||||
|
||||
def test_max_proposals_trims_but_the_count_still_reports_the_truth():
|
||||
target = ['a']
|
||||
donors = [('PNL{0}'.format(i), ['a']) for i in range(8)]
|
||||
got = rm.suggest(target, donors, canned({('a', 'a'): 0.0}),
|
||||
max_proposals=3)
|
||||
assert len(got.proposals) == 3
|
||||
assert got.cover_count == 8
|
||||
|
||||
|
||||
# ------------------------------------------------------------- determinism
|
||||
|
||||
def test_dead_even_ties_break_by_input_order():
|
||||
"""Three indistinguishable donors. Input order decides, so the caller
|
||||
controls it -- e.g. by listing same-elevation donors first."""
|
||||
target = ['a']
|
||||
table = {('a', 'a'): 0.0}
|
||||
first = rm.suggest(target, [('PNL_A', ['a']), ('PNL_B', ['a']),
|
||||
('PNL_C', ['a'])], canned(table))
|
||||
assert [p.donors[0] for p in first.proposals] == ['PNL_A', 'PNL_B',
|
||||
'PNL_C']
|
||||
reordered = rm.suggest(target, [('PNL_C', ['a']), ('PNL_A', ['a']),
|
||||
('PNL_B', ['a'])], canned(table))
|
||||
assert [p.donors[0] for p in reordered.proposals] == ['PNL_C', 'PNL_A',
|
||||
'PNL_B']
|
||||
|
||||
|
||||
def test_the_same_inputs_give_the_same_answer_every_run():
|
||||
donors = [('PNL502', PNL502), ('PNL503', PNL503)]
|
||||
shape = None
|
||||
for _ in range(25):
|
||||
got = rm.suggest(PNL501, donors, rm.rect_distance)
|
||||
this = (got.status, got.donors_needed, got.cover_count,
|
||||
tuple([(p.donors, p.distances, p.assignment)
|
||||
for p in got.proposals]))
|
||||
if shape is None:
|
||||
shape = this
|
||||
assert this == shape
|
||||
|
||||
|
||||
def test_determinism_survives_a_donor_list_full_of_equivalents():
|
||||
target = ['a', 'b', 'c']
|
||||
table = {('a', 'a'): 0.0, ('b', 'b'): 0.0, ('c', 'c'): 0.5}
|
||||
donors = [('PNL{0}'.format(i), ['a', 'b', 'c']) for i in range(12)]
|
||||
runs = set()
|
||||
for _ in range(10):
|
||||
got = rm.suggest(target, donors, canned(table))
|
||||
runs.add(tuple([p.donors for p in got.proposals]))
|
||||
assert len(runs) == 1
|
||||
|
||||
|
||||
# --------------------------------------- the worked example, invented names
|
||||
|
||||
def test_the_worked_example_splits_into_exactly_two_donors():
|
||||
"""The shape of the owner's own example, rebuilt with synthetic marks.
|
||||
|
||||
PNL502 is the only donor with the two big central openings; PNL503 is the
|
||||
only one with the right-end blockout. Neither alone will do, and the answer
|
||||
is the pair -- which is the claim the whole feature rests on.
|
||||
"""
|
||||
donors = [('PNL502', PNL502), ('PNL503', PNL503)]
|
||||
got = rm.suggest(PNL501, donors, rm.rect_distance)
|
||||
assert got.status == rm.COMPLETE
|
||||
assert got.uncovered == ()
|
||||
assert got.donors_needed == 2
|
||||
assert got.best.donors == ('PNL502', 'PNL503')
|
||||
|
||||
|
||||
def test_the_worked_example_is_exact_everywhere_but_the_blockout():
|
||||
"""Five of six features match exactly; the blockout is close, not equal.
|
||||
|
||||
That asymmetry is the measurement, and it is what the ranking is built to
|
||||
express -- the exact features go in front, the approximate one carries a
|
||||
number rather than a verdict.
|
||||
"""
|
||||
donors = [('PNL502', PNL502), ('PNL503', PNL503)]
|
||||
best = rm.suggest(PNL501, donors, rm.rect_distance).best
|
||||
assert best.exact_count == 5
|
||||
assert best.covered_count == 6
|
||||
assert not best.is_exact
|
||||
# feature 0, the blockout, is the only inexact one, and it comes from the
|
||||
# blockout donor.
|
||||
assert best.donor_for(0)[0] == 'PNL503'
|
||||
assert best.donor_for(0)[2] == pytest.approx(1.084, abs=0.001)
|
||||
for i in (1, 2, 3, 4, 5):
|
||||
assert best.donor_for(i)[2] == 0.0
|
||||
|
||||
|
||||
def test_the_worked_example_big_openings_come_from_the_opening_donor():
|
||||
donors = [('PNL502', PNL502), ('PNL503', PNL503)]
|
||||
best = rm.suggest(PNL501, donors, rm.rect_distance).best
|
||||
assert best.donor_for(1)[0] == 'PNL502'
|
||||
assert best.donor_for(2)[0] == 'PNL502'
|
||||
|
||||
|
||||
def test_a_donor_offering_only_the_common_end_hardware_does_not_get_chosen():
|
||||
"""The end notches sit identically on every piece, so they discriminate
|
||||
between nothing -- a donor carrying only them adds nothing to the cover."""
|
||||
common_only = [PNL501[3], PNL501[4], PNL501[5]]
|
||||
donors = [('PNL502', PNL502), ('PNL503', PNL503),
|
||||
('PNL504', common_only)]
|
||||
got = rm.suggest(PNL501, donors, rm.rect_distance)
|
||||
assert got.donors_needed == 2
|
||||
for proposal in got.proposals:
|
||||
assert 'PNL504' not in proposal.donors
|
||||
|
||||
|
||||
def test_a_third_donor_that_makes_a_smaller_cover_possible_takes_over():
|
||||
"""One donor that carries the whole target beats the two-donor split."""
|
||||
whole = list(PNL501)
|
||||
donors = [('PNL502', PNL502), ('PNL503', PNL503), ('PNL505', whole)]
|
||||
got = rm.suggest(PNL501, donors, rm.rect_distance)
|
||||
assert got.donors_needed == 1
|
||||
assert got.best.donors == ('PNL505',)
|
||||
assert got.best.is_exact
|
||||
|
||||
|
||||
# ------------------------------------------------------ greedy's known shape
|
||||
|
||||
def test_restarting_from_every_donor_beats_the_first_bite_trap():
|
||||
"""The failure documented above `_greedy_covers`, run.
|
||||
|
||||
Nine features; PNL_DIAG is tied for the biggest single bite and is listed
|
||||
first, so a single greedy pass takes it and then needs three more. The
|
||||
restarts find the three-donor answer.
|
||||
"""
|
||||
target = [str(i) for i in range(9)]
|
||||
diag = ['d2', 'd5', 'd8']
|
||||
a, b, c = ['a0', 'a1', 'a2'], ['b3', 'b4', 'b5'], ['c6', 'c7', 'c8']
|
||||
table = {('d2', '2'): 0.0, ('d5', '5'): 0.0, ('d8', '8'): 0.0}
|
||||
for group, base in ((a, 0), (b, 3), (c, 6)):
|
||||
for offset, name in enumerate(group):
|
||||
table[(name, str(base + offset))] = 0.0
|
||||
donors = [('PNL_DIAG', diag), ('PNL_A', a), ('PNL_B', b), ('PNL_C', c)]
|
||||
got = rm.suggest(target, donors, canned(table))
|
||||
assert got.status == rm.COMPLETE
|
||||
assert got.donors_needed == 3
|
||||
assert got.best.donors == ('PNL_A', 'PNL_B', 'PNL_C')
|
||||
|
||||
|
||||
def test_a_donor_that_covers_nothing_is_simply_ignored():
|
||||
target = ['a', 'b']
|
||||
donors = [('PNL_EMPTY', []), ('PNL_NONE', ['z']), ('PNL_GOOD', ['a', 'b'])]
|
||||
got = rm.suggest(target, donors, canned({('a', 'a'): 0.0,
|
||||
('b', 'b'): 0.0}))
|
||||
assert got.donor_count == 3
|
||||
assert got.donors_needed == 1
|
||||
assert got.best.donors == ('PNL_GOOD',)
|
||||
|
||||
|
||||
def test_a_donor_with_no_features_does_not_crash_the_run():
|
||||
got = rm.suggest(['a'], [('PNL_EMPTY', None)], canned({}))
|
||||
assert got.status == rm.NO_COVERAGE
|
||||
|
||||
|
||||
# --------------------------------------------------- the matcher stays blind
|
||||
|
||||
def test_the_matcher_never_touches_a_feature_record():
|
||||
"""Feature records that raise on ANY attribute access still work.
|
||||
|
||||
This is the seam, asserted. If the matcher ever grows a peek at a field,
|
||||
this test is what fails.
|
||||
"""
|
||||
class Opaque(object):
|
||||
def __init__(self, tag):
|
||||
object.__setattr__(self, '_tag', tag)
|
||||
|
||||
def __getattr__(self, name):
|
||||
raise AssertionError(
|
||||
'the matcher read field {0!r} off a feature record'.format(
|
||||
name))
|
||||
|
||||
def covers(donor_feature, target_feature):
|
||||
d = object.__getattribute__(donor_feature, '_tag')
|
||||
t = object.__getattribute__(target_feature, '_tag')
|
||||
return 0.0 if d == t else None
|
||||
|
||||
target = [Opaque('a'), Opaque('b')]
|
||||
donors = [('PNL_X', [Opaque('a')]), ('PNL_Y', [Opaque('b')])]
|
||||
got = rm.suggest(target, donors, covers)
|
||||
assert got.status == rm.COMPLETE
|
||||
assert got.best.donors == ('PNL_X', 'PNL_Y')
|
||||
|
||||
|
||||
def test_donor_keys_may_be_anything_hashable():
|
||||
target = ['a']
|
||||
donors = [((123456, 'assembly'), ['a'])]
|
||||
got = rm.suggest(target, donors, canned({('a', 'a'): 0.0}))
|
||||
assert got.best.donors == ((123456, 'assembly'),)
|
||||
|
||||
|
||||
# ------------------------------------------------------- MASTER as tie-break
|
||||
#
|
||||
# Owner's ruling, 2026-08-30: a donor typed MASTER in the model has been
|
||||
# REVIEWED, and reviewed donors are preferred. Asked how that interacts with
|
||||
# exactness -- a loosely-fitting MASTER against a perfectly exact non-MASTER
|
||||
# -- he ruled 拟合更好的排在前面: fit wins. MASTER breaks ties and nothing more.
|
||||
#
|
||||
# So there are exactly two things to prove: that it DOES decide a dead-even
|
||||
# tie, and that it NEVER moves anything the distance vector has separated.
|
||||
|
||||
|
||||
def test_master_breaks_a_tie_the_distances_cannot():
|
||||
"""Two covers, identical distance vectors. The reviewed one goes first.
|
||||
|
||||
Without the signal these two are dead level and only input order
|
||||
separates them -- and PNL801 is listed first, so a matcher that ignored
|
||||
MASTER would put PNL801 on top. That is what makes this test load-bearing
|
||||
rather than decorative.
|
||||
"""
|
||||
covers = canned({('a', 't'): 0.5, ('b', 't'): 0.5})
|
||||
donors = [('PNL801', ['a']), ('PNL802', ['b'])]
|
||||
|
||||
plain = rm.suggest(['t'], donors, covers)
|
||||
assert plain.proposals[0].donors == ('PNL801',)
|
||||
|
||||
ranked = rm.suggest(['t'], donors, covers, masters=['PNL802'])
|
||||
assert ranked.proposals[0].donors == ('PNL802',)
|
||||
assert ranked.proposals[0].master_count == 1
|
||||
assert ranked.proposals[1].master_count == 0
|
||||
|
||||
|
||||
def test_master_never_beats_a_better_fit():
|
||||
"""The ruling, as an executable statement.
|
||||
|
||||
PNL811 is a MASTER and fits loosely (0.5 off). PNL812 is not reviewed at
|
||||
all and is exact. Fit wins, every time, and the reviewed donor comes
|
||||
second -- it is not promoted past a cover the geometry has already put
|
||||
in front of it.
|
||||
"""
|
||||
covers = canned({('a', 't'): 0.5, ('b', 't'): 0.0})
|
||||
donors = [('PNL811', ['a']), ('PNL812', ['b'])]
|
||||
|
||||
ranked = rm.suggest(['t'], donors, covers, masters=['PNL811'])
|
||||
assert ranked.proposals[0].donors == ('PNL812',)
|
||||
assert ranked.proposals[0].master_count == 0
|
||||
assert ranked.proposals[1].donors == ('PNL811',)
|
||||
|
||||
|
||||
def test_master_never_beats_a_better_fit_even_a_hair_better():
|
||||
"""The margin does not matter. 0.001 ft of fit still outranks a review."""
|
||||
covers = canned({('a', 't'): 0.001, ('b', 't'): 0.0})
|
||||
donors = [('PNL821', ['a']), ('PNL822', ['b'])]
|
||||
ranked = rm.suggest(['t'], donors, covers, masters=['PNL821'])
|
||||
assert ranked.proposals[0].donors == ('PNL822',)
|
||||
|
||||
|
||||
def test_master_never_beats_fewer_donors():
|
||||
"""Donor count is the objective and MASTER sits below that too.
|
||||
|
||||
Only covers of the smallest size are built at all, so a two-donor set of
|
||||
masters is never even offered against a one-donor non-master cover.
|
||||
"""
|
||||
covers = canned({('a', 't0'): 0.0, ('a', 't1'): 0.0,
|
||||
('b', 't0'): 0.0, ('c', 't1'): 0.0})
|
||||
donors = [('PNL831', ['a']), ('PNL832', ['b']), ('PNL833', ['c'])]
|
||||
ranked = rm.suggest(['t0', 't1'], donors, covers,
|
||||
masters=['PNL832', 'PNL833'])
|
||||
assert ranked.donors_needed == 1
|
||||
assert ranked.proposals[0].donors == ('PNL831',)
|
||||
|
||||
|
||||
def test_more_masters_ranks_above_fewer_when_everything_else_is_level():
|
||||
"""A cover carrying a reviewed donor outranks one carrying none.
|
||||
|
||||
Four donors, two features, every distance 0.5, so the distance vectors of
|
||||
all three covers found are identical and only the tie-breaks separate
|
||||
them. PNL844 is the only master IN A COVER, and its cover is not the one
|
||||
input order would have chosen -- (PNL841, PNL842) is. So this fails if
|
||||
the master count is dropped from the key or put below input order.
|
||||
|
||||
(Only three covers exist rather than all four pairings because the search
|
||||
is greedy-with-restarts and seeds each walk from one donor; that is the
|
||||
matcher's documented approximation and is not what is under test here.)
|
||||
"""
|
||||
covers = canned({('a', 't0'): 0.5, ('b', 't1'): 0.5,
|
||||
('c', 't0'): 0.5, ('d', 't1'): 0.5})
|
||||
donors = [('PNL841', ['a']), ('PNL842', ['b']),
|
||||
('PNL843', ['c']), ('PNL844', ['d'])]
|
||||
plain = rm.suggest(['t0', 't1'], donors, covers)
|
||||
assert plain.proposals[0].donors == ('PNL841', 'PNL842')
|
||||
|
||||
ranked = rm.suggest(['t0', 't1'], donors, covers,
|
||||
masters=['PNL843', 'PNL844'])
|
||||
assert ranked.proposals[0].donors == ('PNL841', 'PNL844')
|
||||
assert ranked.proposals[0].master_count == 1
|
||||
assert ranked.proposals[-1].master_count == 0
|
||||
|
||||
|
||||
def test_master_is_never_a_filter():
|
||||
"""A pool with one MASTER still offers every other cover it found."""
|
||||
covers = canned({('a', 't'): 0.5, ('b', 't'): 0.5, ('c', 't'): 0.5})
|
||||
donors = [('PNL851', ['a']), ('PNL852', ['b']), ('PNL853', ['c'])]
|
||||
ranked = rm.suggest(['t'], donors, covers, masters=['PNL852'])
|
||||
assert len(ranked.proposals) == 3
|
||||
assert set(p.donors[0] for p in ranked.proposals) == {
|
||||
'PNL851', 'PNL852', 'PNL853'}
|
||||
|
||||
|
||||
def test_a_master_key_that_is_not_in_the_pool_is_ignored():
|
||||
covers = canned({('a', 't'): 0.5, ('b', 't'): 0.5})
|
||||
donors = [('PNL861', ['a']), ('PNL862', ['b'])]
|
||||
ranked = rm.suggest(['t'], donors, covers, masters=['PNL999'])
|
||||
assert ranked.proposals[0].donors == ('PNL861',)
|
||||
assert all(p.master_count == 0 for p in ranked.proposals)
|
||||
|
||||
|
||||
def test_no_masters_ranks_exactly_as_before():
|
||||
"""The signal off is the old matcher, byte for byte in its ordering."""
|
||||
covers = canned({('a', 't'): 0.5, ('b', 't'): 0.5})
|
||||
donors = [('PNL871', ['a']), ('PNL872', ['b'])]
|
||||
a = rm.suggest(['t'], donors, covers)
|
||||
b = rm.suggest(['t'], donors, covers, masters=[])
|
||||
c = rm.suggest(['t'], donors, covers, masters=None)
|
||||
assert ([p.donors for p in a.proposals] == [p.donors for p in b.proposals]
|
||||
== [p.donors for p in c.proposals])
|
||||
|
||||
|
||||
def test_a_proposal_reports_which_of_its_donors_are_reviewed():
|
||||
covers = canned({('a', 't0'): 0.0, ('b', 't1'): 0.0})
|
||||
donors = [('PNL881', ['a']), ('PNL882', ['b'])]
|
||||
ranked = rm.suggest(['t0', 't1'], donors, covers, masters=['PNL882'])
|
||||
best = ranked.proposals[0]
|
||||
assert best.masters == ('PNL882',)
|
||||
assert best.is_master('PNL882') and not best.is_master('PNL881')
|
||||
|
||||
|
||||
# ---------------------------------------------------- SAME ELEVATION as tie-break
|
||||
#
|
||||
# Owner, 2026-09-02, on WP425: five donors tied on every criterion above --
|
||||
# 1 donor, 5/5 covered, 4 exact, 0 reviewed, 0.04 in total distance, all five
|
||||
# identical -- and the tie fell through to mark order, so WP133 printed
|
||||
# first though WP234 shares WP425's own building elevation:
|
||||
# "WP234明明在同一个elevation,为什么排名没有提前?" Approved as briefed
|
||||
# ("同意,就这么做"): elevation is an AFFINITY, ranked LAST -- below MASTER,
|
||||
# above input order -- never a filter. The proof obligations mirror the
|
||||
# MASTER section exactly: it DOES decide a dead-even tie, and it NEVER moves
|
||||
# anything the distance vector or MASTER have already separated.
|
||||
|
||||
|
||||
def test_same_elevation_breaks_a_tie_the_distances_and_master_cannot():
|
||||
"""Two covers, identical distance vectors and no MASTER either. The
|
||||
donor sharing the target's elevation goes first.
|
||||
|
||||
Without the signal these two are dead level and only input order
|
||||
separates them -- and PNL901 is listed first, so a matcher that ignored
|
||||
elevation would put PNL901 on top. That is what makes this test
|
||||
load-bearing rather than decorative.
|
||||
"""
|
||||
covers = canned({('a', 't'): 0.5, ('b', 't'): 0.5})
|
||||
donors = [('PNL901', ['a']), ('PNL902', ['b'])]
|
||||
|
||||
plain = rm.suggest(['t'], donors, covers)
|
||||
assert plain.proposals[0].donors == ('PNL901',)
|
||||
|
||||
ranked = rm.suggest(['t'], donors, covers, same_elevation=['PNL902'])
|
||||
assert ranked.proposals[0].donors == ('PNL902',)
|
||||
assert ranked.proposals[0].elevation_count == 1
|
||||
assert ranked.proposals[1].elevation_count == 0
|
||||
|
||||
|
||||
def test_same_elevation_never_beats_a_better_fit():
|
||||
"""The ruling, as an executable statement.
|
||||
|
||||
PNL911 shares the target's elevation but fits loosely (0.5 off). PNL912
|
||||
is on a different elevation and is exact. Fit wins, every time, and the
|
||||
same-elevation donor comes second -- it is not promoted past a cover the
|
||||
geometry has already put in front of it.
|
||||
"""
|
||||
covers = canned({('a', 't'): 0.5, ('b', 't'): 0.0})
|
||||
donors = [('PNL911', ['a']), ('PNL912', ['b'])]
|
||||
|
||||
ranked = rm.suggest(['t'], donors, covers, same_elevation=['PNL911'])
|
||||
assert ranked.proposals[0].donors == ('PNL912',)
|
||||
assert ranked.proposals[0].elevation_count == 0
|
||||
assert ranked.proposals[1].donors == ('PNL911',)
|
||||
|
||||
|
||||
def test_same_elevation_never_beats_fewer_donors():
|
||||
"""Donor count is the objective and elevation sits below that too."""
|
||||
covers = canned({('a', 't0'): 0.0, ('a', 't1'): 0.0,
|
||||
('b', 't0'): 0.0, ('c', 't1'): 0.0})
|
||||
donors = [('PNL921', ['a']), ('PNL922', ['b']), ('PNL923', ['c'])]
|
||||
ranked = rm.suggest(['t0', 't1'], donors, covers,
|
||||
same_elevation=['PNL922', 'PNL923'])
|
||||
assert ranked.donors_needed == 1
|
||||
assert ranked.proposals[0].donors == ('PNL921',)
|
||||
|
||||
|
||||
def test_same_elevation_sits_below_master():
|
||||
"""MASTER decides first; elevation only breaks what MASTER leaves tied.
|
||||
|
||||
PNL931 is reviewed but on a different elevation; PNL932 shares the
|
||||
target's elevation but is not reviewed. Both fit identically (0.5 off).
|
||||
MASTER wins -- exactly the priority `_proposal_sort_key` states.
|
||||
"""
|
||||
covers = canned({('a', 't'): 0.5, ('b', 't'): 0.5})
|
||||
donors = [('PNL931', ['a']), ('PNL932', ['b'])]
|
||||
ranked = rm.suggest(['t'], donors, covers, masters=['PNL931'],
|
||||
same_elevation=['PNL932'])
|
||||
assert ranked.proposals[0].donors == ('PNL931',)
|
||||
assert ranked.proposals[0].master_count == 1
|
||||
assert ranked.proposals[0].elevation_count == 0
|
||||
|
||||
|
||||
def test_same_elevation_is_never_a_filter():
|
||||
"""A pool with one same-elevation donor still offers every cover found."""
|
||||
covers = canned({('a', 't'): 0.5, ('b', 't'): 0.5, ('c', 't'): 0.5})
|
||||
donors = [('PNL941', ['a']), ('PNL942', ['b']), ('PNL943', ['c'])]
|
||||
ranked = rm.suggest(['t'], donors, covers, same_elevation=['PNL942'])
|
||||
assert len(ranked.proposals) == 3
|
||||
assert set(p.donors[0] for p in ranked.proposals) == {
|
||||
'PNL941', 'PNL942', 'PNL943'}
|
||||
|
||||
|
||||
def test_a_same_elevation_key_that_is_not_in_the_pool_is_ignored():
|
||||
covers = canned({('a', 't'): 0.5, ('b', 't'): 0.5})
|
||||
donors = [('PNL951', ['a']), ('PNL952', ['b'])]
|
||||
ranked = rm.suggest(['t'], donors, covers, same_elevation=['PNL999'])
|
||||
assert ranked.proposals[0].donors == ('PNL951',)
|
||||
assert all(p.elevation_count == 0 for p in ranked.proposals)
|
||||
|
||||
|
||||
def test_no_same_elevation_ranks_exactly_as_before():
|
||||
"""The signal off is the old matcher, byte for byte in its ordering."""
|
||||
covers = canned({('a', 't'): 0.5, ('b', 't'): 0.5})
|
||||
donors = [('PNL961', ['a']), ('PNL962', ['b'])]
|
||||
a = rm.suggest(['t'], donors, covers)
|
||||
b = rm.suggest(['t'], donors, covers, same_elevation=[])
|
||||
c = rm.suggest(['t'], donors, covers, same_elevation=None)
|
||||
assert ([p.donors for p in a.proposals] == [p.donors for p in b.proposals]
|
||||
== [p.donors for p in c.proposals])
|
||||
|
||||
|
||||
def test_a_proposal_reports_which_of_its_donors_share_elevation():
|
||||
covers = canned({('a', 't0'): 0.0, ('b', 't1'): 0.0})
|
||||
donors = [('PNL971', ['a']), ('PNL972', ['b'])]
|
||||
ranked = rm.suggest(['t0', 't1'], donors, covers,
|
||||
same_elevation=['PNL972'])
|
||||
best = ranked.proposals[0]
|
||||
assert best.same_elevation == ('PNL972',)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the donor cap
|
||||
#
|
||||
# Owner's ruling, 2026-08-30: "一个方案,只要找到足够覆盖全部特征的母版,就够了。
|
||||
# 我期待是 1-4 个,最多不超过 4 个。" Four donors in one proposal, hard.
|
||||
#
|
||||
# The OVERFLOW rule -- what happens when four cannot cover everything -- was
|
||||
# NOT ruled and is inferred: return the best four and name what they miss,
|
||||
# never relax to a fifth. These tests are what pins that reading, so if the
|
||||
# owner overrules it they are the place to start.
|
||||
|
||||
|
||||
def _one_each(n):
|
||||
"""n target features, n donors, each donor carrying exactly one of them.
|
||||
|
||||
The worst case for a cap: the only way to cover everything is to take
|
||||
every donor, so any cap below n has to bite.
|
||||
"""
|
||||
target = [str(i) for i in range(n)]
|
||||
donors = [('PNL{0}'.format(600 + i), [str(i)]) for i in range(n)]
|
||||
table = dict(((str(i), str(i)), 0.0) for i in range(n))
|
||||
return target, donors, table
|
||||
|
||||
|
||||
def test_a_proposal_never_names_more_than_four_donors():
|
||||
target, donors, table = _one_each(8)
|
||||
got = rm.suggest(target, donors, canned(table))
|
||||
assert got.donor_cap == 4
|
||||
for proposal in got.proposals:
|
||||
assert len(proposal.donors) <= 4
|
||||
|
||||
|
||||
def test_the_cap_does_not_relax_to_a_fifth_donor():
|
||||
"""The inferred boundary, stated as a test. Eight features, eight donors
|
||||
with one feature each: five donors would cover more and the search may
|
||||
not take them."""
|
||||
target, donors, table = _one_each(8)
|
||||
got = rm.suggest(target, donors, canned(table))
|
||||
assert got.donors_needed == 4
|
||||
assert len(got.best.donors) == 4
|
||||
assert got.best.covered_count == 4
|
||||
|
||||
|
||||
def test_the_cap_names_what_it_could_not_reach():
|
||||
"""A partial answer that says what it missed is useful; one that quietly
|
||||
covers half the panel is not."""
|
||||
target, donors, table = _one_each(8)
|
||||
got = rm.suggest(target, donors, canned(table))
|
||||
assert got.status == rm.CAPPED
|
||||
assert len(got.capped_out) == 4
|
||||
assert set(got.capped_out) == set(got.best.uncovered)
|
||||
assert 'not within' in got.summary() or 'not by any' in got.summary()
|
||||
|
||||
|
||||
def test_capped_out_is_never_confused_with_nobody_has_it():
|
||||
"""Two different findings with opposite remedies. A feature the cap left
|
||||
out HAS a donor -- go and open it. A feature nobody has must be detailed
|
||||
from scratch. They are reported in separate fields and never merged."""
|
||||
target, donors, table = _one_each(8)
|
||||
got = rm.suggest(target, donors, canned(table))
|
||||
assert got.uncovered == () # somebody has every one of them
|
||||
assert got.capped_out # the cap is why they are missing
|
||||
assert got.status == rm.CAPPED
|
||||
assert got.status != rm.PARTIAL
|
||||
|
||||
|
||||
def test_the_cap_and_a_feature_nobody_has_are_reported_apart():
|
||||
"""Both can be true at once. The status reports the harder fact, and the
|
||||
two sets stay disjoint so the report can print two tables."""
|
||||
target, donors, table = _one_each(8)
|
||||
target = target + ['orphan']
|
||||
got = rm.suggest(target, donors, canned(table))
|
||||
assert got.status == rm.PARTIAL # nobody has 'orphan'
|
||||
assert got.uncovered == (8,)
|
||||
assert len(got.capped_out) == 4
|
||||
assert not set(got.capped_out) & set(got.uncovered)
|
||||
|
||||
|
||||
def test_four_donors_that_do_cover_everything_are_a_complete_answer():
|
||||
"""The cap only bites when it has to. At exactly four, nothing changes."""
|
||||
target, donors, table = _one_each(4)
|
||||
got = rm.suggest(target, donors, canned(table))
|
||||
assert got.status == rm.COMPLETE
|
||||
assert got.donors_needed == 4
|
||||
assert got.capped_out == ()
|
||||
|
||||
|
||||
def test_the_cap_is_not_the_proposal_limit():
|
||||
"""THE TWO AXES. `max_donors` bounds the size of one answer;
|
||||
`max_proposals` bounds how many answers come back. Conflating them gives
|
||||
either one giant proposal or four tiny ones."""
|
||||
target, donors, table = _one_each(8)
|
||||
got = rm.suggest(target, donors, canned(table), max_proposals=2)
|
||||
assert len(got.proposals) == 2 # the proposal limit bit
|
||||
assert got.cover_count > 2 # and there were more of them
|
||||
for proposal in got.proposals:
|
||||
assert len(proposal.donors) == 4 # the donor cap bit separately
|
||||
|
||||
|
||||
def test_under_the_cap_covering_more_beats_using_fewer_donors():
|
||||
"""Fewest donors is the objective only among answers that cover the same
|
||||
ground. Once the cap can stop a walk short, a two-donor set covering two
|
||||
features must not outrank a four-donor set covering six."""
|
||||
target = ['a', 'b', 'c', 'd', 'e', 'f']
|
||||
donors = [
|
||||
('PNL_SMALL_1', ['a']),
|
||||
('PNL_SMALL_2', ['b']),
|
||||
('PNL_BIG_1', ['c', 'd']),
|
||||
('PNL_BIG_2', ['e', 'f']),
|
||||
]
|
||||
table = {('a', 'a'): 0.0, ('b', 'b'): 0.0, ('c', 'c'): 0.0,
|
||||
('d', 'd'): 0.0, ('e', 'e'): 0.0, ('f', 'f'): 0.0}
|
||||
got = rm.suggest(target, donors, canned(table), max_donors=2)
|
||||
assert got.best.covered_count == 4
|
||||
assert got.best.donors == ('PNL_BIG_1', 'PNL_BIG_2')
|
||||
assert got.status == rm.CAPPED
|
||||
|
||||
|
||||
def test_the_cap_can_be_lifted_and_the_old_answer_comes_back():
|
||||
"""`max_donors=None` is the uncapped matcher, unchanged. Kept so the
|
||||
ruling stays a parameter rather than a rewrite."""
|
||||
target, donors, table = _one_each(8)
|
||||
got = rm.suggest(target, donors, canned(table), max_donors=None)
|
||||
assert got.status == rm.COMPLETE
|
||||
assert got.donors_needed == 8
|
||||
assert got.capped_out == ()
|
||||
|
||||
|
||||
def test_a_better_fit_still_wins_under_the_cap():
|
||||
"""THE LOAD-BEARING RULE, re-checked with the cap in place: a better fit
|
||||
outranks everything below it, MASTER included. Both covers here are of
|
||||
legal size, so the cap has nothing to say and must not interfere."""
|
||||
target = ['t']
|
||||
donors = [('PNL811', ['a']), ('PNL812', ['b'])]
|
||||
covers = canned({('a', 't'): 0.5, ('b', 't'): 0.0})
|
||||
got = rm.suggest(target, donors, covers, masters=['PNL811'])
|
||||
assert got.best.donors == ('PNL812',) # the exact one, not the MASTER
|
||||
assert got.best.exact_count == 1
|
||||
|
||||
|
||||
def test_the_cap_does_not_reorder_covers_of_equal_reach():
|
||||
"""Among covers that reach equally far the ranking is the old one, in
|
||||
order: fewest donors, then the distance vector, then MASTER, then input
|
||||
order. The cap adds a term above all of that and it is constant here."""
|
||||
target = ['t0', 't1']
|
||||
donors = [('PNL821', ['a', 'b']), ('PNL822', ['c', 'd'])]
|
||||
covers = canned({('a', 't0'): 1.0, ('b', 't1'): 1.0,
|
||||
('c', 't0'): 0.0, ('d', 't1'): 0.0})
|
||||
got = rm.suggest(target, donors, covers)
|
||||
assert [p.donors[0] for p in got.proposals] == ['PNL822', 'PNL821']
|
||||
@@ -0,0 +1,269 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The offline CLI: `tools/reinforce_offline.py`, run with no Revit at all.
|
||||
|
||||
WHAT THESE TESTS ARE REALLY FOR
|
||||
--------------------------------
|
||||
The claim this feature rests on is a claim about an IMPORT GRAPH: that the
|
||||
whole chain from an index file to a printed suggestion touches nothing in
|
||||
`Autodesk.*`. That claim cannot be argued, only run -- and running it here is
|
||||
the proof, because host CPython has no Revit API at all, so a stray
|
||||
module-scope `from Autodesk.Revit.DB import ...` anywhere on the path does not
|
||||
degrade, it raises ImportError and this file goes red.
|
||||
|
||||
The rest is the wording an offline reader depends on: that a snapshot is
|
||||
never presented as a model, that a missing index says what to do about it, and
|
||||
that an index from another schema is refused whole rather than half-read.
|
||||
|
||||
Every mark here is an invented `PNL...` placeholder, as everywhere else in
|
||||
this suite. No real mark, job or client name appears.
|
||||
"""
|
||||
# pylint: disable=import-error,invalid-name
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe import foldreport as fold
|
||||
from vibe import reinforcecore as core
|
||||
|
||||
CLI_PATH = Path(__file__).resolve().parents[2] / 'tools' / 'reinforce_offline.py'
|
||||
|
||||
|
||||
def _load_cli():
|
||||
spec = importlib.util.spec_from_file_location('reinforce_offline',
|
||||
str(CLI_PATH))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
cli = _load_cli()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ fixtures
|
||||
|
||||
def _piece(name, rects, reinforced=1, on_hold=0, master=False):
|
||||
feats = [core.feature(name, i, r[0], r[1], r[2], r[3], True, 0.0,
|
||||
30.0, 10.0, 8.0)
|
||||
for i, r in enumerate(rects)]
|
||||
return core.piece_record(name, 100 + len(name), 'sig', reinforced, on_hold,
|
||||
design_number='PNL1.0',
|
||||
length_ft=30.0, width_ft=10.0, thickness_in=8.0,
|
||||
features=feats, master=master)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def snapshot(tmp_path):
|
||||
"""A jobs root with one model in it, holding a two-donor index."""
|
||||
folder = tmp_path / 'jobs' / 'sample_job' / 'reinforce'
|
||||
folder.mkdir(parents=True)
|
||||
cache = core.new_cache('sample_job')
|
||||
cache['built_at'] = '2026-08-30 09:00:00'
|
||||
cache['scope'] = {'prefixes': ['PNL'], 'note': None, 'declared_by': None}
|
||||
pieces = [
|
||||
_piece('PNL800', [(4.0, 6.0, 2.0, 5.0), (20.0, 22.0, 2.0, 5.0)],
|
||||
reinforced=0), # the target: not a donor
|
||||
_piece('PNL801', [(4.0, 6.0, 2.0, 5.0), (20.0, 22.0, 2.0, 5.0)]),
|
||||
_piece('BM802', [(4.0, 6.0, 2.0, 5.0)]), # another engineer's piece
|
||||
]
|
||||
cache['pieces'] = dict((p['name'], p) for p in pieces)
|
||||
core.save(str(folder / 'index.json'), cache)
|
||||
return tmp_path / 'jobs'
|
||||
|
||||
|
||||
# ----------------------------------------------------- the import graph claim
|
||||
|
||||
def test_no_part_of_the_offline_path_touches_revit(snapshot, capsys):
|
||||
"""The claim, run rather than argued. See the module docstring."""
|
||||
before = set(sys.modules)
|
||||
assert cli.main(['PNL800', '--jobs', str(snapshot),
|
||||
'--model', 'sample_job']) == 0
|
||||
capsys.readouterr()
|
||||
loaded = set(sys.modules) - before
|
||||
forbidden = [m for m in set(sys.modules)
|
||||
if m.split('.')[0] in ('Autodesk', 'clr', 'pyrevit',
|
||||
'System', 'Microsoft')]
|
||||
assert forbidden == [], forbidden
|
||||
# And the modules that DID load are the ones that were promised to be free
|
||||
# of it, which is the other half of the claim.
|
||||
assert 'vibe.reinforcebind' in loaded or 'vibe.reinforcebind' in sys.modules
|
||||
|
||||
|
||||
def test_the_two_functions_that_need_a_document_are_named_and_not_called():
|
||||
"""`outline_audit` and `suggest_for_piece` are the Revit-bound pair. They
|
||||
are importable -- the module has no Revit at module scope -- and calling
|
||||
either is what an offline caller must not do."""
|
||||
from vibe import reinforcebind as bind
|
||||
assert callable(bind.outline_audit)
|
||||
assert callable(bind.suggest_for_piece)
|
||||
assert callable(bind.suggest_from_index) # the one this path uses
|
||||
assert callable(bind.load_audit_at) # the doc-free audit reader
|
||||
|
||||
|
||||
# -------------------------------------------------------------- finding it
|
||||
|
||||
def test_an_explicit_path_wins_and_is_named_in_the_output(snapshot):
|
||||
path, how = cli.resolve_index(
|
||||
str(snapshot / 'sample_job' / 'reinforce' / 'index.json'))
|
||||
assert path.endswith('index.json')
|
||||
assert '--index' in how
|
||||
|
||||
|
||||
def test_a_jobs_root_plus_a_model_resolves_the_standard_layout(snapshot):
|
||||
path, how = cli.resolve_index(None, str(snapshot), 'sample_job')
|
||||
assert os.path.isfile(path)
|
||||
assert 'sample_job' in how
|
||||
|
||||
|
||||
def test_one_model_under_the_root_needs_no_naming(snapshot):
|
||||
path, _how = cli.resolve_index(None, str(snapshot), None)
|
||||
assert os.path.isfile(path)
|
||||
|
||||
|
||||
def test_several_models_refuse_to_be_guessed_between(snapshot):
|
||||
(snapshot / 'another_job').mkdir()
|
||||
with pytest.raises(SystemExit) as got:
|
||||
cli.resolve_index(None, str(snapshot), None)
|
||||
assert 'Which model' in str(got.value)
|
||||
|
||||
|
||||
def test_no_index_and_no_jobs_root_says_all_three_ways_to_give_one(monkeypatch):
|
||||
monkeypatch.delenv('WILLTOOLS_JOBS', raising=False)
|
||||
with pytest.raises(SystemExit) as got:
|
||||
cli.resolve_index(None, None, None)
|
||||
message = str(got.value)
|
||||
assert '--index' in message and '--jobs' in message and 'WILLTOOLS_JOBS' \
|
||||
in message
|
||||
|
||||
|
||||
def test_a_missing_index_says_it_is_written_from_inside_revit(tmp_path):
|
||||
with pytest.raises(SystemExit) as got:
|
||||
cli.load_index(str(tmp_path / 'nope.json'))
|
||||
assert 'from inside Revit' in str(got.value)
|
||||
|
||||
|
||||
def test_an_index_from_another_schema_is_refused_whole(tmp_path):
|
||||
"""Never half-read: a donors-only file would answer 'not in the index'
|
||||
for every ordinary target while looking entirely healthy."""
|
||||
path = tmp_path / 'index.json'
|
||||
path.write_text(json.dumps({'schema': core.SCHEMA - 1, 'pieces': {}}),
|
||||
encoding='utf-8')
|
||||
with pytest.raises(SystemExit) as got:
|
||||
cli.load_index(str(path))
|
||||
message = str(got.value)
|
||||
assert 'discarded whole' in message
|
||||
assert 'Reinforcing Suggestion' in message # and what to do about it
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the run
|
||||
|
||||
def test_a_target_that_may_not_donate_is_answered_from_the_snapshot(snapshot):
|
||||
"""The whole point: PNL800 is not reinforced, so before schema 5 it was
|
||||
not in the file at all and only a live measurement could answer it."""
|
||||
folder = str(snapshot / 'sample_job' / 'reinforce')
|
||||
cache = cli.load_index(os.path.join(folder, 'index.json'))
|
||||
suggestion, info, sections = cli.run(cache, 'PNL800', folder)
|
||||
assert suggestion is not None
|
||||
assert info['offline'] is True
|
||||
assert info['donor_names'] == ['PNL801'] # BM802 is out of scope
|
||||
text = fold.text(sections, show_all=True)
|
||||
assert 'OFFLINE' in text
|
||||
assert '2026-08-30 09:00:00' in text
|
||||
assert 'checked against the model' in text
|
||||
|
||||
|
||||
def test_the_stamped_scope_is_used_and_can_be_overridden(snapshot):
|
||||
folder = str(snapshot / 'sample_job' / 'reinforce')
|
||||
cache = cli.load_index(os.path.join(folder, 'index.json'))
|
||||
_s, bounded, _sections = cli.run(cache, 'PNL800', folder)
|
||||
assert bounded['rejected'].get('prefix') == 1
|
||||
_s, unbounded, _sections = cli.run(cache, 'PNL800', folder,
|
||||
no_scope=True)
|
||||
assert 'prefix' not in unbounded['rejected']
|
||||
|
||||
|
||||
def test_an_index_with_no_stamped_scope_says_so_rather_than_assuming(snapshot):
|
||||
"""'Nobody recorded it' is not 'no scope was declared'."""
|
||||
folder = str(snapshot / 'sample_job' / 'reinforce')
|
||||
path = os.path.join(folder, 'index.json')
|
||||
cache = cli.load_index(path)
|
||||
cache['scope'] = None
|
||||
_s, info, sections = cli.run(cache, 'PNL800', folder)
|
||||
assert info['scope_stamped'] is False
|
||||
# It goes in the HEADLINE, not a folded section: every count below rests
|
||||
# on a pool bounded by nobody knows what.
|
||||
assert any('no declared scope' in b[1] for b in sections[0].blocks
|
||||
if b[0] == 'md')
|
||||
|
||||
|
||||
def test_listing_keeps_donors_and_targets_apart(snapshot):
|
||||
cache = cli.load_index(str(snapshot / 'sample_job' / 'reinforce' /
|
||||
'index.json'))
|
||||
donors, targets = cli.listing(cache)
|
||||
assert donors == ['BM802', 'PNL801']
|
||||
assert targets == ['PNL800']
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the printing
|
||||
|
||||
def test_entities_become_ascii_and_the_words_are_left_alone():
|
||||
got = cli.plain('This is **bold** — and `code`.')
|
||||
assert '--' in got
|
||||
assert '—' not in got
|
||||
assert 'This is bold -- and code.' in got
|
||||
|
||||
|
||||
def test_a_quote_block_is_marked_so_a_caveat_cannot_look_like_prose():
|
||||
assert cli.plain('> a warning').lstrip().startswith('!')
|
||||
|
||||
|
||||
def test_a_table_never_truncates_a_mark_however_wide_the_row_gets():
|
||||
"""A silently cut mark is a wrong answer that looks like a right one."""
|
||||
got = cli.table(['donor', 'what'],
|
||||
[['PNL803', 'x' * 120], ['PNL804', 'y']])
|
||||
assert 'PNL803' in got and 'PNL804' in got
|
||||
assert 'x' * 120 in got
|
||||
|
||||
|
||||
def test_an_empty_table_says_nothing_rather_than_printing_a_header():
|
||||
assert 'nothing' in cli.table(['a'], [])
|
||||
|
||||
|
||||
# ------------------------------------------------------- what folds at a shell
|
||||
|
||||
def test_the_terminal_folds_the_routine_sections_to_one_line(snapshot, capsys):
|
||||
"""The owner's rule, at a command line: the answer, then a short list of
|
||||
what else there is. `--all` opens everything."""
|
||||
args = ['PNL800', '--jobs', str(snapshot), '--model', 'sample_job']
|
||||
cli.main(args)
|
||||
folded = _flat(capsys.readouterr().out)
|
||||
cli.main(args + ['--all'])
|
||||
everything = _flat(capsys.readouterr().out)
|
||||
assert len(everything) > len(folded)
|
||||
assert '(folded)' in folded
|
||||
assert 'How to read the numbers' in folded # named, though folded
|
||||
assert 'finest tolerance' not in folded # but not printed in full
|
||||
assert 'finest tolerance' in everything
|
||||
|
||||
|
||||
def test_the_unfolded_report_can_always_be_written_out(snapshot, tmp_path,
|
||||
capsys):
|
||||
"""Folded is not gone. A fold that will not open must cost a file open,
|
||||
never a lost sentence -- so the file holds every section in full."""
|
||||
out = tmp_path / 'full.txt'
|
||||
cli.main(['PNL800', '--jobs', str(snapshot), '--model', 'sample_job',
|
||||
'--out', str(out)])
|
||||
printed = _flat(capsys.readouterr().out)
|
||||
written = _flat(out.read_text(encoding='utf-8'))
|
||||
assert 'finest tolerance' in written
|
||||
assert 'finest tolerance' not in printed
|
||||
assert str(out) in written # the page names itself
|
||||
|
||||
|
||||
def _flat(text):
|
||||
"""Whitespace-normalised. The plain-text renderer wraps at 78 columns, so
|
||||
a phrase these tests look for can be split across two lines."""
|
||||
return ' '.join(text.split())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,318 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The declared scope: what the tool is answerable for, and what it says
|
||||
about everything else.
|
||||
|
||||
Every mark here is a placeholder. `PNL` stands for the pieces in scope and
|
||||
`XX` / `YY` for somebody else's -- the real prefixes on a real job are job
|
||||
information and belong in the job knowledge file, never in this repository.
|
||||
|
||||
What these tests are actually protecting, in one sentence each:
|
||||
|
||||
* an undeclared scope judges EVERYTHING (an empty list is not an empty
|
||||
scope, and this is the assertion that stops it becoming one);
|
||||
* a mark whose prefix cannot be read is never set aside;
|
||||
* the report line exists in all three states and never reads as a pass;
|
||||
* nothing the tool sets aside is summed away without being named;
|
||||
* a NEAR MISS -- a set-aside prefix that is one letter off a declared one
|
||||
-- is reported and never resolved. `PNLA` is used for that throughout:
|
||||
it is `PNL` plus a letter, which is the shape that made this a hole.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from vibe import scope as S
|
||||
|
||||
|
||||
# ------------------------------------------------------------ the predicate
|
||||
|
||||
def test_no_declaration_includes_everything():
|
||||
"""The single most important behaviour here. 'Nobody said' must never
|
||||
collapse into 'nothing is in scope'."""
|
||||
empty = S.Scope()
|
||||
assert empty.is_declared is False
|
||||
for name in ('PNL001', 'XX001', 'YY9', '', None, '12345'):
|
||||
assert empty.includes(name) is True
|
||||
|
||||
|
||||
def test_declared_scope_keeps_its_own_and_sets_the_rest_aside():
|
||||
scope = S.Scope(prefixes=('PNL',))
|
||||
assert scope.includes('PNL001') is True
|
||||
assert scope.includes('pnl001') is True # case is not a fact
|
||||
assert scope.includes('XX001') is False
|
||||
assert scope.includes('YY12') is False
|
||||
|
||||
|
||||
def test_several_prefixes_may_be_declared():
|
||||
scope = S.Scope(prefixes=('PNL', 'XX'))
|
||||
assert scope.includes('XX001') is True
|
||||
assert scope.includes('YY001') is False
|
||||
|
||||
|
||||
def test_a_mark_with_no_readable_prefix_is_never_set_aside():
|
||||
"""'I could not tell' may only ever resolve towards judging it."""
|
||||
scope = S.Scope(prefixes=('PNL',))
|
||||
assert scope.includes('12345') is True
|
||||
assert scope.includes('') is True
|
||||
assert scope.includes(None) is True
|
||||
assert scope.is_unreadable('12345') is True
|
||||
assert scope.kind('12345') == S.UNREADABLE
|
||||
|
||||
|
||||
def test_blank_and_duplicate_prefixes_are_cleaned_not_honoured():
|
||||
scope = S.Scope(prefixes=['PNL', ' pnl ', '', None])
|
||||
assert scope.prefixes == ('PNL',)
|
||||
|
||||
|
||||
def test_an_all_blank_declaration_is_no_declaration():
|
||||
assert S.Scope(prefixes=['', ' ']).is_declared is False
|
||||
|
||||
|
||||
# ------------------------------------------------------------- mark shapes
|
||||
|
||||
@pytest.mark.parametrize('name,expected', [
|
||||
('PNL001', True), ('PNL 1', True), ('XX12', True),
|
||||
('GENERAL ARRANGEMENT', False), ('', False), (None, False),
|
||||
('12345', False),
|
||||
])
|
||||
def test_looks_like_mark(name, expected):
|
||||
"""A sheet name that is not a mark is not somebody else's piece, and
|
||||
must not be counted as one."""
|
||||
assert S.looks_like_mark(name) is expected
|
||||
|
||||
|
||||
def test_as_mark_hands_back_nothing_for_a_non_mark():
|
||||
scope = S.Scope(prefixes=('PNL',))
|
||||
assert scope.includes(S.as_mark('GENERAL ARRANGEMENT')) is True
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- splits
|
||||
|
||||
def test_split_preserves_order_and_keeps_both_halves():
|
||||
scope = S.Scope(prefixes=('PNL',))
|
||||
inside, outside = scope.split_names(
|
||||
['PNL001', 'XX001', 'PNL002', 'YY001'])
|
||||
assert inside == ['PNL001', 'PNL002']
|
||||
assert outside == ['XX001', 'YY001']
|
||||
|
||||
|
||||
def test_split_reads_a_key():
|
||||
scope = S.Scope(prefixes=('PNL',))
|
||||
rows = [{'mark': 'PNL001'}, {'mark': 'XX001'}]
|
||||
inside, outside = scope.split(rows, key=lambda r: r['mark'])
|
||||
assert inside == [{'mark': 'PNL001'}]
|
||||
assert outside == [{'mark': 'XX001'}]
|
||||
|
||||
|
||||
def test_tally_names_every_kind_biggest_first():
|
||||
"""Counted AND named. '435 pieces' sends nobody anywhere."""
|
||||
scope = S.Scope(prefixes=('PNL',))
|
||||
assert scope.tally(['XX1', 'XX2', 'YY1', 'XX3', 'ZZ1', 'ZZ2']) == [
|
||||
('XX', 3), ('ZZ', 2), ('YY', 1)]
|
||||
|
||||
|
||||
# ------------------------------------------------------------ the reporting
|
||||
|
||||
def test_the_line_when_work_was_set_aside_names_the_kinds():
|
||||
scope = S.Scope(prefixes=('PNL',))
|
||||
line = scope.line(['XX1', 'XX2', 'YY1'], total=10)
|
||||
assert 'NOT JUDGED, OUT OF SCOPE' in line
|
||||
assert 'XX 2' in line and 'YY 1' in line
|
||||
assert '3 of 10' in line
|
||||
# Never a pass, and never a failure -- it says both out loud.
|
||||
assert 'Not judged is not clean, and not a failure either.' in line
|
||||
|
||||
|
||||
def test_the_line_is_printed_even_when_nothing_was_set_aside():
|
||||
"""A run that filtered nothing looks exactly like a run that filtered
|
||||
everything once the answer is on the screen."""
|
||||
line = S.Scope(prefixes=('PNL',)).line([])
|
||||
assert 'none' in line
|
||||
assert 'declared scope is piece marks beginning PNL' in line
|
||||
|
||||
|
||||
def test_the_undeclared_line_says_everything_was_judged_and_why_that_matters():
|
||||
line = S.Scope().line([])
|
||||
assert 'No scope is declared' in line
|
||||
assert 'every piece was judged' in line
|
||||
assert 'never been validated' in line
|
||||
|
||||
|
||||
def test_the_line_reports_unreadable_marks_separately():
|
||||
scope = S.Scope(prefixes=('PNL',))
|
||||
line = scope.line(['XX1'], total=3, unreadable_names=['12345'])
|
||||
assert 'no readable mark prefix' in line
|
||||
assert 'judged rather than set aside' in line
|
||||
|
||||
|
||||
def test_the_annotation_is_for_work_the_user_picked_himself():
|
||||
"""Nothing the user selects is ever dropped. It is judged, and the
|
||||
report says the verdict rests on unvalidated knowledge."""
|
||||
scope = S.Scope(prefixes=('PNL',))
|
||||
note = scope.annotation(['XX1'])
|
||||
assert 'AT YOUR REQUEST' in note
|
||||
assert 'not been validated' in note
|
||||
assert scope.annotation([]) is None
|
||||
assert S.Scope().annotation(['XX1']) is None # nothing declared, nothing odd
|
||||
|
||||
|
||||
def test_label_reads_as_a_sentence_a_person_can_check():
|
||||
assert S.Scope(prefixes=('PNL',)).label() == 'piece marks beginning PNL'
|
||||
assert S.Scope(prefixes=('PNL', 'XX')).label() == \
|
||||
'piece marks beginning PNL or XX'
|
||||
assert S.Scope().label() == 'no scope declared'
|
||||
|
||||
|
||||
def test_declaration_lines_carry_who_said_so():
|
||||
scope = S.Scope(prefixes=('PNL',), declared_by='somebody',
|
||||
note='because of a reason')
|
||||
assert scope.declaration_lines() == ['scope declared by somebody',
|
||||
'because of a reason']
|
||||
assert S.Scope(prefixes=('PNL',)).declaration_lines() == []
|
||||
|
||||
|
||||
# --------------------------------------------------------- the near miss
|
||||
#
|
||||
# The hole: `prefix_of` reads the whole leading letter run, so a declared
|
||||
# `PNL` does not reach `PNLA001` and that piece is handed to another
|
||||
# engineer on the strength of one letter. The decision recorded here is that
|
||||
# the MATCH IS NOT WIDENED and the near miss is REPORTED -- widening would
|
||||
# also swallow every other run starting `PNL`, and a false in-scope is judged
|
||||
# silently while a false out-of-scope is at least printed.
|
||||
|
||||
def test_the_match_is_still_the_whole_letter_run_and_was_not_widened():
|
||||
"""The regression lock on the decision. If somebody later widens the
|
||||
match, they must come here, read why it was not, and change it on
|
||||
purpose -- with the owner's answer in hand, not instead of it."""
|
||||
scope = S.Scope(prefixes=('PNL',))
|
||||
assert scope.includes('PNLA001') is False
|
||||
assert scope.includes('PNLZ001') is False
|
||||
assert scope.prefixes == ('PNL',)
|
||||
|
||||
|
||||
def test_a_nearly_matching_prefix_is_named_as_a_near_miss():
|
||||
scope = S.Scope(prefixes=('PNL',))
|
||||
assert scope.near_misses(['PNLA1', 'PNLA2', 'XX1']) == [('PNLA', 2, 'PNL')]
|
||||
|
||||
|
||||
def test_a_near_miss_is_found_in_both_directions():
|
||||
"""Either spelling may be the longer one. Declaring `PNLA` and finding
|
||||
`PNL001` is the same mistake seen from the other end."""
|
||||
assert S.Scope(prefixes=('PNLA',)).near_misses(['PNL1']) == \
|
||||
[('PNL', 1, 'PNLA')]
|
||||
|
||||
|
||||
def test_an_exact_match_is_not_a_near_miss_and_neither_is_an_unrelated_one():
|
||||
scope = S.Scope(prefixes=('PNL',))
|
||||
assert scope.nearly('PNL') is None # it is a match, not a near one
|
||||
assert scope.nearly('XX') is None
|
||||
assert scope.near_misses(['XX1', 'YY2']) == []
|
||||
|
||||
|
||||
def test_an_unreadable_mark_is_never_a_near_miss():
|
||||
"""It has no prefix to be nearly anything, and it was judged anyway."""
|
||||
scope = S.Scope(prefixes=('PNL',))
|
||||
assert scope.nearly(S.UNREADABLE) is None
|
||||
assert scope.near_misses(['12345']) == []
|
||||
|
||||
|
||||
def test_nothing_is_a_near_miss_when_nothing_was_declared():
|
||||
assert S.Scope().near_misses(['PNLA1']) == []
|
||||
assert S.Scope().near_miss_line(['PNLA1']) is None
|
||||
|
||||
|
||||
def test_the_near_miss_line_says_both_spellings_and_refuses_to_guess():
|
||||
line = S.Scope(prefixes=('PNL',)).near_miss_line(['PNLA1'], unit='piece')
|
||||
assert 'PNLA' in line and 'PNL' in line
|
||||
assert 'NEAR MISS' in line
|
||||
assert 'will not guess' in line
|
||||
# It names the fix without making it: the human widens the declaration.
|
||||
assert 'scope.mark_prefixes' in line
|
||||
|
||||
|
||||
def test_the_near_miss_rides_on_the_ordinary_report_line():
|
||||
"""So that every existing consumer of `line()` reports it without
|
||||
having been changed to know it exists."""
|
||||
line = S.Scope(prefixes=('PNL',)).line(['PNLA1', 'XX1'], total=9)
|
||||
assert 'NOT JUDGED, OUT OF SCOPE' in line
|
||||
assert 'NEAR MISS' in line
|
||||
assert 'PNLA 1' in line # still tallied by kind as before
|
||||
|
||||
|
||||
def test_an_ordinary_out_of_scope_line_carries_no_near_miss_noise():
|
||||
line = S.Scope(prefixes=('PNL',)).line(['XX1', 'YY2'], total=9)
|
||||
assert 'NEAR MISS' not in line
|
||||
|
||||
|
||||
def test_the_annotation_carries_the_near_miss_too():
|
||||
"""Here it matters more: the reader is being told his own selection
|
||||
rests on unvalidated knowledge, and that warning may be the wrong one."""
|
||||
note = S.Scope(prefixes=('PNL',)).annotation(['PNLA1'])
|
||||
assert 'AT YOUR REQUEST' in note
|
||||
assert 'NEAR MISS' in note
|
||||
|
||||
|
||||
# ------------------------------------------------------- the short headline
|
||||
|
||||
def test_the_short_line_says_the_count_in_all_three_states():
|
||||
scope = S.Scope(prefixes=('PNL',))
|
||||
assert 'none declared' in S.Scope().short_line(['XX1'])
|
||||
assert 'no piece' in scope.short_line([], total=9)
|
||||
busy = scope.short_line(['XX1', 'XX2', 'YY1'], total=9, unit='assembly')
|
||||
assert 'NOT JUDGED, OUT OF SCOPE' in busy
|
||||
assert '3 of 9 assembly(s)' in busy
|
||||
assert 'XX 2' in busy and 'YY 1' in busy
|
||||
|
||||
|
||||
def test_the_short_line_caps_the_kinds_so_it_stays_short():
|
||||
scope = S.Scope(prefixes=('PNL',))
|
||||
text = scope.short_line(['AA1', 'BB1', 'CC1', 'DD1', 'EE1'])
|
||||
assert '+2 more' in text
|
||||
assert len(text) < 200
|
||||
|
||||
|
||||
def test_the_short_line_flags_a_near_miss_without_explaining_it():
|
||||
"""It is a pointer at the full sentence, not a replacement for it."""
|
||||
text = S.Scope(prefixes=('PNL',)).short_line(['PNLA1'])
|
||||
assert text.endswith('-- NEAR MISS')
|
||||
assert 'will not guess' not in text
|
||||
|
||||
|
||||
# ------------------------------------------------------- reading the layer
|
||||
|
||||
def _get(know, dotted, default=None):
|
||||
node = know
|
||||
for part in dotted.split('.'):
|
||||
if not isinstance(node, dict) or part not in node:
|
||||
return default
|
||||
node = node[part]
|
||||
return default if node is None else node
|
||||
|
||||
|
||||
def test_of_reads_the_job_key():
|
||||
know = {'scope': {'mark_prefixes': ['PNL'], 'declared_by': 'somebody',
|
||||
'note': 'a reason'}}
|
||||
scope = S.of(know, _get)
|
||||
assert scope.prefixes == ('PNL',)
|
||||
assert scope.declared_by == 'somebody'
|
||||
assert scope.note == 'a reason'
|
||||
|
||||
|
||||
def test_of_accepts_a_single_scalar_because_yaml_readers_hand_back_strings():
|
||||
assert S.of({'scope': {'mark_prefixes': 'PNL'}}, _get).prefixes == ('PNL',)
|
||||
|
||||
|
||||
def test_of_on_an_absent_or_empty_key_declares_nothing():
|
||||
for know in ({}, None, {'scope': {}}, {'scope': {'mark_prefixes': []}},
|
||||
{'scope': {'mark_prefixes': None}}):
|
||||
assert S.of(know, _get).is_declared is False
|
||||
|
||||
|
||||
def test_the_shipped_default_declares_nothing():
|
||||
"""The repository must never carry one job's division of labour."""
|
||||
from vibe import knowledge
|
||||
assert knowledge.DEFAULTS['scope']['mark_prefixes'] == []
|
||||
assert S.of(knowledge.DEFAULTS, knowledge.get).is_declared is False
|
||||
|
||||
|
||||
def test_a_folded_yaml_note_does_not_carry_its_newline_into_the_report():
|
||||
scope = S.Scope(prefixes=('PNL',), note='one sentence.\n')
|
||||
assert scope.declaration_lines() == ['one sentence.']
|
||||
@@ -0,0 +1,641 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for tools/scrub.py -- the placeholder exemption on the Revit
|
||||
model/family filename patterns, and the term-boundary fix that stops a
|
||||
denylist term from hiding behind a glued-on underscore or digit.
|
||||
|
||||
`scrub.py` is host CPython (git subprocess calls, `from __future__ import
|
||||
annotations`), so it is loaded the same way `tools/knowledge_drift.py` is in
|
||||
`test_knowledge_drift.py`: put `tools/` on `sys.path` and import it directly,
|
||||
rather than the by-path `importlib` trick `test_reinforceoffline.py` uses --
|
||||
either works, this one is fewer lines for a module with no package-relative
|
||||
imports of its own.
|
||||
|
||||
What is worth testing here is not "does scrub.py find things" in general --
|
||||
it is specifically the shape of two exemptions/fixes going wrong:
|
||||
|
||||
* the placeholder exemption: the placeholder itself must pass, a genuine-
|
||||
looking model filename must still fail, and -- the narrow way an
|
||||
exemption like this goes too far -- a name that merely CONTAINS the
|
||||
placeholder (`sample_job_...`) must still fail too.
|
||||
* the term boundary: a denylist term glued to "_SOMETHING" or a digit must
|
||||
now be caught, while a term that is a substring of an unrelated longer
|
||||
word must still be let alone.
|
||||
|
||||
Both are checked at the unit level (`_is_placeholder_filename`,
|
||||
`load_placeholders`, the compiled term pattern) and at the `scan_worktree` /
|
||||
`scan_history` / `main` level, so a future refactor that keeps the unit
|
||||
right but wires it up wrong still gets caught.
|
||||
|
||||
No real client, job, or plant name appears anywhere below. Every
|
||||
"real-looking" filename and every term used to probe the boundary fix is
|
||||
invented for this test and identifies no one -- and every one of them is
|
||||
assembled at runtime rather than spelled whole in this file's own source
|
||||
(the same trick `test_ticketqc_evidence.py` uses for its model path), because
|
||||
`tools/scrub.py` correctly treats this file like any other: a near-miss or a
|
||||
realistic-looking name is SUPPOSED to keep failing the gate, including when
|
||||
`scrub.py` scans this file's own source, so writing one in whole would make
|
||||
this test file permanently unable to pass `--worktree` / `--staged` itself.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))))
|
||||
TOOLS = os.path.join(ROOT, 'tools')
|
||||
if TOOLS not in sys.path:
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
scrub = pytest.importorskip('scrub')
|
||||
|
||||
_RVT = '.' + 'rvt'
|
||||
_RFA = '.' + 'rfa'
|
||||
|
||||
|
||||
# A stand-in for CLAUDE.md, shaped exactly like the real Section 3 excerpt:
|
||||
# the placeholder table plus the "Product codes" paragraph right after it,
|
||||
# so a test against this fixture proves the same thing a test against the
|
||||
# real file would, without depending on CLAUDE.md's prose staying byte-for-
|
||||
# byte the same while five other agents may be editing it.
|
||||
FAKE_CLAUDE_MD = """
|
||||
## 3. Hard rules
|
||||
|
||||
**No client or job information in the repo. Ever.**
|
||||
|
||||
Not in code, not in comments, not in docstrings, not in a commit message, not
|
||||
in an example. When you need to refer to one, write the placeholder rather
|
||||
than deleting the sentence:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| a client / owner | `sample_client` |
|
||||
| a job / project / model | `sample_job` |
|
||||
|
||||
Product codes are **not** client information and stay as they are: design
|
||||
numbers (`PNL1.0`), piece marks (`PNL101`), hardware part numbers
|
||||
(`ANCHOR_FACE_4T`) are naming conventions, not identities.
|
||||
"""
|
||||
|
||||
PLACEHOLDERS = ['sample_client', 'sample_job']
|
||||
|
||||
# Invented, plausible-but-fake "real" model filenames -- what a genuine job
|
||||
# might be named, but naming no actual client, job, or plant.
|
||||
REAL_LOOKING_NAMES = [
|
||||
'northgate_manufacturing_bldg_a' + _RVT,
|
||||
'ridgeline_precast_ph2' + _RFA,
|
||||
]
|
||||
|
||||
# Failure mode 1 from the ticket: the placeholder as a PREFIX/SUBSTRING of a
|
||||
# longer name, not the whole name. These must never be treated as the
|
||||
# placeholder itself.
|
||||
NEAR_MISS_NAMES = [
|
||||
'sample_job_real_client_name' + _RVT,
|
||||
'sample_job_northgate' + _RFA,
|
||||
'the_sample_job' + _RVT,
|
||||
'sample_job2' + _RVT,
|
||||
]
|
||||
|
||||
|
||||
def _write(path, text):
|
||||
with open(path, 'w', encoding='utf-8') as fh:
|
||||
fh.write(text)
|
||||
return str(path)
|
||||
|
||||
|
||||
def _git(repo, *args):
|
||||
subprocess.run(['git'] + list(args), cwd=str(repo), check=True,
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def git_repo(tmp_path):
|
||||
"""An empty, isolated git repo -- never the real one -- for history
|
||||
tests. `scan_history` shells out to git with `cwd=scrub.ROOT`, so
|
||||
pointing `scrub.ROOT` here (see tests below) is enough to sandbox it."""
|
||||
repo = tmp_path / 'repo'
|
||||
repo.mkdir()
|
||||
_git(repo, 'init', '-q')
|
||||
# Assembled, not written whole, so this fixture email does not itself
|
||||
# read as an email-shaped structural hit in this file's own source.
|
||||
_git(repo, 'config', 'user.email', 'scrub-test' + '@' + 'example.invalid')
|
||||
_git(repo, 'config', 'user.name', 'Test')
|
||||
return repo
|
||||
|
||||
|
||||
def _pats():
|
||||
return scrub.patterns([])
|
||||
|
||||
|
||||
# --------------------------------------------------------- load_placeholders
|
||||
|
||||
def test_load_placeholders_reads_the_real_source_of_truth():
|
||||
"""The actual source of truth, read end to end: both documented
|
||||
placeholders come back, and nothing else does.
|
||||
|
||||
No path is passed -- this exercises load_placeholders' own real
|
||||
CLAUDE.md -> CONTRIBUTING.md fallback (see its docstring), not one
|
||||
hardcoded file. In the owner's own checkout that reads CLAUDE.md; in a
|
||||
knowledge-less distribution (tools/make_dist.py), CLAUDE.md does not
|
||||
ship and this reads CONTRIBUTING.md instead, which carries the
|
||||
identical heading and table verbatim -- so this test passes unchanged
|
||||
in both trees, exactly the property it is meant to prove."""
|
||||
names = scrub.load_placeholders()
|
||||
assert 'sample_client' in names
|
||||
assert 'sample_job' in names
|
||||
|
||||
|
||||
def test_load_placeholders_from_table(tmp_path):
|
||||
path = _write(tmp_path / 'CLAUDE.md', FAKE_CLAUDE_MD)
|
||||
assert scrub.load_placeholders(path) == ['sample_client', 'sample_job']
|
||||
|
||||
|
||||
def test_load_placeholders_excludes_product_code_examples(tmp_path):
|
||||
"""`PNL1.0` / `PNL101` / `ANCHOR_FACE_4T` sit in prose, not a table row,
|
||||
two paragraphs after the table -- and CLAUDE.md says outright they
|
||||
'stay as they are'. They must never come back as an exemption."""
|
||||
path = _write(tmp_path / 'CLAUDE.md', FAKE_CLAUDE_MD)
|
||||
names = scrub.load_placeholders(path)
|
||||
assert 'pnl1.0' not in names
|
||||
assert 'pnl101' not in names
|
||||
assert 'anchor_face_4t' not in names
|
||||
|
||||
|
||||
def test_load_placeholders_missing_heading_is_empty(tmp_path):
|
||||
path = _write(tmp_path / 'CLAUDE.md', '# unrelated file, no such rule\n')
|
||||
assert scrub.load_placeholders(path) == []
|
||||
|
||||
|
||||
def test_load_placeholders_missing_file_is_empty(tmp_path):
|
||||
assert scrub.load_placeholders(str(tmp_path / 'does-not-exist.md')) == []
|
||||
|
||||
|
||||
# ----------------------------------------------------- _is_placeholder_filename
|
||||
|
||||
@pytest.mark.parametrize('name', [
|
||||
'sample_job' + _RVT, 'sample_job' + _RFA,
|
||||
'sample_client' + _RVT, 'sample_client' + _RFA,
|
||||
'SAMPLE_JOB' + _RVT.upper(), 'Sample_Job' + _RVT.title(),
|
||||
])
|
||||
def test_placeholder_filename_is_exempt(name):
|
||||
assert scrub._is_placeholder_filename(name, PLACEHOLDERS) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', NEAR_MISS_NAMES + REAL_LOOKING_NAMES)
|
||||
def test_placeholder_filename_is_not_exempt(name):
|
||||
# Failure mode 1 lives in NEAR_MISS_NAMES: the placeholder as a
|
||||
# substring of a longer name, not the whole name -- the exact shape
|
||||
# that must keep failing.
|
||||
assert scrub._is_placeholder_filename(name, PLACEHOLDERS) is False
|
||||
|
||||
|
||||
def test_no_placeholders_loaded_exempts_nothing():
|
||||
"""If CLAUDE.md can't be read, the gate must fail SAFE -- stricter, not
|
||||
looser -- so the placeholder itself is still flagged."""
|
||||
assert scrub._is_placeholder_filename('sample_job' + _RVT, []) is False
|
||||
assert scrub._is_placeholder_filename('sample_job' + _RVT, None) is False
|
||||
|
||||
|
||||
# ------------------------------------------------------------- scan_worktree
|
||||
|
||||
def test_scan_worktree_placeholder_passes(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(tmp_path))
|
||||
_write(tmp_path / 'fixture.py',
|
||||
"MODEL_PATH = '{0}'\n".format('sample_job' + _RVT))
|
||||
hits = scrub.scan_worktree(_pats(), ['fixture.py'], PLACEHOLDERS)
|
||||
assert hits == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', REAL_LOOKING_NAMES)
|
||||
def test_scan_worktree_real_looking_name_still_fails(tmp_path, monkeypatch, name):
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(tmp_path))
|
||||
_write(tmp_path / 'fixture.py', "MODEL_PATH = '{0}'\n".format(name))
|
||||
hits = scrub.scan_worktree(_pats(), ['fixture.py'], PLACEHOLDERS)
|
||||
assert len(hits) == 1
|
||||
assert 'filename' in hits[0]
|
||||
|
||||
|
||||
def test_scan_worktree_near_miss_still_fails(tmp_path, monkeypatch):
|
||||
"""The too-broad failure mode from the ticket, run through the real
|
||||
scanning path: a placeholder-prefixed name that is not the placeholder
|
||||
must still be a hit."""
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(tmp_path))
|
||||
_write(tmp_path / 'fixture.py',
|
||||
"MODEL_PATH = '{0}'\n".format(NEAR_MISS_NAMES[0]))
|
||||
hits = scrub.scan_worktree(_pats(), ['fixture.py'], PLACEHOLDERS)
|
||||
assert len(hits) == 1
|
||||
|
||||
|
||||
def test_scan_worktree_untracked_placeholder_passes(tmp_path, monkeypatch):
|
||||
"""The exemption applies the same way whether the caller is scanning a
|
||||
tracked list or the untracked one -- `scan_worktree` does not know or
|
||||
care which; both `--worktree` and `--staged`'s loose-file pass go
|
||||
through it."""
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(tmp_path))
|
||||
_write(tmp_path / 'scratch.py',
|
||||
"m = '{0}'\n".format('sample_job' + _RVT))
|
||||
hits = scrub.scan_worktree(_pats(), ['scratch.py'], PLACEHOLDERS)
|
||||
assert hits == []
|
||||
|
||||
|
||||
# -------------------------------------------------------------- scan_history
|
||||
|
||||
def test_scan_history_placeholder_passes(git_repo, monkeypatch):
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(git_repo))
|
||||
_write(git_repo / 'fixture.py',
|
||||
"MODEL_PATH = '{0}'\n".format('sample_job' + _RVT))
|
||||
_git(git_repo, 'add', 'fixture.py')
|
||||
_git(git_repo, 'commit', '-q', '-m', 'add fixture')
|
||||
hits = scrub.scan_history(_pats(), PLACEHOLDERS)
|
||||
assert hits == []
|
||||
|
||||
|
||||
def test_scan_history_real_looking_name_still_fails(git_repo, monkeypatch):
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(git_repo))
|
||||
_write(git_repo / 'fixture.py',
|
||||
"MODEL_PATH = '{0}'\n".format(REAL_LOOKING_NAMES[0]))
|
||||
_git(git_repo, 'add', 'fixture.py')
|
||||
_git(git_repo, 'commit', '-q', '-m', 'add fixture')
|
||||
hits = scrub.scan_history(_pats(), PLACEHOLDERS)
|
||||
assert len(hits) == 1
|
||||
|
||||
|
||||
def test_scan_history_near_miss_still_fails(git_repo, monkeypatch):
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(git_repo))
|
||||
_write(git_repo / 'fixture.py',
|
||||
"MODEL_PATH = '{0}'\n".format(NEAR_MISS_NAMES[0]))
|
||||
_git(git_repo, 'add', 'fixture.py')
|
||||
_git(git_repo, 'commit', '-q', '-m', 'add fixture')
|
||||
hits = scrub.scan_history(_pats(), PLACEHOLDERS)
|
||||
assert len(hits) == 1
|
||||
|
||||
|
||||
def test_scan_history_no_placeholders_fails_safe(git_repo, monkeypatch):
|
||||
"""The same fail-safe direction as `_is_placeholder_filename`: if
|
||||
`placeholders` is empty, history scanning flags the placeholder name
|
||||
too rather than silently exempting it on an unproven assumption."""
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(git_repo))
|
||||
_write(git_repo / 'fixture.py',
|
||||
"MODEL_PATH = '{0}'\n".format('sample_job' + _RVT))
|
||||
_git(git_repo, 'add', 'fixture.py')
|
||||
_git(git_repo, 'commit', '-q', '-m', 'add fixture')
|
||||
hits = scrub.scan_history(_pats(), [])
|
||||
assert len(hits) == 1
|
||||
|
||||
|
||||
# ------------------------------------------------------- term boundary (\b hole)
|
||||
|
||||
# A fake stand-in for the real leak found in tracked files today: `\b` does
|
||||
# not sit between a letter and an underscore (both are "word" characters to
|
||||
# `re`), so `\bTerm\b` never matched a term glued to a role identifier or a
|
||||
# piece mark, e.g. TERM_EMBED_P76. "Northgate" here is invented for this
|
||||
# test and is not a real client, job, or plant name.
|
||||
FAKE_TERM = 'Northgate'
|
||||
|
||||
|
||||
def test_term_pattern_catches_underscore_glued_hit():
|
||||
pat, why = scrub.patterns([(FAKE_TERM, False)])[-1]
|
||||
assert pat.search('ref = ' + FAKE_TERM.upper() + '_EMBED_P76') is not None
|
||||
|
||||
|
||||
def test_term_pattern_catches_digit_glued_hit():
|
||||
pat, why = scrub.patterns([(FAKE_TERM, False)])[-1]
|
||||
assert pat.search('model = ' + FAKE_TERM + '2026') is not None
|
||||
|
||||
|
||||
def test_term_pattern_still_excludes_longer_word():
|
||||
"""The other half of the same rule: a term that is a substring of an
|
||||
ordinary longer word -- letters on both sides -- must not fire, or
|
||||
every term becomes a landmine for unrelated vocabulary."""
|
||||
pat, why = scrub.patterns([(FAKE_TERM, False)])[-1]
|
||||
assert pat.search(FAKE_TERM + 'way Logistics Inc') is None
|
||||
assert pat.search('pre' + FAKE_TERM + 'Setup') is None
|
||||
|
||||
|
||||
def test_term_pattern_still_matches_plain_word_boundary():
|
||||
"""The ordinary case `\\b` already handled -- whitespace either side --
|
||||
must keep working; this is a widening of the boundary, not a swap to
|
||||
something unrelated."""
|
||||
pat, why = scrub.patterns([(FAKE_TERM, False)])[-1]
|
||||
assert pat.search('the ' + FAKE_TERM + ' project') is not None
|
||||
|
||||
|
||||
def test_scan_worktree_catches_underscore_glued_term(tmp_path, monkeypatch):
|
||||
"""The hole, exercised through the real scanning path a commit would
|
||||
actually go through, not just the compiled pattern in isolation."""
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(tmp_path))
|
||||
_write(tmp_path / 'fixture.py',
|
||||
"REF = '{0}'\n".format(FAKE_TERM.upper() + '_EMBED_P76'))
|
||||
pats = scrub.patterns([(FAKE_TERM, False)])
|
||||
hits = scrub.scan_worktree(pats, ['fixture.py'], [])
|
||||
assert len(hits) == 1
|
||||
assert FAKE_TERM.lower() in hits[0].lower()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- main
|
||||
|
||||
def test_main_worktree_end_to_end(git_repo, monkeypatch):
|
||||
"""The whole CLI path, `--worktree`, wired together: placeholder in a
|
||||
tracked file is clean; a real-looking name added afterwards (untracked,
|
||||
since `--worktree` must catch those too) fails."""
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(git_repo))
|
||||
monkeypatch.setattr(scrub, 'CLAUDE_MD', str(git_repo / 'CLAUDE.md'))
|
||||
_write(git_repo / 'CLAUDE.md', FAKE_CLAUDE_MD)
|
||||
terms_path = _write(git_repo / 'empty-terms.txt', '')
|
||||
_write(git_repo / 'fixture.py',
|
||||
"MODEL_PATH = '{0}'\n".format('sample_job' + _RVT))
|
||||
_git(git_repo, 'add', '-A')
|
||||
|
||||
monkeypatch.setattr(sys, 'argv',
|
||||
['scrub.py', '--worktree', '--terms', terms_path])
|
||||
assert scrub.main() == 0
|
||||
|
||||
_write(git_repo / 'other.py',
|
||||
"MODEL_PATH = '{0}'\n".format(REAL_LOOKING_NAMES[0]))
|
||||
assert scrub.main() == 1
|
||||
|
||||
|
||||
# ------------------------------------------------------ --private / the gitignored tree
|
||||
|
||||
# `QNA/`, `knowledge/plant/` and `knowledge/job/` are gitignored, so every
|
||||
# mode above is blind to them by construction. `--private` is the mode that
|
||||
# looks, and what is worth testing about it is not "does it find names" --
|
||||
# in that tree it finds thousands, and that is correct -- but the two things
|
||||
# that keep it from being a wall of expected hits nobody reads:
|
||||
#
|
||||
# * the resident/travelling split. A name in a sentence is the file doing
|
||||
# its job. The same name inside a fence, a quoted literal, a proposed
|
||||
# `key: value`, a path, or glued into a longer identifier is a string
|
||||
# shaped to be copied into the public repo, and that is what gets listed.
|
||||
# * aggregation by the distinct string, so one identifier mentioned four
|
||||
# hundred times is one row.
|
||||
#
|
||||
# And one thing that must NOT have changed: `--staged` still blocks on staged
|
||||
# files only. A gate that starts refusing commits over a note in the private
|
||||
# repo is a gate that gets passed with --no-verify, and then it guards
|
||||
# nothing -- so there is a test below pinning that, right next to the new
|
||||
# code that could plausibly have broken it.
|
||||
|
||||
_FAKE_PLANT = 'Ridgeline' # invented; identifies no real plant
|
||||
|
||||
|
||||
|
||||
def _plant_pats():
|
||||
"""Just the fake plant term, built through `patterns()` so these tests
|
||||
exercise the real boundary logic; the structural patterns are dropped so
|
||||
a fixture cannot accidentally pass by matching one of those instead."""
|
||||
return scrub.patterns([(_FAKE_PLANT, False)])[len(scrub.STRUCTURAL):]
|
||||
|
||||
|
||||
def _travels(line, kind='doc', why="term 'x'", in_fence=False):
|
||||
"""`_travels` for the first occurrence of the fake plant name on `line`."""
|
||||
return scrub._travels(kind, line, line.lower().index(_FAKE_PLANT.lower()),
|
||||
why, in_fence)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- private_files
|
||||
|
||||
def test_private_files_drops_dot_segments(tmp_path, monkeypatch):
|
||||
"""A browser profile cache under QNA/ is 58,000 files on its own. Dot
|
||||
names in a working tree are tool state, not documents, and reading them
|
||||
would bury the report they are supposed to be part of."""
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(tmp_path))
|
||||
_write(tmp_path / 'note.md', 'x\n')
|
||||
keep, dropped = scrub.private_files(
|
||||
['note.md', '.cache/a.md', 'QNA/.agent-home/profiles/b.md'])
|
||||
assert keep == ['note.md']
|
||||
assert dropped['tool state'] == 2
|
||||
|
||||
|
||||
def test_private_files_drops_build_output(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(tmp_path))
|
||||
_write(tmp_path / 'note.md', 'x\n')
|
||||
keep, dropped = scrub.private_files(
|
||||
['note.md', 'lib/__pycache__/a.py', 'dist/b.py'])
|
||||
assert keep == ['note.md']
|
||||
assert dropped['build output'] == 2
|
||||
|
||||
|
||||
def test_private_files_drops_unreadable_and_counts_them(tmp_path, monkeypatch):
|
||||
"""A ticket PDF carries the job name in its title block and nothing here
|
||||
can read it. It has to be COUNTED, not passed over: "not scanned" and
|
||||
"scanned, clean" are different answers and a scrub report may not
|
||||
collapse them."""
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(tmp_path))
|
||||
_write(tmp_path / 'note.md', 'x\n')
|
||||
keep, dropped = scrub.private_files(
|
||||
['note.md', 'tickets/a.pdf', 'img/b.png'])
|
||||
assert keep == ['note.md']
|
||||
assert dropped['unreadable'] == 2
|
||||
|
||||
|
||||
def test_private_files_drops_over_size_cap(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(tmp_path))
|
||||
monkeypatch.setattr(scrub, 'PRIVATE_MAX_BYTES', 10)
|
||||
_write(tmp_path / 'small.md', 'x\n')
|
||||
_write(tmp_path / 'big.md', 'x' * 50)
|
||||
keep, dropped = scrub.private_files(['small.md', 'big.md'])
|
||||
assert keep == ['small.md']
|
||||
assert dropped['over size cap'] == 1
|
||||
|
||||
|
||||
def test_private_files_accounts_for_every_input(tmp_path, monkeypatch):
|
||||
"""Kept plus dropped equals what went in. Nothing may fall out of the
|
||||
walk unnoticed -- the count in the report is a coverage claim."""
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(tmp_path))
|
||||
_write(tmp_path / 'note.md', 'x\n')
|
||||
listed = ['note.md', '.cache/a.md', 'dist/b.py', 'c.pdf', 'gone.md']
|
||||
keep, dropped = scrub.private_files(listed)
|
||||
assert len(keep) + sum(dropped.values()) == len(listed)
|
||||
assert dropped['gone'] == 1
|
||||
|
||||
|
||||
def test_ignored_files_returns_what_git_is_ignoring(git_repo, monkeypatch):
|
||||
"""The git plumbing itself: `--others --ignored` is the mirror image of
|
||||
the `--others --exclude-standard` walk the untracked scan uses."""
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(git_repo))
|
||||
_write(git_repo / '.gitignore', 'private/\n')
|
||||
(git_repo / 'private').mkdir()
|
||||
_write(git_repo / 'private' / 'note.md', 'x\n')
|
||||
_write(git_repo / 'open.md', 'x\n')
|
||||
found = [f.replace('\\', '/') for f in scrub.ignored_files()]
|
||||
assert 'private/note.md' in found
|
||||
assert 'open.md' not in found
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ _travels
|
||||
|
||||
def test_prose_mention_in_a_document_is_resident():
|
||||
"""The whole point. A producer's name in a sentence about that producer,
|
||||
in the repository that documents them, is not a finding."""
|
||||
assert _travels('The ' + _FAKE_PLANT + ' manual calls this a soffit.') is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize('line, in_fence, expected', [
|
||||
(' key = "' + _FAKE_PLANT + '"', True, 'in a fenced code block'),
|
||||
('set `' + _FAKE_PLANT + '_THK` on the piece', False, 'in inline code'),
|
||||
('the sheet says "' + _FAKE_PLANT + '" in the corner', False,
|
||||
'in a quoted literal'),
|
||||
('plant_id: ' + _FAKE_PLANT.lower(), False, 'in a key: value line'),
|
||||
('see knowledge/plant/' + _FAKE_PLANT.lower() + '/knowledge.yaml', False,
|
||||
'in a path'),
|
||||
])
|
||||
def test_travelling_shapes_in_a_document(line, in_fence, expected):
|
||||
assert _travels(line, in_fence=in_fence) == expected
|
||||
|
||||
|
||||
def test_prose_colon_line_is_not_mistaken_for_a_yaml_shape():
|
||||
"""`Note: the plant says X` is a sentence, not a proposed key. The key
|
||||
pattern requires a lowercase, space-free key, which is what keeps an
|
||||
ordinary English colon out of the travelling pile."""
|
||||
assert _travels('Note: the ' + _FAKE_PLANT + ' manual disagrees') is None
|
||||
assert _travels('the plant says: ' + _FAKE_PLANT + ' does it this way') is None
|
||||
|
||||
|
||||
def test_data_file_value_is_resident_but_a_comment_travels():
|
||||
"""Values in a plant or job file ARE the file. A comment is prose written
|
||||
for a reader, and it is what gets carried across when the shape of the
|
||||
file is proposed somewhere else."""
|
||||
assert _travels(' plant: ' + _FAKE_PLANT.lower(), kind='data') is None
|
||||
assert _travels(' thk: 4 # ' + _FAKE_PLANT + ' always uses 4',
|
||||
kind='data') == 'in a comment'
|
||||
|
||||
|
||||
def test_code_file_always_travels():
|
||||
"""A private script is all instruction; there is no prose half of it for
|
||||
a name to be resident in."""
|
||||
assert _travels('# a note about ' + _FAKE_PLANT, kind='code') == 'in code'
|
||||
|
||||
|
||||
def test_structural_always_travels_whatever_the_file_is():
|
||||
"""A user path, an address or a model filename names one person, one
|
||||
machine or one job. There is no 'doing its job' reading of it to weigh
|
||||
against travelling, in any kind of file."""
|
||||
for kind in ('doc', 'data', 'code'):
|
||||
assert scrub._travels(kind, 'path = x', 0,
|
||||
'absolute Windows user path', False) \
|
||||
== 'structural'
|
||||
|
||||
|
||||
# -------------------------------------------------------------- scan_private
|
||||
|
||||
def test_scan_private_aggregates_one_string_across_many_places(tmp_path,
|
||||
monkeypatch):
|
||||
"""One identifier mentioned in three files is ONE row with a count, not
|
||||
three lines. This is what keeps the report short enough to be read."""
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(tmp_path))
|
||||
ident = _FAKE_PLANT.upper() + '_EMBED_P76'
|
||||
for name in ('a.md', 'b.md', 'c.md'):
|
||||
_write(tmp_path / name, 'use `' + ident + '` here\n')
|
||||
travelling, _, _ = scrub.scan_private(
|
||||
_plant_pats(), ['a.md', 'b.md', 'c.md'], [])
|
||||
assert list(travelling) == [ident]
|
||||
assert travelling[ident][0] == 3
|
||||
|
||||
|
||||
def test_scan_private_marks_a_name_glued_into_an_identifier(tmp_path,
|
||||
monkeypatch):
|
||||
"""The `glued` flag, which sorts these to the top: a name buried inside a
|
||||
longer token is the one a human reader skims past. A bare backticked
|
||||
mention is still a word somebody can see is a name."""
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(tmp_path))
|
||||
ident = _FAKE_PLANT.upper() + '_EMBED_P76'
|
||||
_write(tmp_path / 'glued.md', 'use `' + ident + '` here\n')
|
||||
_write(tmp_path / 'bare.md', 'use `' + _FAKE_PLANT + '` here\n')
|
||||
travelling, _, _ = scrub.scan_private(
|
||||
_plant_pats(), ['glued.md', 'bare.md'], [])
|
||||
assert travelling[ident][3] is True
|
||||
assert travelling[_FAKE_PLANT][3] is False
|
||||
|
||||
|
||||
def test_scan_private_reports_the_files_own_name_separately(tmp_path,
|
||||
monkeypatch):
|
||||
"""A folder named after the job puts the name in the path of every file
|
||||
under it. Those are aggregated into their own pile and counted -- 380
|
||||
identical lines would drown everything else -- but they are reported,
|
||||
because a tracked file that quotes one of those paths publishes it."""
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(tmp_path))
|
||||
sub = tmp_path / (_FAKE_PLANT.lower() + '_job')
|
||||
sub.mkdir()
|
||||
for name in ('one.md', 'two.md'):
|
||||
_write(sub / name, 'nothing interesting\n')
|
||||
listed = [_FAKE_PLANT.lower() + '_job/one.md',
|
||||
_FAKE_PLANT.lower() + '_job/two.md']
|
||||
travelling, named_dirs, resident = scrub.scan_private(
|
||||
_plant_pats(), listed, [])
|
||||
assert travelling == {}
|
||||
assert resident == {}
|
||||
assert list(named_dirs.values())[0][0] == 2
|
||||
|
||||
|
||||
def test_scan_private_counts_prose_without_listing_it(tmp_path, monkeypatch):
|
||||
"""Resident matches come back as a number per file and never as a line.
|
||||
A wall of expected hits is a report nobody reads twice."""
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(tmp_path))
|
||||
_write(tmp_path / 'note.md',
|
||||
('The ' + _FAKE_PLANT + ' manual says so.\n') * 5)
|
||||
travelling, named_dirs, resident = scrub.scan_private(
|
||||
_plant_pats(), ['note.md'], [])
|
||||
assert travelling == {}
|
||||
assert named_dirs == {}
|
||||
assert resident == {'note.md': 5}
|
||||
|
||||
|
||||
# --------------------------------------------------------------- main --private
|
||||
|
||||
def test_main_private_exits_zero_even_when_it_finds_things(git_repo,
|
||||
monkeypatch, capsys):
|
||||
"""--private is an inventory, not a gate. Every string it prints carries
|
||||
a denylist term or a structural pattern, so a copy of one into a tracked
|
||||
file is already a --worktree failure -- there is no leak here for an exit
|
||||
code to catch that is not caught one directory out. A mode that returned
|
||||
non-zero on the raw material would return non-zero every run, and a check
|
||||
that always fails gets wrapped in `|| true`."""
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(git_repo))
|
||||
monkeypatch.setattr(scrub, 'CLAUDE_MD', str(git_repo / 'CLAUDE.md'))
|
||||
_write(git_repo / 'CLAUDE.md', FAKE_CLAUDE_MD)
|
||||
# Outside the repo on purpose: a terms file that holds the term, staged,
|
||||
# would be a hit on itself and would prove nothing about anything else.
|
||||
terms = _write(git_repo.parent / 'terms.txt', _FAKE_PLANT + '\n')
|
||||
_write(git_repo / '.gitignore', 'private/\n')
|
||||
(git_repo / 'private').mkdir()
|
||||
_write(git_repo / 'private' / 'note.md',
|
||||
'use `' + _FAKE_PLANT.upper() + '_EMBED_P76` here\n')
|
||||
_git(git_repo, 'add', '-A')
|
||||
_git(git_repo, 'commit', '-q', '-m', 'init')
|
||||
|
||||
monkeypatch.setattr(sys, 'argv',
|
||||
['scrub.py', '--private', '--terms', terms])
|
||||
assert scrub.main() == 0
|
||||
out = capsys.readouterr().out
|
||||
assert 'TRAVELLING' in out
|
||||
assert _FAKE_PLANT.upper() + '_EMBED_P76' in out
|
||||
|
||||
|
||||
def test_private_mode_did_not_change_what_staged_blocks_on(git_repo,
|
||||
monkeypatch):
|
||||
"""The rule --private must not have disturbed: the hook blocks on STAGED
|
||||
files and nothing else. A name in the private tree is loud in its own
|
||||
mode and silent here, because a gate that refuses a commit over a note in
|
||||
the other repository is a gate that gets passed with --no-verify."""
|
||||
monkeypatch.setattr(scrub, 'ROOT', str(git_repo))
|
||||
monkeypatch.setattr(scrub, 'CLAUDE_MD', str(git_repo / 'CLAUDE.md'))
|
||||
_write(git_repo / 'CLAUDE.md', FAKE_CLAUDE_MD)
|
||||
# Outside the repo on purpose: a terms file that holds the term, staged,
|
||||
# would be a hit on itself and would prove nothing about anything else.
|
||||
terms = _write(git_repo.parent / 'terms.txt', _FAKE_PLANT + '\n')
|
||||
_write(git_repo / '.gitignore', 'private/\n')
|
||||
(git_repo / 'private').mkdir()
|
||||
_write(git_repo / 'private' / 'note.md',
|
||||
'use `' + _FAKE_PLANT.upper() + '_EMBED_P76` here\n')
|
||||
_write(git_repo / 'clean.py', 'x = 1\n')
|
||||
_git(git_repo, 'add', 'clean.py', '.gitignore', 'CLAUDE.md')
|
||||
|
||||
monkeypatch.setattr(sys, 'argv',
|
||||
['scrub.py', '--staged', '--terms', terms])
|
||||
assert scrub.main() == 0
|
||||
|
||||
_write(git_repo / 'dirty.py', "plant = '{0}'\n".format(_FAKE_PLANT))
|
||||
_git(git_repo, 'add', 'dirty.py')
|
||||
assert scrub.main() == 1
|
||||
@@ -0,0 +1,507 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for tools/setup_machine.py's `.setup-opts` opt-file.
|
||||
|
||||
Loaded the way `test_libindex.py` and `test_snapshot_cycle.py` load their
|
||||
`tools/` modules: put `tools/` on `sys.path` and import it directly. Lives
|
||||
here because this is where every other `tools/*.py` test already lives.
|
||||
|
||||
WHAT THIS FILE DOES NOT COVER
|
||||
------------------------------
|
||||
Nothing here shells out to real `pip`, PowerShell, or the real `panellift`
|
||||
CLI -- those are exercised by hand (see the module's own docstring and
|
||||
machine-setup.md) because they mutate machine-wide state (env vars, the
|
||||
installed package set) that a test suite must not touch. What IS covered is
|
||||
everything `.setup-opts` changes about the DECISIONS this script makes:
|
||||
whether a line is read at all, whether an unrecognised or malformed line is
|
||||
refused, whether the panellift step is skipped instead of run, and how the
|
||||
final VERDICT line counts a skip. `_run` is monkeypatched wherever a real
|
||||
subprocess would otherwise be launched, and `S.OPTS_FILE` is monkeypatched to
|
||||
a path under `tmp_path` so nothing here reads or writes the real repo root's
|
||||
`.setup-opts`.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))))
|
||||
TOOLS = os.path.join(ROOT, 'tools')
|
||||
if TOOLS not in sys.path:
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
S = pytest.importorskip('setup_machine')
|
||||
|
||||
|
||||
def _step():
|
||||
"""A Step that records every line to a list instead of printing it."""
|
||||
out = []
|
||||
step = S.Step(out.append)
|
||||
step.lines = out
|
||||
return step
|
||||
|
||||
|
||||
def _write_opts(path, text):
|
||||
with open(str(path), 'w', encoding='utf-8') as fh:
|
||||
fh.write(text)
|
||||
|
||||
|
||||
def _git(repo, *args):
|
||||
"""Run a real `git` command against a real temp repo. Used instead of a
|
||||
mocked porcelain string for the completeness-guard tests below -- Gitea
|
||||
issue #1's diagnosis turned on exactly which two-column codes a real
|
||||
`git status --porcelain` prints for real filesystem states (an untracked
|
||||
folder vs. a tracked file actually missing from disk), and a hand-typed
|
||||
porcelain string is precisely the kind of guess that could quietly
|
||||
encode the same wrong assumption the bug had."""
|
||||
result = subprocess.run(['git', '-C', str(repo)] + list(args),
|
||||
capture_output=True, text=True, encoding='utf-8')
|
||||
assert result.returncode == 0, (
|
||||
'git {0} failed: {1}'.format(' '.join(args), result.stderr))
|
||||
return result
|
||||
|
||||
|
||||
def _init_repo(repo):
|
||||
repo.mkdir()
|
||||
_git(repo, 'init', '-q', '.')
|
||||
_git(repo, 'config', 'user.email', 'scrub-test' + '@' + 'example.invalid')
|
||||
_git(repo, 'config', 'user.name', 'test')
|
||||
return repo
|
||||
|
||||
|
||||
# --------------------------------------------------------------- load_opts()
|
||||
|
||||
def test_absent_file_returns_no_opts_and_no_failures(tmp_path, monkeypatch):
|
||||
"""No `.setup-opts` at all -- today's default on every machine that has
|
||||
never declared one. Must behave exactly as if the feature did not
|
||||
exist: an empty dict, nothing on `step.failures`."""
|
||||
monkeypatch.setattr(S, 'OPTS_FILE', str(tmp_path / '.setup-opts'))
|
||||
step = _step()
|
||||
opts = S.load_opts(step)
|
||||
assert opts == {}
|
||||
assert step.failures == []
|
||||
assert step.lines == []
|
||||
|
||||
|
||||
def test_dev_mode_declared_is_read(tmp_path, monkeypatch):
|
||||
opts_file = tmp_path / '.setup-opts'
|
||||
_write_opts(opts_file, 'panellift: dev-mode\n')
|
||||
monkeypatch.setattr(S, 'OPTS_FILE', str(opts_file))
|
||||
step = _step()
|
||||
opts = S.load_opts(step)
|
||||
assert opts == {'panellift': 'dev-mode'}
|
||||
assert step.failures == []
|
||||
|
||||
|
||||
def test_comments_and_blank_lines_are_ignored(tmp_path, monkeypatch):
|
||||
opts_file = tmp_path / '.setup-opts'
|
||||
_write_opts(opts_file,
|
||||
'# a comment\n\n \npanellift: dev-mode # trailing note\n')
|
||||
monkeypatch.setattr(S, 'OPTS_FILE', str(opts_file))
|
||||
step = _step()
|
||||
opts = S.load_opts(step)
|
||||
assert opts == {'panellift': 'dev-mode'}
|
||||
assert step.failures == []
|
||||
|
||||
|
||||
def test_stale_entry_is_flagged_not_silently_dropped(tmp_path, monkeypatch):
|
||||
"""A step name this script does not know -- the blindfold-left-behind
|
||||
case. Must be refused loudly (a FAIL step naming it 'stale'), not merged
|
||||
into an empty-looking opts dict that quietly ignores it."""
|
||||
opts_file = tmp_path / '.setup-opts'
|
||||
_write_opts(opts_file, 'snapshot_cycle: dev-mode\n')
|
||||
monkeypatch.setattr(S, 'OPTS_FILE', str(opts_file))
|
||||
step = _step()
|
||||
opts = S.load_opts(step)
|
||||
assert opts == {}
|
||||
assert len(step.failures) == 1
|
||||
assert 'stale' in ' '.join(step.lines).lower()
|
||||
assert 'snapshot_cycle' in ' '.join(step.lines)
|
||||
|
||||
|
||||
def test_unknown_mode_for_a_known_step_is_flagged(tmp_path, monkeypatch):
|
||||
opts_file = tmp_path / '.setup-opts'
|
||||
_write_opts(opts_file, 'panellift: some-other-mode\n')
|
||||
monkeypatch.setattr(S, 'OPTS_FILE', str(opts_file))
|
||||
step = _step()
|
||||
opts = S.load_opts(step)
|
||||
assert opts == {}
|
||||
assert step.failures
|
||||
assert 'some-other-mode' in ' '.join(step.lines)
|
||||
|
||||
|
||||
def test_empty_value_is_refused_like_a_bare_empty_key(tmp_path, monkeypatch):
|
||||
"""`panellift:` with nothing after the colon is ambiguous -- it cannot be
|
||||
told apart from a value that was meant to be written and was not -- so it
|
||||
is refused, the same way a bare empty knowledge key is, rather than
|
||||
treated as an opt-out."""
|
||||
opts_file = tmp_path / '.setup-opts'
|
||||
_write_opts(opts_file, 'panellift:\n')
|
||||
monkeypatch.setattr(S, 'OPTS_FILE', str(opts_file))
|
||||
step = _step()
|
||||
opts = S.load_opts(step)
|
||||
assert opts == {}
|
||||
assert step.failures
|
||||
assert 'no value' in ' '.join(step.lines).lower()
|
||||
|
||||
|
||||
def test_unreadable_line_is_refused(tmp_path, monkeypatch):
|
||||
opts_file = tmp_path / '.setup-opts'
|
||||
_write_opts(opts_file, 'this is not a key value line\n')
|
||||
monkeypatch.setattr(S, 'OPTS_FILE', str(opts_file))
|
||||
step = _step()
|
||||
opts = S.load_opts(step)
|
||||
assert opts == {}
|
||||
assert step.failures
|
||||
|
||||
|
||||
# ----------------------------------------------------------- verify_panellift
|
||||
|
||||
def test_verify_panellift_skips_in_dev_mode_and_names_the_file(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(S, 'OPTS_FILE', str(tmp_path / '.setup-opts'))
|
||||
step = _step()
|
||||
ok = S.verify_panellift(step, str(tmp_path), {'panellift': 'dev-mode'})
|
||||
assert ok is True
|
||||
assert step.skipped == ['panellift CLI runs installed (no PYTHONPATH)']
|
||||
assert step.failures == []
|
||||
assert step.passed == []
|
||||
joined = ' '.join(step.lines)
|
||||
assert 'SKIP' in joined
|
||||
assert '.setup-opts' in joined
|
||||
assert 'dev mode' in joined.lower()
|
||||
|
||||
|
||||
def test_verify_panellift_with_no_opts_runs_the_real_check_unchanged(monkeypatch, tmp_path):
|
||||
"""opts={} (equivalently opts=None, i.e. no `.setup-opts` at all) must
|
||||
take the ORIGINAL code path -- the installed-CLI subprocess is actually
|
||||
invoked, and a failure there is reported as FAIL under the same step
|
||||
name as before this feature existed, never as SKIP."""
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
calls.append(cmd)
|
||||
class R(object):
|
||||
returncode = 1
|
||||
stdout = ''
|
||||
stderr = "No module named panellift"
|
||||
return R()
|
||||
|
||||
monkeypatch.setattr(S, '_run', fake_run)
|
||||
step = _step()
|
||||
ok = S.verify_panellift(step, str(tmp_path), {})
|
||||
assert ok is False
|
||||
assert step.skipped == []
|
||||
assert step.failures == ['panellift CLI runs installed (no PYTHONPATH)']
|
||||
assert len(calls) == 1 # the real CLI invocation was attempted
|
||||
|
||||
|
||||
def test_verify_panellift_default_opts_argument_behaves_like_absent_file(monkeypatch, tmp_path):
|
||||
"""Calling verify_panellift with no `opts` argument at all (the old
|
||||
call shape, before this feature existed) must still work -- a caller
|
||||
that never learned about `.setup-opts` is not broken by it."""
|
||||
def fake_run(cmd, **kw):
|
||||
class R(object):
|
||||
returncode = 1
|
||||
stdout = ''
|
||||
stderr = "No module named panellift"
|
||||
return R()
|
||||
|
||||
monkeypatch.setattr(S, '_run', fake_run)
|
||||
step = _step()
|
||||
ok = S.verify_panellift(step, str(tmp_path))
|
||||
assert ok is False
|
||||
assert step.skipped == []
|
||||
assert step.failures == ['panellift CLI runs installed (no PYTHONPATH)']
|
||||
|
||||
|
||||
# --------------------------------------------------------- install_dependencies
|
||||
|
||||
def test_install_dependencies_dev_mode_skips_pip_install_e_and_verifies_pythonpath(
|
||||
monkeypatch, tmp_path):
|
||||
"""With `panellift: dev-mode`, the full (non---check) run must not call
|
||||
`pip install -e panellift`, and must instead run the PYTHONPATH route and
|
||||
say which route it verified."""
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
calls.append((cmd, kw))
|
||||
class R(object):
|
||||
returncode = 0
|
||||
stdout = ''
|
||||
stderr = ''
|
||||
return R()
|
||||
|
||||
# write_requirements() only writes if requirements.txt is missing; the
|
||||
# real one exists in this checkout, so no filesystem write happens here.
|
||||
monkeypatch.setattr(S, '_run', fake_run)
|
||||
step = _step()
|
||||
ok = S.install_dependencies(step, {'panellift': 'dev-mode'})
|
||||
assert ok is True
|
||||
assert step.failures == []
|
||||
|
||||
# Exactly two subprocess calls: `pip install -r requirements.txt`, then
|
||||
# the PYTHONPATH=src probe. `pip install -e panellift` never happens.
|
||||
assert len(calls) == 2
|
||||
first_cmd, _ = calls[0]
|
||||
assert 'requirements.txt' in ' '.join(first_cmd)
|
||||
second_cmd, second_kw = calls[1]
|
||||
assert '-e' not in second_cmd
|
||||
assert 'panellift' in second_cmd
|
||||
assert '--help' in second_cmd
|
||||
env = second_kw.get('env') or {}
|
||||
assert 'PYTHONPATH' in env
|
||||
assert env['PYTHONPATH'].endswith(os.path.join('panellift', 'src'))
|
||||
|
||||
joined = ' '.join(step.lines)
|
||||
assert 'PYTHONPATH=src' in joined or 'PYTHONPATH=' in joined
|
||||
assert 'dev mode' in joined.lower()
|
||||
|
||||
|
||||
def test_install_dependencies_no_opts_still_installs_panellift_editable(
|
||||
monkeypatch, tmp_path):
|
||||
"""opts={} must reach the original `pip install -e panellift` call --
|
||||
unchanged behaviour when nothing has been declared."""
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
calls.append(cmd)
|
||||
class R(object):
|
||||
returncode = 0
|
||||
stdout = ''
|
||||
stderr = ''
|
||||
return R()
|
||||
|
||||
monkeypatch.setattr(S, '_run', fake_run)
|
||||
step = _step()
|
||||
ok = S.install_dependencies(step, {})
|
||||
assert ok is True
|
||||
assert len(calls) == 2
|
||||
assert '-e' in calls[1]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ _verdict
|
||||
|
||||
def test_verdict_pass_with_no_skips_has_old_unadorned_shape():
|
||||
step = _step()
|
||||
step.passed = ['a', 'b', 'c']
|
||||
code, lines = S._verdict(step)
|
||||
assert code == 0
|
||||
assert len(lines) == 1
|
||||
assert lines[0].startswith('VERDICT: PASS -- 3 passed --')
|
||||
assert 'skipped' not in lines[0]
|
||||
|
||||
|
||||
def test_verdict_pass_with_skips_counts_them_in_the_summary():
|
||||
step = _step()
|
||||
step.passed = ['a', 'b', 'c', 'd', 'e']
|
||||
step.skipped = ['panellift CLI runs installed (no PYTHONPATH)']
|
||||
code, lines = S._verdict(step)
|
||||
assert code == 0
|
||||
assert lines[0] == ('VERDICT: PASS -- 5 passed, 1 skipped (see SKIP '
|
||||
'lines above for why each one) -- everything above '
|
||||
'is a real, checked answer, not just an exit code.')
|
||||
|
||||
|
||||
def test_verdict_fail_still_fails_even_with_a_skip_present():
|
||||
"""A skip is never allowed to launder a real failure into a pass -- the
|
||||
whole point of SKIP as a third state is that it is honest about doing
|
||||
nothing, not that it forgives something else that broke."""
|
||||
step = _step()
|
||||
step.passed = ['a']
|
||||
step.skipped = ['panellift CLI runs installed (no PYTHONPATH)']
|
||||
step.failures = ['knowledgelint.py']
|
||||
code, lines = S._verdict(step)
|
||||
assert code == 1
|
||||
assert lines[0].startswith('VERDICT: FAIL')
|
||||
assert 'knowledgelint.py' in lines[0]
|
||||
# the passed/skipped counts are still visible even though it failed
|
||||
assert any('1 skipped' in line for line in lines)
|
||||
|
||||
|
||||
def test_verdict_fail_with_no_skips_has_old_shape():
|
||||
step = _step()
|
||||
step.passed = ['a']
|
||||
step.failures = ['knowledgelint.py']
|
||||
code, lines = S._verdict(step)
|
||||
assert code == 1
|
||||
assert not any('skipped' in line for line in lines)
|
||||
|
||||
|
||||
# ------------------------------------------- _parse_porcelain_status (Gitea #1)
|
||||
#
|
||||
# Gitea issue #1: `resolve_private_repo`'s completeness guard failed on ANY
|
||||
# non-empty `git status --porcelain` output, but the truncation it exists to
|
||||
# catch (the long-path `sessions/` failure, machine-setup.md sec.2) only ever
|
||||
# shows up as a TRACKED file missing from the worktree -- porcelain worktree
|
||||
# status `D`. An untracked addition (`??`) or a locally modified tracked file
|
||||
# (`M`) cannot mean that, and used to false-FAIL anyway, with a
|
||||
# `restore --source=HEAD` hint that is a no-op for untracked paths.
|
||||
|
||||
def test_parse_porcelain_classifies_untracked_as_untracked_not_missing():
|
||||
missing, untracked, other = S._parse_porcelain_status('?? sample_job/\n')
|
||||
assert missing == []
|
||||
assert untracked == ['sample_job/']
|
||||
assert other == []
|
||||
|
||||
|
||||
def test_parse_porcelain_treats_worktree_D_as_missing_tracked():
|
||||
"""The truncation signature itself: index still names the file (X is
|
||||
one of ' ', M, A, R, C -- whatever staged state), but Y='D' means it is
|
||||
not actually on disk."""
|
||||
for code in (' D', 'MD', 'AD', 'RD', 'CD'):
|
||||
missing, untracked, other = S._parse_porcelain_status(
|
||||
'{0} plant/foo.yaml\n'.format(code))
|
||||
assert missing == ['plant/foo.yaml'], 'code {0!r} should be missing-tracked'.format(code)
|
||||
assert untracked == []
|
||||
assert other == []
|
||||
|
||||
|
||||
def test_parse_porcelain_staged_deletion_is_not_missing_tracked():
|
||||
"""'D ' (X='D', Y=' ') is a DELIBERATE staged deletion (`git rm`, or
|
||||
`git add` after deleting by hand) -- index and worktree already agree
|
||||
the file is gone on purpose. This must not be confused with 'D' in the
|
||||
worktree column, which means the opposite: an accident."""
|
||||
missing, untracked, other = S._parse_porcelain_status('D plant/foo.yaml\n')
|
||||
assert missing == []
|
||||
assert other == ['plant/foo.yaml']
|
||||
|
||||
|
||||
def test_parse_porcelain_ordinary_modifications_are_other_not_missing():
|
||||
for code in ('M ', ' M', 'MM', 'A ', 'R ', 'C '):
|
||||
missing, untracked, other = S._parse_porcelain_status(
|
||||
'{0} plant/foo.yaml\n'.format(code))
|
||||
assert missing == [], 'code {0!r} must not be treated as truncation'.format(code)
|
||||
|
||||
|
||||
def test_parse_porcelain_rename_reports_destination_path():
|
||||
missing, untracked, other = S._parse_porcelain_status(
|
||||
'R old/name.yaml -> new/name.yaml\n')
|
||||
assert other == ['new/name.yaml']
|
||||
missing, untracked, other = S._parse_porcelain_status(
|
||||
'RD old/name.yaml -> new/name.yaml\n')
|
||||
assert missing == ['new/name.yaml']
|
||||
|
||||
|
||||
def test_parse_porcelain_conflict_codes_are_not_guessed_at_as_missing():
|
||||
"""Merge-conflict markers can't arise on the plain clone/status path
|
||||
this guard runs on (no merge in progress); they are filed as 'other'
|
||||
rather than treated as truncation evidence."""
|
||||
for code in ('UU', 'AA', 'DD', 'AU', 'UD', 'UA', 'DU'):
|
||||
missing, untracked, other = S._parse_porcelain_status(
|
||||
'{0} plant/foo.yaml\n'.format(code))
|
||||
assert missing == [], 'conflict code {0!r} must not fail the guard'.format(code)
|
||||
|
||||
|
||||
def test_parse_porcelain_empty_output_is_all_empty():
|
||||
missing, untracked, other = S._parse_porcelain_status('')
|
||||
assert (missing, untracked, other) == ([], [], [])
|
||||
|
||||
|
||||
# --------------------------------------------- resolve_private_repo (Gitea #1)
|
||||
#
|
||||
# These use real temp git repos (not mocked porcelain strings) so the
|
||||
# completeness guard is exercised against what `git status --porcelain`
|
||||
# actually prints for real filesystem states, per the issue's own
|
||||
# instruction not to trust a hand-typed porcelain string here.
|
||||
|
||||
def test_untracked_job_folder_checkout_passes(tmp_path):
|
||||
"""The issue's exact false-FAIL scenario: a complete checkout with one
|
||||
untracked job folder (named `sample_job`, never a real client name --
|
||||
see CLAUDE.md's scrub rule) must PASS, not FAIL."""
|
||||
repo = _init_repo(tmp_path / 'private')
|
||||
(repo / 'plant').mkdir()
|
||||
(repo / 'plant' / 'knowledge.yaml').write_text('a: 1\n', encoding='utf-8')
|
||||
_git(repo, 'add', '.')
|
||||
_git(repo, 'commit', '-q', '-m', 'init')
|
||||
|
||||
(repo / 'sample_job').mkdir()
|
||||
(repo / 'sample_job' / 'notes.txt').write_text('scratch work\n', encoding='utf-8')
|
||||
|
||||
step = _step()
|
||||
result = S.resolve_private_repo(step, str(repo))
|
||||
assert result == str(repo)
|
||||
assert step.failures == []
|
||||
joined = ' '.join(step.lines)
|
||||
assert '1 untracked' in joined
|
||||
assert "installer's business" in joined
|
||||
|
||||
|
||||
def test_missing_tracked_file_checkout_fails_with_restore_hint(tmp_path):
|
||||
"""The real truncation signature: a committed file that never made it
|
||||
onto disk (simulating the long-path checkout failure). Must FAIL, and
|
||||
the fix hint's `restore --source=HEAD` genuinely applies here."""
|
||||
repo = _init_repo(tmp_path / 'private')
|
||||
(repo / 'plant').mkdir()
|
||||
(repo / 'plant' / 'knowledge.yaml').write_text('a: 1\n', encoding='utf-8')
|
||||
_git(repo, 'add', '.')
|
||||
_git(repo, 'commit', '-q', '-m', 'init')
|
||||
|
||||
os.remove(str(repo / 'plant' / 'knowledge.yaml'))
|
||||
|
||||
step = _step()
|
||||
result = S.resolve_private_repo(step, str(repo))
|
||||
assert result is None
|
||||
assert step.failures == ['private repo checkout is complete']
|
||||
joined = ' '.join(step.lines)
|
||||
assert 'tracked path(s) missing from the working tree' in joined
|
||||
assert 'restore --source=HEAD :/' in joined
|
||||
|
||||
|
||||
def test_modified_only_checkout_passes_with_informational_note(tmp_path):
|
||||
"""A locally modified tracked file is ordinary uncommitted work -- it
|
||||
cannot indicate a truncated checkout (the file IS present, just
|
||||
edited), and must PASS, with the modification only noted, not acted on."""
|
||||
repo = _init_repo(tmp_path / 'private')
|
||||
(repo / 'plant').mkdir()
|
||||
(repo / 'plant' / 'knowledge.yaml').write_text('a: 1\n', encoding='utf-8')
|
||||
_git(repo, 'add', '.')
|
||||
_git(repo, 'commit', '-q', '-m', 'init')
|
||||
|
||||
(repo / 'plant' / 'knowledge.yaml').write_text('a: 2\n', encoding='utf-8')
|
||||
|
||||
step = _step()
|
||||
result = S.resolve_private_repo(step, str(repo))
|
||||
assert result == str(repo)
|
||||
assert step.failures == []
|
||||
joined = ' '.join(step.lines)
|
||||
assert '1 modified' in joined
|
||||
assert "installer's business" in joined
|
||||
assert 'restore --source=HEAD' not in joined
|
||||
|
||||
|
||||
def test_cascade_untracked_job_folder_passes_wiring_end_to_end(tmp_path, monkeypatch):
|
||||
"""The issue's reported cascade: a false FAIL on the completeness guard
|
||||
used to skip wiring entirely (plant layer stays empty, knowledgelint
|
||||
reports 1 layer). With the guard fixed, resolving a checkout that has
|
||||
only an untracked job folder must let `wire_links` actually run and
|
||||
succeed -- proving the fix, not just the guard's own return value."""
|
||||
private = _init_repo(tmp_path / 'KNOWLEDGE')
|
||||
for name in ('plant', 'job', 'library', 'qna'):
|
||||
d = private / name
|
||||
d.mkdir()
|
||||
(d / 'README.md').write_text('placeholder for ' + name, encoding='utf-8')
|
||||
_git(private, 'add', '.')
|
||||
_git(private, 'commit', '-q', '-m', 'init')
|
||||
|
||||
(private / 'sample_job').mkdir()
|
||||
(private / 'sample_job' / 'notes.txt').write_text('scratch\n', encoding='utf-8')
|
||||
|
||||
step = _step()
|
||||
resolved = S.resolve_private_repo(step, str(private))
|
||||
assert resolved == str(private)
|
||||
assert step.failures == []
|
||||
|
||||
fake_root = tmp_path / 'checkout'
|
||||
(fake_root / 'knowledge').mkdir(parents=True)
|
||||
monkeypatch.setattr(S, 'ROOT', str(fake_root))
|
||||
|
||||
wire_step = _step()
|
||||
S.wire_links(wire_step, resolved)
|
||||
assert wire_step.failures == []
|
||||
|
||||
plant_local = fake_root / 'knowledge' / 'plant'
|
||||
assert plant_local.is_dir()
|
||||
assert (plant_local / 'README.md').read_text(encoding='utf-8') == 'placeholder for plant'
|
||||
job_local = fake_root / 'knowledge' / 'job'
|
||||
assert (job_local / 'README.md').read_text(encoding='utf-8') == 'placeholder for job'
|
||||
@@ -0,0 +1,792 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for `tools/snapshot_cycle.py` -- everything that needs no Revit.
|
||||
|
||||
The pipeline's five steps split into two kinds of code. One kind launches and
|
||||
kills processes and cannot be tested here at all. The other kind DECIDES:
|
||||
which pid may be closed, what a `/status` answer means, which port is the
|
||||
sandbox, what the settings resolve to, what the request file says. Those are
|
||||
pure functions with their inputs injected, and they are the ones where a
|
||||
mistake is invisible until it is expensive -- `may_kill` most of all, because
|
||||
the cost of getting it wrong is the owner's Revit session.
|
||||
|
||||
Loaded by path like `test_snapshot_defer.py`: host CPython, in a repository
|
||||
whose extension is IronPython 2.7.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))))
|
||||
TOOLS = os.path.join(ROOT, 'tools')
|
||||
if TOOLS not in sys.path:
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
from vibe import snapshotmeta as S # noqa: E402 (registers the vibe stub)
|
||||
|
||||
snapshot_cycle = pytest.importorskip('snapshot_cycle')
|
||||
C = snapshot_cycle
|
||||
|
||||
LIVE = 'sample_job'
|
||||
SNAP_ROOT = os.path.join('C:', os.sep, 'snapshots')
|
||||
SNAP_FILE = os.path.join(SNAP_ROOT, LIVE + '_20260901-0930',
|
||||
LIVE + S.MODEL_EXT)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_ambient_job_env(monkeypatch):
|
||||
"""No test here may depend on which job folders the machine running it
|
||||
happens to have under a real WILLTOOLS_JOBS -- this checkout's own
|
||||
`knowledge/job/` holds three on the machine this suite was written on,
|
||||
which is exactly the "ambiguous" case `resolve_job_layer` is meant to
|
||||
refuse on, and a test suite that refuses depending on who runs it is not
|
||||
a test suite. Every test that wants WILLTOOLS_JOBS or
|
||||
WILLTOOLS_JOB_KNOWLEDGE set states so itself, on top of this."""
|
||||
monkeypatch.delenv(C.JL.JOBS_ENV, raising=False)
|
||||
monkeypatch.delenv('WILLTOOLS_JOB_KNOWLEDGE', raising=False)
|
||||
|
||||
|
||||
class Args(object):
|
||||
"""The argparse namespace, with every default this module reads."""
|
||||
|
||||
def __init__(self, **over):
|
||||
self.fresh_detach = False
|
||||
self.use = None
|
||||
self.run = None
|
||||
self.request = None
|
||||
self.source = None
|
||||
self.base = None
|
||||
self.sandbox_pid = 0
|
||||
self.knowledge = None
|
||||
self.job = None
|
||||
self.detached_dir = None
|
||||
self.revit = None
|
||||
self.revit_exe = None
|
||||
self.pyrevit = None
|
||||
self.log = None
|
||||
self.port = None
|
||||
self.wait_minutes = None
|
||||
self.keep_open = False
|
||||
self.dry_run = False
|
||||
for key, value in over.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
|
||||
# --------------------------------------------------------- what may be killed
|
||||
#
|
||||
# The most important block in this file. Every allowance is a claim that this
|
||||
# pid is not the model the owner is working in, and the refusals are what
|
||||
# stand between a mis-set flag and a closed live session.
|
||||
|
||||
def test_a_pid_this_run_launched_may_be_closed():
|
||||
assert C.may_kill(4242, launched=[4242]) is None
|
||||
|
||||
|
||||
def test_a_pid_nobody_can_vouch_for_is_refused_and_says_why():
|
||||
reason = C.may_kill(4242)
|
||||
assert reason and 'REFUS' in reason
|
||||
assert '4242' in reason
|
||||
assert '--sandbox-pid' in reason
|
||||
|
||||
|
||||
def test_no_pid_at_all_is_a_refusal_not_a_silent_pass():
|
||||
for value in (0, None, '', 'nonsense'):
|
||||
assert C.may_kill(value)
|
||||
|
||||
|
||||
def test_an_explicit_pid_that_is_a_revit_may_be_closed():
|
||||
assert C.may_kill(4242, explicit=4242, image='Revit.exe') is None
|
||||
assert C.may_kill(4242, explicit=4242, image='REVIT.EXE') is None
|
||||
|
||||
|
||||
def test_an_explicit_pid_that_is_not_a_revit_is_refused():
|
||||
reason = C.may_kill(4242, explicit=4242, image='notepad.exe')
|
||||
assert reason and 'notepad.exe' in reason
|
||||
|
||||
|
||||
def test_an_explicit_pid_whose_image_cannot_be_read_is_refused():
|
||||
"""Unknown is not a licence. Being wrong this way costs a message; being
|
||||
wrong the other way ends somebody's Revit session."""
|
||||
reason = C.may_kill(4242, explicit=4242, image=None)
|
||||
assert reason and 'could not be read' in reason
|
||||
|
||||
|
||||
def test_a_pid_named_explicitly_is_STILL_refused_if_a_bridge_calls_it_live():
|
||||
"""The flag is a human remembering a number; the refusal is the document
|
||||
itself, over its own bridge. The document wins."""
|
||||
reason = C.may_kill(4242, explicit=4242, image='Revit.exe',
|
||||
live_pids=[4242])
|
||||
assert reason and 'live' in reason.lower()
|
||||
|
||||
|
||||
def test_a_launched_pid_is_still_refused_if_a_bridge_calls_it_live():
|
||||
"""Belt and braces: if the pid we think we started answers with a live
|
||||
refusal, something is wrong enough that killing is not the response."""
|
||||
assert C.may_kill(4242, launched=[4242], live_pids=[4242])
|
||||
|
||||
|
||||
def test_a_pid_the_bridge_proved_is_a_sandbox_may_be_closed():
|
||||
"""This is what lets the button start the pipeline without knowing a pid
|
||||
-- and therefore what stops a stale sandbox becoming the third Revit."""
|
||||
assert C.may_kill(4242, proven=[4242]) is None
|
||||
|
||||
|
||||
# --------------------------------------------------- proving it is a sandbox
|
||||
|
||||
def _seen(**over):
|
||||
row = {'port': 48884, 'verdict': 'up', 'why': 'ready', 'pid': 4242,
|
||||
'doc_path': SNAP_FILE, 'doc_title': LIVE}
|
||||
row.update(over)
|
||||
return row
|
||||
|
||||
|
||||
def test_a_session_whose_model_is_under_a_snapshot_root_is_proven():
|
||||
assert C.sandbox_pids_from([_seen()], [SNAP_ROOT]) == [4242]
|
||||
|
||||
|
||||
def test_a_session_with_no_local_file_can_never_be_proven():
|
||||
"""Which is precisely the live cloud model: it reports no path at all."""
|
||||
assert C.sandbox_pids_from([_seen(doc_path=None)], [SNAP_ROOT]) == []
|
||||
|
||||
|
||||
def test_a_refused_session_is_never_proven_whatever_it_reports():
|
||||
assert C.sandbox_pids_from(
|
||||
[_seen(verdict='refused')], [SNAP_ROOT]) == []
|
||||
|
||||
|
||||
def test_a_session_on_a_model_somewhere_else_is_not_proven():
|
||||
other = os.path.join('C:', os.sep, 'elsewhere', LIVE + S.MODEL_EXT)
|
||||
assert C.sandbox_pids_from([_seen(doc_path=other)], [SNAP_ROOT]) == []
|
||||
|
||||
|
||||
def test_no_roots_configured_proves_nothing():
|
||||
assert C.sandbox_pids_from([_seen()], []) == []
|
||||
|
||||
|
||||
def test_the_refused_sessions_are_collected_as_live_pids():
|
||||
rows = [_seen(port=48884, verdict='refused', pid=111),
|
||||
_seen(port=48885, pid=222)]
|
||||
assert C.live_pids_from(rows) == [111]
|
||||
|
||||
|
||||
# ------------------------------------------------------ reading /status back
|
||||
|
||||
def test_a_matching_session_is_up():
|
||||
body = {'ok': True, 'doc_title': LIVE}
|
||||
assert C.status_verdict(body, LIVE)[0] == 'up'
|
||||
|
||||
|
||||
def test_the_extension_on_the_title_does_not_change_the_answer():
|
||||
body = {'ok': True, 'doc_title': LIVE + S.MODEL_EXT}
|
||||
assert C.status_verdict(body, LIVE)[0] == 'up'
|
||||
|
||||
|
||||
def test_a_refusal_is_its_own_verdict_and_not_a_retry():
|
||||
"""The distinction the whole port race turns on. "The live Revit is
|
||||
holding this port" and "the sandbox has not started yet" look identical
|
||||
from outside and need opposite responses -- one is waited out, the other
|
||||
never resolves."""
|
||||
body = {'ok': False, 'refused': True, 'doc_title': LIVE,
|
||||
'error': 'REFUSED: ...'}
|
||||
verdict, why = C.status_verdict(body, LIVE)
|
||||
assert verdict == 'refused'
|
||||
assert 'REFUSED' in why
|
||||
|
||||
|
||||
def test_a_session_with_the_wrong_model_is_not_the_sandbox():
|
||||
body = {'ok': True, 'doc_title': 'something_else'}
|
||||
verdict, why = C.status_verdict(body, LIVE)
|
||||
assert verdict == 'other-model'
|
||||
assert 'something_else' in why
|
||||
|
||||
|
||||
def test_nothing_answering_is_silent_rather_than_an_exception():
|
||||
assert C.status_verdict(None, LIVE)[0] == 'silent'
|
||||
assert C.status_verdict({'ok': False, 'error': 'boom'}, LIVE)[0] == 'silent'
|
||||
|
||||
|
||||
def test_no_wanted_title_accepts_any_healthy_session():
|
||||
"""The pre-flight survey asks "who is out there", not "is it mine"."""
|
||||
assert C.status_verdict({'ok': True, 'doc_title': 'anything'},
|
||||
None)[0] == 'up'
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the port scan
|
||||
|
||||
def test_the_scan_walks_up_past_the_live_session_to_the_sandbox():
|
||||
"""pyRevit hands 48884 to whichever Revit started first, so the sandbox
|
||||
is routinely NOT on the configured port. Finding it is the whole job."""
|
||||
answers = {
|
||||
48884: {'ok': False, 'refused': True, 'doc_title': LIVE, 'pid': 111},
|
||||
48885: {'ok': True, 'doc_title': LIVE, 'doc_path': SNAP_FILE,
|
||||
'pid': 222},
|
||||
}
|
||||
port, seen = C.find_sandbox(48884, 4, LIVE, SNAP_FILE,
|
||||
ask=lambda p: answers.get(p))
|
||||
assert port == 48885
|
||||
assert seen[0]['verdict'] == 'refused'
|
||||
assert C.live_pids_from(seen) == [111]
|
||||
|
||||
|
||||
def test_the_scan_reports_every_port_it_saw_when_it_finds_nothing():
|
||||
port, seen = C.find_sandbox(48884, 3, LIVE, ask=lambda p: None)
|
||||
assert port is None
|
||||
assert [row['port'] for row in seen] == [48884, 48885, 48886]
|
||||
assert all(row['verdict'] == 'silent' for row in seen)
|
||||
|
||||
|
||||
def test_a_right_title_on_the_wrong_file_is_not_accepted():
|
||||
"""The trap the identical titles create: the sandbox and the live model
|
||||
are both called the same thing, so the file has to be checked too."""
|
||||
other = os.path.join('C:', os.sep, 'elsewhere', LIVE + S.MODEL_EXT)
|
||||
answers = {48884: {'ok': True, 'doc_title': LIVE, 'doc_path': other,
|
||||
'pid': 1}}
|
||||
port, seen = C.find_sandbox(48884, 1, LIVE, SNAP_FILE,
|
||||
ask=lambda p: answers.get(p))
|
||||
assert port is None
|
||||
assert seen[0]['verdict'] == 'other-model'
|
||||
|
||||
|
||||
def test_the_survey_visits_every_port_even_after_a_match():
|
||||
"""`stop_at_first=False` exists because the pre-flight scan's real job is
|
||||
to find the LIVE session's pid, and that session can be sitting above a
|
||||
port that already answered."""
|
||||
answers = {
|
||||
48884: {'ok': True, 'doc_title': 'scratch', 'pid': 1},
|
||||
48885: {'ok': False, 'refused': True, 'doc_title': LIVE, 'pid': 2},
|
||||
}
|
||||
port, seen = C.find_sandbox(48884, 2, None, ask=lambda p: answers.get(p),
|
||||
stop_at_first=False)
|
||||
assert port == 48884
|
||||
assert C.live_pids_from(seen) == [2]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ settings
|
||||
|
||||
def test_flags_beat_the_knowledge_layer_which_beats_the_defaults():
|
||||
know, error = C.read_knowledge(None)
|
||||
assert error is None
|
||||
settings = C.resolve(Args(detached_dir=SNAP_ROOT, run=['echo one']), know)
|
||||
assert settings['detached_dir'] == SNAP_ROOT
|
||||
assert settings['batch'] == ['echo one']
|
||||
assert settings['layout'] == S.FOLDER # straight from DEFAULTS
|
||||
|
||||
|
||||
def test_the_default_batch_is_the_sweep():
|
||||
"""The commission's default: open the sandbox, re-export, get a verdict."""
|
||||
know, _ = C.read_knowledge(None)
|
||||
settings = C.resolve(Args(), know)
|
||||
assert settings['batch'] == ['python tools/sweep.py']
|
||||
|
||||
|
||||
def test_the_detached_dir_is_a_sandbox_root_even_when_it_came_from_a_flag():
|
||||
"""Otherwise a `--detached-dir` run cannot recognise its own snapshots
|
||||
and refuses to close the sandbox it opened last time."""
|
||||
know, _ = C.read_knowledge(None)
|
||||
settings = C.resolve(Args(detached_dir=SNAP_ROOT), know)
|
||||
assert any(C.G.is_under(SNAP_FILE, root)
|
||||
for root in settings['sandbox_roots'])
|
||||
|
||||
|
||||
def test_the_defaults_protect_no_model_at_all():
|
||||
"""Empty is the only honest default: the tool does not know which of a
|
||||
machine's models is somebody's live work."""
|
||||
know, _ = C.read_knowledge(None)
|
||||
assert C.resolve(Args(), know)['refuse_titles'] == []
|
||||
|
||||
|
||||
def test_a_knowledge_file_supplies_the_denylist_and_the_roots(tmp_path):
|
||||
path = tmp_path / 'knowledge.yaml'
|
||||
path.write_text(
|
||||
'bridge:\n'
|
||||
' refuse_titles: [{0}]\n'
|
||||
' allow_roots: [D:/other]\n'
|
||||
'snapshot:\n'
|
||||
' detached_dir: {1}\n'.format(LIVE, SNAP_ROOT.replace('\\', '/')),
|
||||
encoding='utf-8')
|
||||
know, error = C.read_knowledge(str(path))
|
||||
assert error is None
|
||||
settings = C.resolve(Args(), know)
|
||||
assert settings['refuse_titles'] == [LIVE]
|
||||
assert 'D:/other' in settings['sandbox_roots']
|
||||
|
||||
|
||||
def test_a_knowledge_file_that_is_not_there_is_an_error_not_a_shrug():
|
||||
"""Same rule as `knowledge._config_missing`: a pointer to a file that is
|
||||
gone must never fall back quietly, because the run would then use a
|
||||
configuration nobody chose and look completely normal."""
|
||||
know, error = C.read_knowledge(os.path.join(str(SNAP_ROOT), 'nope.yaml'))
|
||||
assert error and 'no such knowledge file' in error
|
||||
|
||||
|
||||
def test_a_knowledge_file_that_will_not_parse_is_reported(tmp_path):
|
||||
path = tmp_path / 'knowledge.yaml'
|
||||
path.write_text('just a sentence, not a mapping\n', encoding='utf-8')
|
||||
_know, error = C.read_knowledge(str(path))
|
||||
assert error
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the blockers
|
||||
|
||||
def test_it_names_every_missing_setting_at_once():
|
||||
"""A pipeline that reports one missing setting per attempt is a pipeline
|
||||
configured over four runs."""
|
||||
know, _ = C.read_knowledge(None)
|
||||
args = Args(fresh_detach=True, run=[])
|
||||
settings = C.resolve(args, know)
|
||||
settings['detached_dir'] = None
|
||||
settings['pyrevit_exe'] = None
|
||||
settings['revit_exe'] = None
|
||||
settings['batch'] = []
|
||||
stops = C.blockers(settings, args)
|
||||
text = ' | '.join(stops)
|
||||
assert 'detached_dir' in text
|
||||
assert 'pyRevit CLI' in text
|
||||
assert 'Revit.exe' in text
|
||||
assert '--run' in text
|
||||
assert 'WHICH model' in text
|
||||
|
||||
|
||||
def test_neither_fresh_nor_use_is_a_blocker():
|
||||
know, _ = C.read_knowledge(None)
|
||||
args = Args()
|
||||
stops = C.blockers(C.resolve(args, know), args)
|
||||
assert any('--fresh-detach' in line for line in stops)
|
||||
|
||||
|
||||
def test_use_that_names_no_file_is_a_blocker(tmp_path):
|
||||
know, _ = C.read_knowledge(None)
|
||||
args = Args(use=str(tmp_path / ('gone' + S.MODEL_EXT)))
|
||||
stops = C.blockers(C.resolve(args, know), args)
|
||||
assert any('names no file' in line for line in stops)
|
||||
|
||||
|
||||
def _job_dir(tmp_path, name, text):
|
||||
folder = tmp_path / name
|
||||
folder.mkdir()
|
||||
(folder / 'knowledge.yaml').write_text(text, encoding='utf-8')
|
||||
return folder
|
||||
|
||||
|
||||
def test_a_blocker_says_the_layer_was_read_and_did_not_set_the_key(tmp_path):
|
||||
"""The distinction the disease report is named for: "not found" and
|
||||
"found but does not set this" are different sentences."""
|
||||
job = _job_dir(tmp_path, 'onlyjob', u'scope:\n declared_by: Will\n')
|
||||
job_result = C.JL.JobLocateResult(path=str(job / 'knowledge.yaml'),
|
||||
how='single')
|
||||
know, _ = C.read_knowledge(str(job / 'knowledge.yaml'))
|
||||
args = Args(fresh_detach=True, run=[])
|
||||
settings = C.resolve(args, know)
|
||||
settings['detached_dir'] = None
|
||||
stops = C.blockers(settings, args, job_result=job_result)
|
||||
text = ' | '.join(stops)
|
||||
assert str(job / 'knowledge.yaml') in text
|
||||
assert 'does not set this' in text
|
||||
|
||||
|
||||
def test_a_blocker_says_no_layer_was_read_when_none_was(tmp_path):
|
||||
job_result = C.JL.JobLocateResult(
|
||||
reason='WILLTOOLS_JOBS is not set, so there is no folder to look '
|
||||
'for a job under.')
|
||||
know, _ = C.read_knowledge(None)
|
||||
args = Args(fresh_detach=True, run=[])
|
||||
settings = C.resolve(args, know)
|
||||
settings['detached_dir'] = None
|
||||
stops = C.blockers(settings, args, job_result=job_result)
|
||||
text = ' | '.join(stops)
|
||||
assert 'none was read' in text
|
||||
assert 'WILLTOOLS_JOBS is not set' in text
|
||||
|
||||
|
||||
def test_a_blocker_with_no_job_result_at_all_still_says_something_sane():
|
||||
"""Every existing caller before this feature passes no `job_result` --
|
||||
it must read as "no information", not raise."""
|
||||
know, _ = C.read_knowledge(None)
|
||||
args = Args(fresh_detach=True, run=[])
|
||||
settings = C.resolve(args, know)
|
||||
settings['detached_dir'] = None
|
||||
stops = C.blockers(settings, args)
|
||||
assert any('detached_dir' in line for line in stops)
|
||||
|
||||
|
||||
# --------------------------------------------------- resolving the job layer
|
||||
#
|
||||
# Off-Revit there is no doc.Title to pick a job folder by, so WILLTOOLS_JOBS
|
||||
# is asked directly -- see `vibe.joblocate`. These exercise the plumbing in
|
||||
# `resolve_job_layer` specifically (env reading, wiring --job through);
|
||||
# `test_joblocate.py` is where the resolution RULE itself is tested.
|
||||
|
||||
def test_resolve_job_layer_prefers_an_explicit_knowledge_flag(tmp_path,
|
||||
monkeypatch):
|
||||
monkeypatch.setenv(C.JL.JOBS_ENV, str(tmp_path))
|
||||
_job_dir(tmp_path, 'alpha', u'x: 1\n')
|
||||
_job_dir(tmp_path, 'beta', u'x: 1\n')
|
||||
args = Args(knowledge='explicit.yaml')
|
||||
result = C.resolve_job_layer(args)
|
||||
assert result.path == 'explicit.yaml'
|
||||
assert result.how == 'explicit'
|
||||
|
||||
|
||||
def test_resolve_job_layer_finds_the_single_job_under_willtools_jobs(
|
||||
tmp_path, monkeypatch):
|
||||
job = _job_dir(tmp_path, 'sample_job', u'x: 1\n')
|
||||
monkeypatch.setenv(C.JL.JOBS_ENV, str(tmp_path))
|
||||
result = C.resolve_job_layer(Args())
|
||||
assert result.path == str(job / 'knowledge.yaml')
|
||||
assert result.blocking is False
|
||||
|
||||
|
||||
def test_resolve_job_layer_refuses_when_willtools_jobs_is_ambiguous(
|
||||
tmp_path, monkeypatch):
|
||||
_job_dir(tmp_path, 'sample_job', u'x: 1\n')
|
||||
_job_dir(tmp_path, 'Horizon', u'x: 1\n')
|
||||
monkeypatch.setenv(C.JL.JOBS_ENV, str(tmp_path))
|
||||
result = C.resolve_job_layer(Args())
|
||||
assert result.path is None
|
||||
assert result.blocking is True
|
||||
assert 'sample_job' in result.reason
|
||||
assert 'Horizon' in result.reason
|
||||
|
||||
|
||||
def test_resolve_job_layer_honours_the_job_flag_among_several(tmp_path,
|
||||
monkeypatch):
|
||||
_job_dir(tmp_path, 'sample_job', u'x: 1\n')
|
||||
horizon = _job_dir(tmp_path, 'Horizon', u'x: 1\n')
|
||||
monkeypatch.setenv(C.JL.JOBS_ENV, str(tmp_path))
|
||||
result = C.resolve_job_layer(Args(job='Horizon'))
|
||||
assert result.path == str(horizon / 'knowledge.yaml')
|
||||
|
||||
|
||||
def test_resolve_job_layer_with_no_willtools_jobs_is_not_blocking(
|
||||
monkeypatch):
|
||||
monkeypatch.delenv(C.JL.JOBS_ENV, raising=False)
|
||||
result = C.resolve_job_layer(Args())
|
||||
assert result.path is None
|
||||
assert result.blocking is False
|
||||
|
||||
|
||||
# ------------------------------------------------- end to end, via main()
|
||||
|
||||
def test_dry_run_auto_finds_a_single_job_layer_under_willtools_jobs(
|
||||
tmp_path, monkeypatch, capsys):
|
||||
"""The regression this whole feature exists for: no --knowledge flag,
|
||||
the job layer states snapshot.detached_dir, and it gets used."""
|
||||
jobs_root = tmp_path / 'jobs'
|
||||
jobs_root.mkdir()
|
||||
job = _job_dir(jobs_root, 'sample_job',
|
||||
u'snapshot:\n'
|
||||
u' detached_dir: {0}\n'.format(
|
||||
str(tmp_path / 'snaps').replace('\\', '/')))
|
||||
monkeypatch.setenv(C.JL.JOBS_ENV, str(jobs_root))
|
||||
fake_revit = tmp_path / 'Revit.exe'
|
||||
fake_revit.write_text('x', encoding='utf-8')
|
||||
code = C.main(['--fresh-detach', '--source',
|
||||
str(tmp_path / (LIVE + S.MODEL_EXT)),
|
||||
'--revit-exe', str(fake_revit), '--run', 'echo one',
|
||||
'--dry-run'])
|
||||
out = capsys.readouterr().out
|
||||
assert code == 0
|
||||
assert str(job / 'knowledge.yaml') in out
|
||||
assert 'snaps' in out # snapshot.detached_dir, read from the job layer
|
||||
|
||||
|
||||
def test_main_refuses_rather_than_guesses_when_willtools_jobs_is_ambiguous(
|
||||
tmp_path, monkeypatch, capsys):
|
||||
_job_dir(tmp_path, 'sample_job', u'x: 1\n')
|
||||
_job_dir(tmp_path, 'sample_job_detached', u'x: 1\n')
|
||||
monkeypatch.setenv(C.JL.JOBS_ENV, str(tmp_path))
|
||||
code = C.main(['--fresh-detach', '--dry-run'])
|
||||
err = capsys.readouterr().err
|
||||
assert code == 2
|
||||
assert 'cannot start' in err
|
||||
assert 'sample_job' in err
|
||||
assert '--job' in err
|
||||
|
||||
|
||||
# --------------------------------------------------------------- the handoff
|
||||
|
||||
def test_the_request_file_carries_the_plan_the_runner_will_write(tmp_path):
|
||||
know, _ = C.read_knowledge(None)
|
||||
settings = C.resolve(Args(detached_dir=str(tmp_path)), know)
|
||||
plan = S.plan(str(tmp_path), LIVE, '20260901-0930')
|
||||
source = {'path': None, 'title': LIVE,
|
||||
'cloud': {'region': 'US', 'project_guid': 'p',
|
||||
'model_guid': 'm'}}
|
||||
path = C.write_detach_request(settings, source, plan, None, 'c1')
|
||||
with open(path, 'r', encoding='utf-8') as handle:
|
||||
request = json.loads(handle.read())
|
||||
assert request['plan']['path'] == plan['path']
|
||||
assert request['source']['cloud']['model_guid'] == 'm'
|
||||
assert request['run'] == 'c1'
|
||||
# and the schema is the one the button writes, so one runner reads both
|
||||
for key in ('run', 'base', 'layout', 'worksets', 'plan', 'log', 'source'):
|
||||
assert key in request
|
||||
|
||||
|
||||
def test_the_bridge_port_travels_in_the_environment():
|
||||
"""Batch commands are the owner's own strings and call `rvt.py`
|
||||
themselves; a flag would have to be threaded through every one of them."""
|
||||
env = C.bridge_env(48885)
|
||||
assert env[C.PORT_ENV] == '48885'
|
||||
|
||||
|
||||
# ------------------------------------------------------------- waiting for it
|
||||
|
||||
class Clock(object):
|
||||
def __init__(self):
|
||||
self.t = 1000.0
|
||||
self.slept = []
|
||||
|
||||
def now(self):
|
||||
return self.t
|
||||
|
||||
def sleep(self, seconds):
|
||||
self.slept.append(seconds)
|
||||
self.t += seconds
|
||||
|
||||
|
||||
def test_waiting_gives_up_on_the_clock_rather_than_spinning_forever():
|
||||
know, _ = C.read_knowledge(None)
|
||||
settings = C.resolve(Args(wait_minutes=1.0), know)
|
||||
clock = Clock()
|
||||
port, seen = C.wait_for_sandbox(settings, LIVE, SNAP_FILE, 0,
|
||||
sleep=clock.sleep, clock=clock.now,
|
||||
ask=lambda p: None)
|
||||
assert port is None
|
||||
assert seen
|
||||
assert clock.t - 1000.0 >= 60
|
||||
|
||||
|
||||
def test_waiting_stops_early_when_the_revit_it_waits_for_has_died():
|
||||
"""Polling a dead Revit for fifteen minutes tells nobody anything."""
|
||||
know, _ = C.read_knowledge(None)
|
||||
settings = C.resolve(Args(wait_minutes=30.0), know)
|
||||
clock = Clock()
|
||||
dead = 0xFFFFFFF0 # cannot be a live Windows pid
|
||||
port, _seen = C.wait_for_sandbox(settings, LIVE, SNAP_FILE, dead,
|
||||
sleep=clock.sleep, clock=clock.now,
|
||||
ask=lambda p: None)
|
||||
assert port is None
|
||||
assert clock.t - 1000.0 < 30 * 60
|
||||
|
||||
|
||||
def test_waiting_returns_the_port_as_soon_as_the_sandbox_answers():
|
||||
know, _ = C.read_knowledge(None)
|
||||
settings = C.resolve(Args(), know)
|
||||
clock = Clock()
|
||||
answers = {48884: {'ok': True, 'doc_title': LIVE, 'doc_path': SNAP_FILE,
|
||||
'pid': 9}}
|
||||
port, _seen = C.wait_for_sandbox(settings, LIVE, SNAP_FILE, 0,
|
||||
sleep=clock.sleep, clock=clock.now,
|
||||
ask=lambda p: answers.get(p))
|
||||
assert port == 48884
|
||||
assert clock.slept == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------- the dry run
|
||||
|
||||
def test_the_dry_run_prints_the_plan_and_launches_nothing(tmp_path, capsys):
|
||||
root = tmp_path / 'snapshots'
|
||||
root.mkdir()
|
||||
fake_revit = tmp_path / 'Revit.exe'
|
||||
fake_revit.write_text('x', encoding='utf-8')
|
||||
fake_pyrevit = tmp_path / 'pyrevit.exe'
|
||||
fake_pyrevit.write_text('x', encoding='utf-8')
|
||||
code = C.main(['--fresh-detach', '--source',
|
||||
str(tmp_path / (LIVE + S.MODEL_EXT)),
|
||||
'--detached-dir', str(root),
|
||||
'--revit-exe', str(fake_revit),
|
||||
'--pyrevit', str(fake_pyrevit),
|
||||
'--run', 'echo one', '--dry-run'])
|
||||
out = capsys.readouterr().out
|
||||
assert code == 0
|
||||
assert str(root) in out
|
||||
assert 'echo one' in out
|
||||
assert 'Nothing was launched' in out
|
||||
# nothing was created: not the snapshot folder, not a request file
|
||||
assert os.listdir(str(root)) == []
|
||||
|
||||
|
||||
def test_the_dry_run_says_which_titles_are_protected(tmp_path, capsys):
|
||||
know_file = tmp_path / 'knowledge.yaml'
|
||||
know_file.write_text('bridge:\n refuse_titles: [{0}]\n'.format(LIVE),
|
||||
encoding='utf-8')
|
||||
fake_revit = tmp_path / 'Revit.exe'
|
||||
fake_revit.write_text('x', encoding='utf-8')
|
||||
model = tmp_path / (LIVE + S.MODEL_EXT)
|
||||
model.write_text('x', encoding='utf-8')
|
||||
C.main(['--use', str(model), '--revit-exe', str(fake_revit),
|
||||
'--knowledge', str(know_file), '--run', 'echo one', '--dry-run'])
|
||||
out = capsys.readouterr().out
|
||||
assert LIVE in out
|
||||
assert 'refuse titles' in out
|
||||
|
||||
|
||||
def test_a_run_that_cannot_start_says_so_and_starts_nothing(capsys):
|
||||
code = C.main(['--fresh-detach', '--detached-dir', '', '--dry-run'])
|
||||
assert code == 2
|
||||
assert 'cannot start' in capsys.readouterr().err
|
||||
|
||||
|
||||
# --------------------------------------------------- the 2026-09-02 incident
|
||||
#
|
||||
# See the matching block in `test_snapshot_defer.py`: the button-launched run
|
||||
# that failed on 2026-09-01 printed its refusal to a stderr the button never
|
||||
# redirects anywhere (`_spawn` uses `CreateNoWindow = True` with no output
|
||||
# capture), so a failure that only printed was, in practice, a failure that
|
||||
# vanished. These lock in that a `--detached-dir` (which the button always
|
||||
# passes) now gets a journal line for the pre-`resolve()` refusals too, and
|
||||
# that `snapshot.revit_exe`'s own path is used to recover the Revit year
|
||||
# when `snapshot.revit_year` was never separately configured -- the exact
|
||||
# job-layer shape (`revit_exe` set, `revit_year` not) that let the actual
|
||||
# incident's `pyrevit run` omit `--revit=` entirely.
|
||||
|
||||
def test_revit_year_is_recovered_from_the_revit_exe_path_when_unset():
|
||||
know, _ = C.read_knowledge(None)
|
||||
args = Args(revit_exe=r'C:\Program Files\Autodesk\Revit 2024\Revit.exe')
|
||||
settings = C.resolve(args, know)
|
||||
assert settings['revit_year'] == '2024'
|
||||
|
||||
|
||||
def test_an_explicit_revit_year_is_never_overridden_by_the_exe_guess():
|
||||
know, _ = C.read_knowledge(None)
|
||||
args = Args(revit=2025,
|
||||
revit_exe=r'C:\Program Files\Autodesk\Revit 2024\Revit.exe')
|
||||
settings = C.resolve(args, know)
|
||||
assert settings['revit_year'] == 2025
|
||||
|
||||
|
||||
def test_a_run_that_cannot_start_because_of_a_bad_job_layer_journals_too(
|
||||
tmp_path, monkeypatch):
|
||||
"""Before 2026-09-02 this branch (an ambiguous WILLTOOLS_JOBS) only ever
|
||||
printed to stderr -- exactly the channel a button-launched run cannot be
|
||||
read from. `--detached-dir` is what the button always passes, so it is
|
||||
what recovers a log path before `settings` exists at all."""
|
||||
monkeypatch.setenv(C.JL.JOBS_ENV, str(tmp_path))
|
||||
_job_dir(tmp_path, 'sample_job', u'x: 1\n')
|
||||
_job_dir(tmp_path, 'Horizon', u'x: 1\n')
|
||||
detached_dir = tmp_path / 'snaps'
|
||||
code = C.main(['--fresh-detach',
|
||||
'--detached-dir', str(detached_dir), '--dry-run'])
|
||||
assert code == 2
|
||||
rows = S.read(str(detached_dir / S.LOG_NAME))
|
||||
assert rows and rows[-1]['event'] == S.CYCLE_FAILED
|
||||
assert 'sample_job' in rows[-1]['error']
|
||||
|
||||
|
||||
def test_a_blocker_after_resolve_journals_to_the_resolved_log(tmp_path):
|
||||
"""Once `settings` exists it names the real, resolved log path -- which
|
||||
can differ from the `--detached-dir`-only guess when `--log` was also
|
||||
given."""
|
||||
detached_dir = tmp_path / 'snaps'
|
||||
log_path = tmp_path / 'elsewhere.jsonl'
|
||||
code = C.main(['--fresh-detach', '--detached-dir', str(detached_dir),
|
||||
'--log', str(log_path), '--dry-run'])
|
||||
assert code == 2
|
||||
rows = S.read(str(log_path))
|
||||
assert rows and rows[-1]['event'] == S.CYCLE_FAILED
|
||||
|
||||
|
||||
# --------------------------------------------------- where the identity comes from
|
||||
#
|
||||
# A cloud model is found by its region and two GUIDs, and nothing on this
|
||||
# machine knows those except a Revit session with the model open. So the
|
||||
# identity has to be written down once and reused, or every run needs the
|
||||
# owner to press a button first -- which is exactly the thing he said was too
|
||||
# slow to be part of the loop.
|
||||
|
||||
CLOUD = {'region': 'US', 'project_guid': 'p-guid', 'model_guid': 'm-guid'}
|
||||
|
||||
|
||||
def test_a_request_file_is_the_most_direct_answer(tmp_path):
|
||||
req = tmp_path / 'req.json'
|
||||
req.write_text(json.dumps({'source': {'cloud': CLOUD, 'title': LIVE}}),
|
||||
encoding='utf-8')
|
||||
source, request = C.source_from(Args(request=str(req)), {})
|
||||
assert source['cloud'] == CLOUD
|
||||
assert request is not None
|
||||
|
||||
|
||||
def test_a_stated_path_needs_no_revit_and_no_cloud(tmp_path):
|
||||
model = str(tmp_path / (LIVE + S.MODEL_EXT))
|
||||
source, _ = C.source_from(Args(source=model), {})
|
||||
assert source['path'] == model
|
||||
assert source['cloud'] is None
|
||||
assert source['title'] == LIVE
|
||||
|
||||
|
||||
def test_the_job_layer_can_state_the_cloud_identity_outright():
|
||||
source, _ = C.source_from(Args(), {'source_cloud': CLOUD,
|
||||
'source_title': LIVE})
|
||||
assert source['cloud'] == CLOUD
|
||||
|
||||
|
||||
def test_the_cache_makes_the_next_run_need_no_revit_at_all(tmp_path):
|
||||
"""One button press is the whole bootstrap: it writes the identity, and
|
||||
every run after it starts from a terminal."""
|
||||
written = C.remember_source(str(tmp_path), {'cloud': CLOUD, 'path': None,
|
||||
'title': LIVE})
|
||||
assert written and os.path.isfile(written)
|
||||
source, _ = C.source_from(Args(), {'detached_dir': str(tmp_path)})
|
||||
assert source['cloud'] == CLOUD
|
||||
|
||||
|
||||
def test_a_source_with_nothing_in_it_is_not_cached(tmp_path):
|
||||
"""A cache file saying "no model" is worse than none: the next run would
|
||||
read it and think the question was answered."""
|
||||
assert C.remember_source(str(tmp_path), {'path': None,
|
||||
'cloud': None}) is None
|
||||
assert C.remember_source(str(tmp_path), {}) is None
|
||||
assert not os.path.isfile(C.source_cache_path(str(tmp_path)))
|
||||
|
||||
|
||||
def test_caching_never_raises_on_a_folder_it_cannot_write():
|
||||
assert C.remember_source('', {'cloud': CLOUD}) is None
|
||||
assert C.remember_source(None, {'cloud': CLOUD}) is None
|
||||
|
||||
|
||||
def test_a_corrupt_cache_reads_as_no_answer_rather_than_a_crash(tmp_path):
|
||||
cache = tmp_path / C.SOURCE_CACHE
|
||||
cache.write_text('{not json', encoding='utf-8')
|
||||
source, _ = C.source_from(Args(), {'detached_dir': str(tmp_path)})
|
||||
assert not C.has_source(source)
|
||||
|
||||
|
||||
def test_a_fresh_detach_with_no_identity_anywhere_refuses_and_explains():
|
||||
"""And the explanation has to name the bootstrap, because "press the
|
||||
button once" is not a thing anybody guesses from "no source"."""
|
||||
know, _ = C.read_knowledge(None)
|
||||
args = Args(fresh_detach=True)
|
||||
stops = C.blockers(C.resolve(args, know), args, {})
|
||||
text = ' | '.join(stops)
|
||||
assert 'WHICH model' in text
|
||||
assert 'Snapshot button' in text
|
||||
|
||||
|
||||
def test_a_session_with_the_right_title_and_NO_file_is_never_the_sandbox():
|
||||
"""The live cloud session reports no local `PathName` and carries the
|
||||
same title as the snapshot. Treating "no path" as "nothing to compare"
|
||||
accepted it as the sandbox and would have pointed the whole batch at the
|
||||
model this feature exists to protect. No file means no proof, and no
|
||||
proof is a no -- the same asymmetry `bridgeguard` uses from the other end
|
||||
of the wire."""
|
||||
answers = {48884: {'ok': True, 'doc_title': LIVE, 'doc_path': None,
|
||||
'pid': 1},
|
||||
48885: {'ok': True, 'doc_title': LIVE, 'doc_path': SNAP_FILE,
|
||||
'pid': 2}}
|
||||
port, seen = C.find_sandbox(48884, 2, LIVE, SNAP_FILE,
|
||||
ask=lambda p: answers.get(p))
|
||||
assert port == 48885
|
||||
assert seen[0]['verdict'] == 'other-model'
|
||||
assert 'cloud session' in seen[0]['why']
|
||||
|
||||
|
||||
def test_an_empty_string_path_is_treated_the_same_as_none():
|
||||
"""Which is how the API actually reports it for a cloud document."""
|
||||
answers = {48884: {'ok': True, 'doc_title': LIVE, 'doc_path': '',
|
||||
'pid': 1}}
|
||||
port, _seen = C.find_sandbox(48884, 1, LIVE, SNAP_FILE,
|
||||
ask=lambda p: answers.get(p))
|
||||
assert port is None
|
||||
@@ -0,0 +1,300 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for `tools/snapshot_defer.py` -- the parts that need no Revit.
|
||||
|
||||
The tool's job splits cleanly: WAIT for a process, then LAUNCH one. The wait
|
||||
is the half that can be got wrong invisibly (it can decide a live Revit is
|
||||
gone, and then two 10 GB models are resident at once, which is the single
|
||||
outcome the whole design exists to prevent), so its clock, its sleep and its
|
||||
liveness check are all injected and all tested here. The launch is one argv
|
||||
built by `vibe.snapshotmeta`, tested there and asserted end-to-end here.
|
||||
|
||||
Loaded by path, like `test_knowledge_drift.py`: this is host CPython 3.12 code
|
||||
sitting in a repository whose extension is IronPython 2.7.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))))
|
||||
TOOLS = os.path.join(ROOT, 'tools')
|
||||
if TOOLS not in sys.path:
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
from vibe import snapshotmeta as S # noqa: E402 (registers the vibe stub)
|
||||
|
||||
snapshot_defer = pytest.importorskip('snapshot_defer')
|
||||
D = snapshot_defer
|
||||
|
||||
# Built, never spelled out -- see the note in test_snapshotmeta.py.
|
||||
MODEL = 'm_detached' + S.MODEL_EXT
|
||||
|
||||
|
||||
class Clock(object):
|
||||
"""A wall clock that only moves when something sleeps on it."""
|
||||
|
||||
def __init__(self):
|
||||
self.t = 1000.0
|
||||
self.slept = []
|
||||
|
||||
def now(self):
|
||||
return self.t
|
||||
|
||||
def sleep(self, seconds):
|
||||
self.slept.append(seconds)
|
||||
self.t += seconds
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the wait
|
||||
|
||||
def test_returns_at_once_when_the_process_is_already_gone():
|
||||
clock = Clock()
|
||||
assert D.wait_for_exit(123, 600, alive=lambda pid: False,
|
||||
sleep=clock.sleep, clock=clock.now) is True
|
||||
assert clock.slept == []
|
||||
|
||||
|
||||
def test_waits_until_the_process_goes_and_then_returns_true():
|
||||
clock = Clock()
|
||||
calls = {'n': 0}
|
||||
|
||||
def alive(_pid):
|
||||
calls['n'] += 1
|
||||
return calls['n'] <= 3
|
||||
|
||||
assert D.wait_for_exit(123, 600, alive=alive, sleep=clock.sleep,
|
||||
clock=clock.now, poll=5) is True
|
||||
assert clock.slept == [5, 5, 5]
|
||||
|
||||
|
||||
def test_gives_up_and_says_so_rather_than_launching_anyway():
|
||||
# The Revit that never closes -- an unanswered save prompt. Returning
|
||||
# False is what makes that a reportable outcome instead of a second Revit
|
||||
# started next to a live one.
|
||||
clock = Clock()
|
||||
assert D.wait_for_exit(123, 60, alive=lambda pid: True, sleep=clock.sleep,
|
||||
clock=clock.now, poll=10) is False
|
||||
assert clock.t - 1000.0 >= 60
|
||||
|
||||
|
||||
def test_a_zero_pid_is_not_waited_on():
|
||||
assert D.wait_for_exit(0, 600, alive=lambda pid: True,
|
||||
sleep=lambda s: None, clock=lambda: 0.0) is True
|
||||
|
||||
|
||||
def test_process_alive_says_false_for_a_pid_that_cannot_exist():
|
||||
# 0xFFFFFFF0 is not a valid Windows pid. The point of the assertion is
|
||||
# that the real implementation answers rather than raising.
|
||||
assert D.process_alive(0xFFFFFFF0) in (True, False)
|
||||
|
||||
|
||||
def test_process_alive_is_false_for_no_pid():
|
||||
assert D.process_alive(0) is False
|
||||
assert D.process_alive(None) is False
|
||||
|
||||
|
||||
def test_this_very_process_is_alive():
|
||||
assert D.process_alive(os.getpid()) is True
|
||||
|
||||
|
||||
# ------------------------------------------- the machine, not just one pid
|
||||
|
||||
def test_a_revit_started_again_during_the_wait_is_seen():
|
||||
# The hole this closes: he closes Revit, the tool starts waiting, he
|
||||
# opens Revit again for something else. Waiting only on the original pid
|
||||
# would launch a second 10 GB model beside his live one.
|
||||
assert D.any_revit_running(run=lambda argv: 'Revit.exe 1234 Console') \
|
||||
is True
|
||||
|
||||
|
||||
def test_an_empty_task_list_means_the_machine_is_free():
|
||||
assert D.any_revit_running(
|
||||
run=lambda argv: 'INFO: No tasks are running.') is False
|
||||
|
||||
|
||||
def test_a_task_list_that_will_not_run_counts_as_busy():
|
||||
# Unknown must never read as free: being wrong that way thrashes the
|
||||
# workstation, being wrong the other way costs a wait.
|
||||
def explode(argv):
|
||||
raise OSError('tasklist is not on the path')
|
||||
|
||||
assert D.any_revit_running(run=explode) is True
|
||||
|
||||
|
||||
def test_the_machine_wide_wait_uses_the_same_loop_and_can_time_out():
|
||||
clock = Clock()
|
||||
assert D.wait_for_exit(1, 60, alive=lambda _pid: True, sleep=clock.sleep,
|
||||
clock=clock.now, poll=10) is False
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the command
|
||||
|
||||
def test_command_for_matches_what_the_shared_module_builds():
|
||||
assert D.command_for('p.exe', 'r.py', 2025) == \
|
||||
S.pyrevit_run_argv('p.exe', 'r.py', 2025)
|
||||
|
||||
|
||||
def test_command_for_without_a_year():
|
||||
assert D.command_for('p.exe', 'r.py') == ['p.exe', 'run', 'r.py', '--purge']
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the dry run
|
||||
|
||||
def write_request(tmp_path, **over):
|
||||
plan = S.plan(str(tmp_path / 'snap'), 'model_detached', '20260830-1612')
|
||||
request = {'run': 'r20260830-161200', 'base': 'model_detached',
|
||||
'layout': 'folder', 'worksets': 'preserve', 'plan': plan,
|
||||
'log': str(tmp_path / 'snapshots.jsonl'),
|
||||
'source': {'path': str(tmp_path / ('model' + S.MODEL_EXT)), 'cloud': None}}
|
||||
request.update(over)
|
||||
path = tmp_path / 'req.json'
|
||||
path.write_text(json.dumps(request), encoding='utf-8')
|
||||
return str(path), request
|
||||
|
||||
|
||||
def test_dry_run_prints_the_command_and_launches_nothing(tmp_path, capsys):
|
||||
req_path, request = write_request(tmp_path)
|
||||
code = D.main(['--request', req_path, '--runner', r'C:\x\run.py',
|
||||
'--pyrevit', r'C:\x\pyrevit.exe', '--revit', '2025',
|
||||
'--wait-pid', '4242', '--dry-run'])
|
||||
out = capsys.readouterr().out
|
||||
assert code == 0
|
||||
assert 'pyrevit.exe" run "C:\\x\\run.py" --revit=2025 --purge' in out \
|
||||
or 'pyrevit.exe run C:\\x\\run.py --revit=2025 --purge' in out
|
||||
assert request['plan']['path'] in out
|
||||
assert 'WILLTOOLS_SNAPSHOT_REQUEST' in out
|
||||
assert '4242' in out
|
||||
# nothing was created, nothing was journalled
|
||||
assert not os.path.exists(request['log'])
|
||||
|
||||
|
||||
def test_dry_run_names_the_target_the_finder_will_later_find(tmp_path, capsys):
|
||||
req_path, request = write_request(tmp_path)
|
||||
D.main(['--request', req_path, '--runner', 'r.py', '--pyrevit', 'p.exe',
|
||||
'--dry-run'])
|
||||
out = capsys.readouterr().out
|
||||
target = request['plan']['path']
|
||||
assert target in out
|
||||
# and once that file exists, discovery agrees it is the newest snapshot
|
||||
os.makedirs(os.path.dirname(target))
|
||||
with open(target, 'w') as fh:
|
||||
fh.write('x')
|
||||
assert S.newest(request['plan']['folder'].rsplit(os.sep, 1)[0])['path'] \
|
||||
== target
|
||||
|
||||
|
||||
# ------------------------------------------------------- listing what exists
|
||||
|
||||
def test_newest_listing_reports_nothing_when_there_is_nothing(tmp_path, capsys):
|
||||
code = D.main(['--newest', str(tmp_path)])
|
||||
assert code == 1
|
||||
assert 'no snapshots' in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_newest_listing_marks_the_newest(tmp_path, capsys):
|
||||
root = str(tmp_path)
|
||||
for stamp in ('20260828-0730', '20260830-1612'):
|
||||
folder = os.path.join(root, 'm_detached_' + stamp)
|
||||
os.makedirs(folder)
|
||||
with open(os.path.join(folder, MODEL), 'w') as fh:
|
||||
fh.write('x')
|
||||
code = D.main(['--newest', root])
|
||||
out = capsys.readouterr().out
|
||||
assert code == 0
|
||||
assert out.strip().endswith('m_detached_20260830-1612' + os.sep +
|
||||
MODEL)
|
||||
assert '* 20260830-1612' in out
|
||||
assert ' 20260828-0730' in out
|
||||
|
||||
|
||||
# --------------------------------------------------- the 2026-09-02 incident
|
||||
#
|
||||
# A press on 2026-09-01 was journalled as failed within seconds by the CYCLE
|
||||
# route (not the after-close route the report first assumed), and the
|
||||
# runner's own FAILED line was never written at all -- meaning it crashed
|
||||
# before it could read its own request. Separately, this machine has five
|
||||
# Revit versions attached and `pyrevit run` with no `--revit=` reproducibly
|
||||
# hits the same bare "Object reference not set to an instance of an object"
|
||||
# pyRevit CLI wraps every unhandled exception in. These tests lock in the
|
||||
# three changes that came out of that investigation: `--pyrevit`
|
||||
# auto-discovery (so a run started without it is no longer a silent
|
||||
# `argparse.error()`), a loud journal line even when the request itself
|
||||
# cannot be read, and a `--revit=<year>` reminder in both human-facing
|
||||
# outputs (dry-run print, and the FAILED line of a real run).
|
||||
|
||||
def test_pyrevit_is_auto_discovered_when_left_off(tmp_path, monkeypatch,
|
||||
capsys):
|
||||
req_path, _request = write_request(tmp_path)
|
||||
monkeypatch.setattr(S, 'pyrevit_exe_guess',
|
||||
lambda: r'C:\guessed\pyrevit.exe')
|
||||
code = D.main(['--request', req_path, '--runner', 'r.py', '--dry-run'])
|
||||
out = capsys.readouterr().out
|
||||
assert code == 0
|
||||
assert r'C:\guessed\pyrevit.exe' in out
|
||||
|
||||
|
||||
def test_a_run_with_no_pyrevit_anywhere_journals_instead_of_arg_erroring(
|
||||
tmp_path, monkeypatch, capsys):
|
||||
"""Until 2026-09-02 this was `ap.error()`: stderr only, exit 2, and the
|
||||
ledger stayed exactly as empty as a run that never started at all."""
|
||||
req_path, request = write_request(tmp_path)
|
||||
monkeypatch.setattr(S, 'pyrevit_exe_guess', lambda: None)
|
||||
code = D.main(['--request', req_path, '--runner', 'r.py'])
|
||||
assert code == 2
|
||||
rows = S.read(request['log'])
|
||||
assert rows and rows[-1]['event'] == S.FAILED
|
||||
assert 'pyRevit CLI was not found' in rows[-1]['error']
|
||||
|
||||
|
||||
def test_an_unreadable_request_still_journals_to_the_detached_dir(tmp_path):
|
||||
"""The request file IS where `log` lives, so a request this tool cannot
|
||||
even parse used to leave nothing anywhere -- not even the traceback had
|
||||
a home. `fallback_log_for` recovers `<detached_dir>/snapshots.jsonl`
|
||||
from the `_requests/<run>.json` shape of the path alone."""
|
||||
detached_dir = tmp_path / 'snaps'
|
||||
req_dir = detached_dir / '_requests'
|
||||
req_dir.mkdir(parents=True)
|
||||
bad_req = req_dir / 'c-broken.json'
|
||||
bad_req.write_text('not json at all {{{', encoding='utf-8')
|
||||
code = D.main(['--request', str(bad_req), '--runner', 'r.py',
|
||||
'--pyrevit', 'p.exe'])
|
||||
assert code == 2
|
||||
rows = S.read(str(detached_dir / S.LOG_NAME))
|
||||
assert rows and rows[-1]['event'] == S.WATCHER_CRASHED
|
||||
assert rows[-1]['run'] is None
|
||||
|
||||
|
||||
def test_dry_run_warns_when_no_revit_year_was_given(tmp_path, capsys):
|
||||
req_path, _request = write_request(tmp_path)
|
||||
D.main(['--request', req_path, '--runner', 'r.py', '--pyrevit', 'p.exe',
|
||||
'--dry-run'])
|
||||
out = capsys.readouterr().out
|
||||
assert 'WARNING' in out and '--revit=<year>' in out
|
||||
|
||||
|
||||
def test_dry_run_does_not_warn_when_a_revit_year_was_given(tmp_path, capsys):
|
||||
req_path, _request = write_request(tmp_path)
|
||||
D.main(['--request', req_path, '--runner', 'r.py', '--pyrevit', 'p.exe',
|
||||
'--revit', '2024', '--dry-run'])
|
||||
out = capsys.readouterr().out
|
||||
assert 'WARNING' not in out
|
||||
|
||||
|
||||
def test_a_real_run_journals_watcher_launched_before_touching_the_cli(
|
||||
tmp_path):
|
||||
"""The proof this process actually started and got as far as the wait/
|
||||
launch step -- a REQUESTED with no WATCHER_LAUNCHED after it now means
|
||||
this tool never got this far at all, not "still running"."""
|
||||
req_path, request = write_request(tmp_path)
|
||||
code = D.main(['--request', req_path, '--runner', 'r.py',
|
||||
'--pyrevit', sys.executable])
|
||||
assert code == 2 # sys.executable is not pyrevit.exe
|
||||
rows = S.read(request['log'])
|
||||
events = [row['event'] for row in rows]
|
||||
assert S.WATCHER_LAUNCHED in events
|
||||
assert events.index(S.WATCHER_LAUNCHED) < len(events) - 1
|
||||
launched = [r for r in rows if r['event'] == S.WATCHER_LAUNCHED][0]
|
||||
assert launched['pyrevit_exe'] == sys.executable
|
||||
assert launched['revit_year'] is None
|
||||
@@ -0,0 +1,468 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for `vibe.snapshotmeta` -- the Revit-free half of the snapshot flow.
|
||||
|
||||
Everything here runs on host CPython with no model, no licence and no Revit,
|
||||
which is the whole reason the module exists as its own file. Real temporary
|
||||
directories are used rather than a faked filesystem: the questions being asked
|
||||
are "does listdir order change the answer", "is a stamped folder with no model
|
||||
in it a snapshot", and "does a backup file beat the real one" -- all of which a
|
||||
fake would have to model correctly to be worth anything, at which point it is
|
||||
the thing under test.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe import snapshotmeta as S
|
||||
|
||||
# The model extension is never spelled out as a literal anywhere in this
|
||||
# repository -- `tools/scrub.py` treats any `<word>` + extension as a possible
|
||||
# real model filename and fails the audit on it, which is right: that pattern
|
||||
# is exactly how a client's model name gets committed. Names are built from
|
||||
# `S.MODEL_EXT` instead.
|
||||
MODEL = 'm_detached' + S.MODEL_EXT
|
||||
|
||||
|
||||
def touch(path):
|
||||
folder = os.path.dirname(path)
|
||||
if folder and not os.path.isdir(folder):
|
||||
os.makedirs(folder)
|
||||
with io.open(path, 'w', encoding='utf-8') as fh:
|
||||
fh.write(u'not really a model')
|
||||
return path
|
||||
|
||||
|
||||
# -------------------------------------------------------------- the stamp
|
||||
|
||||
def test_stamp_shape():
|
||||
text = S.stamp()
|
||||
assert S.is_stamp(text)
|
||||
assert len(text) == 13 and text[8] == '-'
|
||||
|
||||
|
||||
def test_stamp_of_a_known_time():
|
||||
# 2026-08-30 16:12 local. Built from a struct_time via mktime so the test
|
||||
# asks the same question on any machine's timezone.
|
||||
import time
|
||||
when = time.mktime((2026, 8, 30, 16, 12, 0, 0, 0, -1))
|
||||
assert S.stamp(when) == '20260830-1612'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bad', ['', None, '2026083-1612', '20260830-161',
|
||||
'20260830_1612', 'nope'])
|
||||
def test_is_stamp_rejects(bad):
|
||||
assert S.is_stamp(bad) is False
|
||||
|
||||
|
||||
# ------------------------------------------------------------ splitting
|
||||
|
||||
def test_split_stamped_plain():
|
||||
assert S.split_stamped('model_20260830-1612') == ('model',
|
||||
'20260830-1612')
|
||||
|
||||
|
||||
def test_split_stamped_keeps_a_base_that_contains_detached_and_spaces():
|
||||
# The real title on this job is of exactly this shape. A base that gets
|
||||
# truncated here is a snapshot that gets reported under the wrong name.
|
||||
base, text = S.split_stamped('some model_detached_20260830-1612')
|
||||
assert base == 'some model_detached'
|
||||
assert text == '20260830-1612'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', ['model', 'model_detached', '',
|
||||
'model_2026-08-30', '_20260830-1612'])
|
||||
def test_split_stamped_returns_none_for_unstamped(name):
|
||||
assert S.split_stamped(name) == (None, None)
|
||||
|
||||
|
||||
def test_split_stamped_never_raises_on_junk():
|
||||
assert S.split_stamped(None) == (None, None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- planning
|
||||
|
||||
def test_plan_folder_layout_keeps_the_title_constant():
|
||||
a = S.plan(r'C:\snap', 'model_detached', '20260830-1612')
|
||||
b = S.plan(r'C:\snap', 'model_detached', '20260831-0900')
|
||||
assert a['title'] == b['title'] == 'model_detached'
|
||||
assert a['title_is_stable'] is True
|
||||
assert a['folder'].endswith('model_detached_20260830-1612')
|
||||
assert a['path'].endswith(os.path.join('model_detached_20260830-1612',
|
||||
('model_detached' + S.MODEL_EXT)))
|
||||
assert a['warning'] is None
|
||||
|
||||
|
||||
def test_plan_filename_layout_moves_the_title_and_says_so():
|
||||
p = S.plan(r'C:\snap', 'model_detached', '20260830-1612',
|
||||
layout=S.FILENAME)
|
||||
assert p['title'] == 'model_detached_20260830-1612'
|
||||
assert p['title_is_stable'] is False
|
||||
assert p['path'].endswith(('model_detached_20260830-1612' + S.MODEL_EXT))
|
||||
assert 'WILLTOOLS_JOBS' in p['warning']
|
||||
|
||||
|
||||
def test_plan_falls_back_to_folder_for_an_unknown_layout():
|
||||
assert S.plan(r'C:\snap', 'm', '20260830-1612',
|
||||
layout='sideways')['layout'] == S.FOLDER
|
||||
|
||||
|
||||
def test_plan_stamps_itself_when_not_given_one():
|
||||
assert S.is_stamp(S.plan(r'C:\snap', 'm')['stamp'])
|
||||
|
||||
|
||||
# ------------------------------------------------------- finding the newest
|
||||
|
||||
def test_snapshots_of_a_missing_root_is_empty_not_an_error():
|
||||
assert S.snapshots(r'C:\nope\nothing\here') == []
|
||||
|
||||
|
||||
def test_newest_of_an_empty_folder_is_none(tmp_path):
|
||||
assert S.newest(str(tmp_path)) is None
|
||||
|
||||
|
||||
def test_finds_both_layouts_and_orders_them_oldest_first(tmp_path):
|
||||
root = str(tmp_path)
|
||||
touch(os.path.join(root, 'm_detached_20260830-1612', MODEL))
|
||||
touch(os.path.join(root, 'm_detached_20260901-0800' + S.MODEL_EXT))
|
||||
touch(os.path.join(root, 'm_detached_20260828-0730', MODEL))
|
||||
rows = S.snapshots(root)
|
||||
assert [r['stamp'] for r in rows] == ['20260828-0730', '20260830-1612',
|
||||
'20260901-0800']
|
||||
assert [r['layout'] for r in rows] == [S.FOLDER, S.FOLDER, S.FILENAME]
|
||||
assert S.newest(root)['stamp'] == '20260901-0800'
|
||||
|
||||
|
||||
def test_a_stamped_folder_with_no_model_in_it_is_not_a_snapshot(tmp_path):
|
||||
# This is the state a Phase C that died mid-SaveAs leaves behind, and
|
||||
# handing that path to Revit next time is a crash for no reason.
|
||||
root = str(tmp_path)
|
||||
os.makedirs(os.path.join(root, 'm_detached_20260830-1612'))
|
||||
touch(os.path.join(root, 'm_detached_20260828-0730', MODEL))
|
||||
rows = S.snapshots(root)
|
||||
assert [r['stamp'] for r in rows] == ['20260828-0730']
|
||||
|
||||
|
||||
def test_unstamped_folders_and_files_are_ignored(tmp_path):
|
||||
root = str(tmp_path)
|
||||
touch(os.path.join(root, 'm_detached', MODEL))
|
||||
touch(os.path.join(root, 'notes.txt'))
|
||||
touch(os.path.join(root, MODEL))
|
||||
touch(os.path.join(root, 'm_detached_20260830-1612', MODEL))
|
||||
assert [r['stamp'] for r in S.snapshots(root)] == ['20260830-1612']
|
||||
|
||||
|
||||
def test_a_revit_backup_beside_the_model_does_not_win(tmp_path):
|
||||
# Revit leaves a `.0001` backup next to a saved central. The real model is
|
||||
# the shorter name; picking the backup would scan yesterday's geometry.
|
||||
root = str(tmp_path)
|
||||
folder = os.path.join(root, 'm_detached_20260830-1612')
|
||||
touch(os.path.join(folder, 'm_detached.0001' + S.MODEL_EXT))
|
||||
touch(os.path.join(folder, MODEL))
|
||||
assert os.path.basename(S.newest(root)['path']) == MODEL
|
||||
|
||||
|
||||
def test_title_comes_from_the_file_on_disk_not_from_the_folder_name(tmp_path):
|
||||
# The folder is stamped; the model inside is not. The title Revit will
|
||||
# report is the model's stem, and that is what the job folder keys on.
|
||||
root = str(tmp_path)
|
||||
touch(os.path.join(root, 'm_detached_20260830-1612', MODEL))
|
||||
assert S.newest(root)['title'] == 'm_detached'
|
||||
|
||||
|
||||
def test_the_plan_and_the_finder_agree(tmp_path):
|
||||
# The two halves of the contract: what plan() says it will write is what
|
||||
# snapshots() finds afterwards. Drift between them is silent.
|
||||
root = str(tmp_path)
|
||||
p = S.plan(root, 'm_detached', '20260830-1612')
|
||||
touch(p['path'])
|
||||
found = S.newest(root)
|
||||
assert found['path'] == p['path']
|
||||
assert found['stamp'] == p['stamp']
|
||||
assert found['title'] == p['title']
|
||||
|
||||
|
||||
def test_the_plan_and_the_finder_agree_in_filename_layout(tmp_path):
|
||||
root = str(tmp_path)
|
||||
p = S.plan(root, 'm_detached', '20260830-1612', layout=S.FILENAME)
|
||||
touch(p['path'])
|
||||
found = S.newest(root)
|
||||
assert found['path'] == p['path']
|
||||
assert found['title'] == p['title'] == 'm_detached_20260830-1612'
|
||||
|
||||
|
||||
# ------------------------------------------------------------- provenance
|
||||
|
||||
def test_provenance_carries_everything_a_reader_needs():
|
||||
b = S.provenance('A', S.LIVE_DOC, source_path=None, source_stamp=None,
|
||||
source_title='model', revit='2025.4',
|
||||
job_folder=r'C:\jobs\model')
|
||||
assert b['phase'] == 'A'
|
||||
assert b['kind'] == S.LIVE_DOC and b['kind_known'] is True
|
||||
assert b['source'] == {'path': None, 'stamp': None, 'title': 'model'}
|
||||
assert b['revit'] == '2025.4'
|
||||
assert b['job_folder'] == r'C:\jobs\model'
|
||||
assert b['at']
|
||||
|
||||
|
||||
def test_provenance_flags_an_unknown_kind_instead_of_silently_taking_it():
|
||||
assert S.provenance('C', 'guessed')['kind_known'] is False
|
||||
|
||||
|
||||
def test_provenance_keeps_source_stamp_separate_from_run_time():
|
||||
# A live document has no snapshot stamp. Filling it with the run time
|
||||
# would make every Phase A snapshot claim a source age it does not have.
|
||||
b = S.provenance('A', S.LIVE_DOC)
|
||||
assert b['source']['stamp'] is None
|
||||
assert b['at'] is not None
|
||||
|
||||
|
||||
def test_provenance_line_is_printable_for_a_detached_source():
|
||||
b = S.provenance('C', S.FRESH_DETACH, source_path=r'C:\s\m' + S.MODEL_EXT,
|
||||
source_stamp='20260830-1612', revit='2025.4')
|
||||
line = S.provenance_line(b)
|
||||
assert 'phase C' in line and ('m' + S.MODEL_EXT) in line and '20260830-1612' in line
|
||||
|
||||
|
||||
def test_provenance_line_says_so_when_there_is_none():
|
||||
assert 'cannot say where it came from' in S.provenance_line(None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the log
|
||||
|
||||
def test_append_and_read_round_trip(tmp_path):
|
||||
path = S.log_path(str(tmp_path))
|
||||
assert S.append(path, {'event': S.SNAPSHOT, 'pieces': 718}) is True
|
||||
assert S.append(path, {'event': S.REQUESTED, 'run': 'r1'}) is True
|
||||
rows = S.read(path)
|
||||
assert [r['event'] for r in rows] == [S.SNAPSHOT, S.REQUESTED]
|
||||
assert rows[0]['pieces'] == 718
|
||||
assert rows[0]['at']
|
||||
|
||||
|
||||
def test_append_never_raises_on_an_impossible_path():
|
||||
assert S.append('', {'event': 'x'}) is False
|
||||
|
||||
|
||||
def test_read_of_a_missing_log_is_empty():
|
||||
assert S.read(r'C:\nope\snapshots.jsonl') == []
|
||||
|
||||
|
||||
def test_a_half_written_last_line_costs_only_that_line(tmp_path):
|
||||
path = S.log_path(str(tmp_path))
|
||||
S.append(path, {'event': S.SNAPSHOT})
|
||||
with io.open(path, 'a', encoding='utf-8') as fh:
|
||||
fh.write(u'{"event": "phase-c-do')
|
||||
rows = S.read(path)
|
||||
assert [r['event'] for r in rows] == [S.SNAPSHOT]
|
||||
|
||||
|
||||
def test_read_limit_keeps_the_newest(tmp_path):
|
||||
path = S.log_path(str(tmp_path))
|
||||
for i in range(5):
|
||||
S.append(path, {'event': S.SNAPSHOT, 'n': i})
|
||||
assert [r['n'] for r in S.read(path, limit=2)] == [3, 4]
|
||||
|
||||
|
||||
# ------------------------------------------- the deferred step that vanished
|
||||
|
||||
def test_a_request_with_no_answer_is_outstanding():
|
||||
rows = [{'event': S.REQUESTED, 'run': 'r1', 'at': 'yesterday'}]
|
||||
assert S.outstanding(rows)['run'] == 'r1'
|
||||
|
||||
|
||||
def test_a_request_that_finished_is_not_outstanding():
|
||||
rows = [{'event': S.REQUESTED, 'run': 'r1'},
|
||||
{'event': S.DONE, 'run': 'r1'}]
|
||||
assert S.outstanding(rows) is None
|
||||
|
||||
|
||||
def test_a_failure_closes_a_request_too():
|
||||
rows = [{'event': S.REQUESTED, 'run': 'r1'},
|
||||
{'event': S.FAILED, 'run': 'r1', 'error': 'no revit'}]
|
||||
assert S.outstanding(rows) is None
|
||||
|
||||
|
||||
def test_an_old_unfinished_request_still_shows_after_a_later_success():
|
||||
# The failure mode this whole ledger exists for: yesterday's detached copy
|
||||
# never happened, today's did, and today's must not paper over it.
|
||||
rows = [{'event': S.REQUESTED, 'run': 'r1', 'at': 'mon'},
|
||||
{'event': S.REQUESTED, 'run': 'r2', 'at': 'tue'},
|
||||
{'event': S.DONE, 'run': 'r2', 'at': 'tue'}]
|
||||
assert S.outstanding(rows)['run'] == 'r1'
|
||||
|
||||
|
||||
def test_a_request_written_without_an_id_is_cleared_by_a_later_terminal_line():
|
||||
rows = [{'event': S.REQUESTED, 'at': 'mon'},
|
||||
{'event': S.DONE, 'at': 'mon'}]
|
||||
assert S.outstanding(rows) is None
|
||||
|
||||
|
||||
def test_summary_of_nothing_says_nothing_has_happened():
|
||||
assert 'no snapshot has been recorded' in S.summary([])[0]
|
||||
|
||||
|
||||
def test_summary_warns_loudly_about_a_vanished_phase_c():
|
||||
rows = [{'event': S.REQUESTED, 'run': 'r1', 'at': '2026-08-29 18:00'}]
|
||||
text = ' '.join(S.summary(rows))
|
||||
assert 'WARNING' in text and '2026-08-29 18:00' in text
|
||||
|
||||
|
||||
def test_summary_reports_the_last_good_copy_and_the_last_index():
|
||||
rows = [{'event': S.SNAPSHOT, 'at': '2026-08-30 17:00', 'pieces': 718},
|
||||
{'event': S.REQUESTED, 'run': 'r1', 'at': '2026-08-30 17:00'},
|
||||
{'event': S.DONE, 'run': 'r1', 'at': '2026-08-30 17:40',
|
||||
'path': os.path.join(r'C:\s\m_detached_20260830-1700', MODEL)}]
|
||||
text = ' '.join(S.summary(rows))
|
||||
assert 'WARNING' not in text
|
||||
assert 'm_detached_20260830-1700' in text
|
||||
assert '718 pieces' in text
|
||||
|
||||
|
||||
# ------------------------------------------------------ the command lines
|
||||
|
||||
def test_pyrevit_run_argv_shape():
|
||||
argv = S.pyrevit_run_argv(r'C:\p\pyrevit.exe', r'C:\s\run.py', 2025)
|
||||
assert argv == [r'C:\p\pyrevit.exe', 'run', r'C:\s\run.py',
|
||||
'--revit=2025', '--purge']
|
||||
|
||||
|
||||
def test_pyrevit_run_argv_without_a_year_leaves_the_choice_to_pyrevit():
|
||||
argv = S.pyrevit_run_argv(r'C:\p\pyrevit.exe', r'C:\s\run.py', None)
|
||||
assert '--revit=2025' not in argv
|
||||
assert not [a for a in argv if a.startswith('--revit')]
|
||||
|
||||
|
||||
def test_quote_only_quotes_what_needs_it():
|
||||
assert S.quote(['a', 'b c']) == 'a "b c"'
|
||||
|
||||
|
||||
def test_quote_produces_a_pasteable_line_for_a_real_looking_command():
|
||||
argv = S.pyrevit_run_argv(
|
||||
r'D:\tools\pyRevit-Master\bin\pyrevit.exe',
|
||||
r'D:\dev\pyrevit-dev\WillTools.extension\runners\detach_snapshot.py',
|
||||
2025)
|
||||
line = S.quote(argv)
|
||||
assert line.count('"') == 0 # no spaces in either path
|
||||
assert line.endswith('--revit=2025 --purge')
|
||||
|
||||
|
||||
def test_quote_handles_a_space_in_the_extension_path():
|
||||
line = S.quote([r'C:\p\pyrevit.exe', 'run', r'C:\side projects\run.py'])
|
||||
assert '"C:\\side projects\\run.py"' in line
|
||||
|
||||
|
||||
# ============================================================ the CYCLE
|
||||
#
|
||||
# Added 2026-09-01 with `tools/snapshot_cycle.py`. The old three phases are a
|
||||
# close-model ritual; these events describe the pipeline that replaced its
|
||||
# tail -- copy, cycle the sandbox Revit, run a batch, close. Same log file on
|
||||
# purpose: a reader wants one ledger per job, not two to interleave by hand.
|
||||
|
||||
def test_the_sandbox_launch_is_revit_itself_not_the_pyrevit_runner():
|
||||
"""`pyrevit run` plays a journal and quits, which is right for the
|
||||
one-shot detach and wrong for a session a sweep has to drive for ten
|
||||
minutes. The sandbox is an ordinary Revit opened on one file."""
|
||||
argv = S.revit_exe_argv(r'C:\Autodesk\Revit.exe', r'C:\s\a' + S.MODEL_EXT)
|
||||
assert argv == [r'C:\Autodesk\Revit.exe', r'C:\s\a' + S.MODEL_EXT]
|
||||
assert 'run' not in argv
|
||||
|
||||
|
||||
def test_the_revit_install_guess_is_a_guess_and_says_nothing_without_a_year():
|
||||
assert S.revit_exe_guess(None) is None
|
||||
guess = S.revit_exe_guess(2024)
|
||||
assert guess.endswith('Revit.exe')
|
||||
assert '2024' in guess
|
||||
|
||||
|
||||
def test_each_batch_command_gets_its_own_recognisable_log():
|
||||
"""A folder of batch-01.log, batch-02.log is a folder nobody opens
|
||||
twice, so the command is in the name as well as in the file."""
|
||||
name = S.batch_log_name(1, 'python tools/sweep.py')
|
||||
assert name.startswith('batch-01')
|
||||
assert name.endswith('.log')
|
||||
assert 'sweep' in name
|
||||
|
||||
|
||||
def test_a_batch_log_name_survives_a_command_full_of_punctuation():
|
||||
name = S.batch_log_name(12, r'"C:\p y\python.exe" x.py --out=".\a b"')
|
||||
assert name.startswith('batch-12')
|
||||
assert name.endswith('.log')
|
||||
for ch in '"\\/:*?<>|':
|
||||
assert ch not in name
|
||||
|
||||
|
||||
def test_a_very_long_command_does_not_become_a_very_long_filename():
|
||||
name = S.batch_log_name(3, 'python ' + 'a' * 400)
|
||||
assert len(name) < 80
|
||||
|
||||
|
||||
def test_a_batch_log_name_with_no_usable_words_is_still_unique():
|
||||
assert S.batch_log_name(7, '///') == 'batch-07.log'
|
||||
assert S.batch_log_name(8, '') == 'batch-08.log'
|
||||
|
||||
|
||||
def test_a_cycle_that_started_and_never_reported_is_outstanding():
|
||||
"""The failure this event pair exists for: a cycle that died mid-run can
|
||||
have left a Revit open on a 300 MB model with nobody in front of it, and
|
||||
the next day looks exactly like a day it worked."""
|
||||
rows = [{'event': S.CYCLE_START, 'run': 'c1', 'at': '2026-09-01 09:00',
|
||||
'sandbox_pid': 4242}]
|
||||
pending = S.outstanding_cycle(rows)
|
||||
assert pending is not None
|
||||
assert pending['run'] == 'c1'
|
||||
assert 'WARNING' in ' '.join(S.cycle_summary(rows))
|
||||
assert '4242' in ' '.join(S.cycle_summary(rows))
|
||||
|
||||
|
||||
def test_a_cycle_that_reported_is_not_outstanding():
|
||||
rows = [{'event': S.CYCLE_START, 'run': 'c1', 'at': '2026-09-01 09:00'},
|
||||
{'event': S.CYCLE_DONE, 'run': 'c1', 'at': '2026-09-01 09:40',
|
||||
'batch_count': 1, 'seconds': 2400}]
|
||||
assert S.outstanding_cycle(rows) is None
|
||||
text = ' '.join(S.cycle_summary(rows))
|
||||
assert 'WARNING' not in text
|
||||
assert '1 batch command' in text
|
||||
|
||||
|
||||
def test_an_old_unfinished_cycle_is_not_papered_over_by_a_later_good_one():
|
||||
"""Matched on the run id, not on position -- the same discipline
|
||||
`outstanding()` has for phase C."""
|
||||
rows = [{'event': S.CYCLE_START, 'run': 'c1', 'at': '09:00'},
|
||||
{'event': S.CYCLE_START, 'run': 'c2', 'at': '10:00'},
|
||||
{'event': S.CYCLE_DONE, 'run': 'c2', 'at': '10:40'}]
|
||||
assert S.outstanding_cycle(rows)['run'] == 'c1'
|
||||
|
||||
|
||||
def test_a_failed_cycle_is_reported_with_its_reason():
|
||||
rows = [{'event': S.CYCLE_START, 'run': 'c1', 'at': '2026-09-01 09:00'},
|
||||
{'event': S.CYCLE_FAILED, 'run': 'c1', 'at': '2026-09-01 09:05',
|
||||
'error': 'the sandbox never answered'}]
|
||||
text = ' '.join(S.cycle_summary(rows))
|
||||
assert 'FAILED' in text
|
||||
assert 'never answered' in text
|
||||
|
||||
|
||||
def test_no_cycle_yet_says_so_rather_than_printing_nothing():
|
||||
assert S.cycle_summary([]) == [
|
||||
'no snapshot cycle has been recorded for this job yet']
|
||||
|
||||
|
||||
def test_the_cycle_events_do_not_disturb_the_phase_ledger():
|
||||
"""Both ledgers share one file, so each has to ignore the other's rows.
|
||||
A cycle line must not read as a phase C that never reported."""
|
||||
rows = [{'event': S.CYCLE_START, 'run': 'c1', 'at': '09:00'},
|
||||
{'event': S.SANDBOX_OPENED, 'run': 'c1', 'pid': 1, 'at': '09:10'}]
|
||||
assert S.outstanding(rows) is None
|
||||
assert 'WARNING' not in ' '.join(S.summary(rows))
|
||||
|
||||
|
||||
def test_the_two_ledgers_round_trip_through_one_log_file(tmp_path):
|
||||
log = S.log_path(str(tmp_path))
|
||||
S.append(log, {'event': S.REQUESTED, 'run': 'r1'})
|
||||
S.append(log, {'event': S.CYCLE_START, 'run': 'c1', 'sandbox_pid': 9})
|
||||
S.append(log, {'event': S.CYCLE_DONE, 'run': 'c1', 'batch_count': 2})
|
||||
rows = S.read(log)
|
||||
assert S.outstanding(rows)['run'] == 'r1' # phase C still pending
|
||||
assert S.outstanding_cycle(rows) is None # the cycle closed
|
||||
@@ -0,0 +1,334 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for vibe.specformat: the spec shape, the strand matrix, and the
|
||||
hand-editable YAML text it reads and writes.
|
||||
|
||||
No Revit dependency -- see conftest.py -- so the whole module is in scope.
|
||||
`designspec.py` is the Revit-side half of this boundary (evidence scraping,
|
||||
disk IO with a live `doc`) and is out of scope for this suite the same way
|
||||
`ticketqc.py` is (see CLAUDE.md).
|
||||
"""
|
||||
import json
|
||||
|
||||
from vibe import specformat
|
||||
|
||||
|
||||
WP1_LAYOUT_NEW = {
|
||||
'datum': 'left_rail',
|
||||
'columns_in': [3.5, 16.5, 40.5, 59.5, 87.5, 109.5, 132.5],
|
||||
'rows': [
|
||||
{'from_bottom_in': 1.75, 'strands': ['X'] * 7},
|
||||
{'from_bottom_in': 6.25, 'strands': ['X'] * 7},
|
||||
],
|
||||
'total_in': 143.0,
|
||||
}
|
||||
|
||||
WP1_LAYOUT_OLD = {
|
||||
'datum': 'left_rail',
|
||||
'positions_in': [3.5, 16.5, 40.5, 59.5, 87.5, 109.5, 132.5],
|
||||
'rows': [
|
||||
{'from_bottom_in': 1.75, 'note': '1 3/4"'},
|
||||
{'from_bottom_in': 6.25, 'note': '4 1/2"'},
|
||||
],
|
||||
'total_in': 143.0,
|
||||
}
|
||||
|
||||
|
||||
def _blank_spec():
|
||||
return json.loads(json.dumps(specformat.SPEC_TEMPLATE))
|
||||
|
||||
|
||||
# --------------------------------------------------------------- get_path
|
||||
|
||||
def test_get_path_walks_dotted_keys():
|
||||
spec = {'concrete': {'fc_psi': 6000}}
|
||||
assert specformat.get_path(spec, 'concrete.fc_psi') == 6000
|
||||
|
||||
|
||||
def test_get_path_missing_returns_default():
|
||||
assert specformat.get_path({}, 'concrete.fc_psi', 'fallback') == 'fallback'
|
||||
|
||||
|
||||
def test_get_path_none_value_returns_default_not_none():
|
||||
spec = {'concrete': {'fc_psi': None}}
|
||||
assert specformat.get_path(spec, 'concrete.fc_psi', 'fallback') == 'fallback'
|
||||
|
||||
|
||||
# ------------------------------------------------------------- validate_spec
|
||||
|
||||
def test_validate_spec_flags_missing_required_paths():
|
||||
spec = _blank_spec()
|
||||
problems = specformat.validate_spec(spec)
|
||||
assert any('design_number' in p for p in problems)
|
||||
assert any('concrete.fc_psi' in p for p in problems)
|
||||
assert any('concrete.fci_psi' in p for p in problems)
|
||||
|
||||
|
||||
def test_validate_spec_happy_path_has_no_complaints():
|
||||
spec = _blank_spec()
|
||||
spec['design_number'] = 'WP1'
|
||||
spec['concrete']['fc_psi'] = 6000
|
||||
spec['concrete']['fci_psi'] = 3500
|
||||
assert specformat.validate_spec(spec) == []
|
||||
|
||||
|
||||
def test_validate_spec_flags_fci_higher_than_fc():
|
||||
spec = _blank_spec()
|
||||
spec['design_number'] = 'WP1'
|
||||
spec['concrete']['fc_psi'] = 3500
|
||||
spec['concrete']['fci_psi'] = 6000
|
||||
problems = specformat.validate_spec(spec)
|
||||
assert any("higher than f'c" in p for p in problems)
|
||||
|
||||
|
||||
def test_validate_spec_flags_a_strand_group_with_zero_qty():
|
||||
spec = _blank_spec()
|
||||
spec['design_number'] = 'WP1'
|
||||
spec['concrete']['fc_psi'] = 6000
|
||||
spec['concrete']['fci_psi'] = 3500
|
||||
spec['reinforcing']['strand_groups'] = [{'qty': 0, 'dia_in': 0.5}]
|
||||
problems = specformat.validate_spec(spec)
|
||||
assert any('qty is 0' in p for p in problems)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- normalize_spec
|
||||
|
||||
def test_normalize_spec_maps_strand_group_synonyms_onto_canonical_names():
|
||||
spec = {'reinforcing': {'strand_groups': [
|
||||
{'count': '14', 'diameter_in': '0.5', 'grade': '270', 'it_lbs': '28,920'},
|
||||
]}}
|
||||
specformat.normalize_spec(spec)
|
||||
group = spec['reinforcing']['strand_groups'][0]
|
||||
assert group['qty'] == 14
|
||||
assert group['dia_in'] == 0.5
|
||||
assert group['grade_ksi'] == 270.0
|
||||
assert group['it_lb'] == 28920.0
|
||||
|
||||
|
||||
def test_normalize_spec_prefers_canonical_field_when_both_present():
|
||||
spec = {'reinforcing': {'strand_groups': [{'qty': 14, 'count': 99}]}}
|
||||
specformat.normalize_spec(spec)
|
||||
assert spec['reinforcing']['strand_groups'][0]['qty'] == 14
|
||||
|
||||
|
||||
def test_normalize_spec_maps_concrete_synonyms():
|
||||
spec = {'concrete': {'fc': '6000 PSI', 'fci': '3500'}}
|
||||
specformat.normalize_spec(spec)
|
||||
assert spec['concrete']['fc_psi'] == 6000.0
|
||||
assert spec['concrete']['fci_psi'] == 3500.0
|
||||
|
||||
|
||||
def test_normalize_spec_tolerates_a_non_dict():
|
||||
assert specformat.normalize_spec(None) is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------- new_spec
|
||||
|
||||
def test_new_spec_carries_provenance_from_evidence():
|
||||
evidence = {
|
||||
'summary_views': [{'name': 'WP1.0 8" SOLID WALL', 'id': 123}],
|
||||
'sheets': [{'number': 'WP1.0', 'name': '8" HORIZONTAL SOLID WALL'}],
|
||||
'model': 'sample_job',
|
||||
}
|
||||
spec = specformat.new_spec('WP1', evidence)
|
||||
assert spec['design_number'] == 'WP1'
|
||||
assert spec['source']['view'] == 'WP1.0 8" SOLID WALL'
|
||||
assert spec['source']['view_id'] == 123
|
||||
assert spec['source']['sheet'] == 'WP1.0 - 8" HORIZONTAL SOLID WALL'
|
||||
assert spec['source']['model'] == 'sample_job'
|
||||
assert spec['source']['extracted'] # a timestamp got written
|
||||
|
||||
|
||||
def test_new_spec_without_evidence_is_still_usable():
|
||||
spec = specformat.new_spec('WP1')
|
||||
assert spec['design_number'] == 'WP1'
|
||||
assert spec['source']['view'] is None
|
||||
|
||||
|
||||
# --------------------------------------------------------- the strand matrix
|
||||
|
||||
def test_strand_positions_reads_the_new_matrix_shape():
|
||||
assert specformat.strand_positions(WP1_LAYOUT_NEW) == \
|
||||
[3.5, 16.5, 40.5, 59.5, 87.5, 109.5, 132.5]
|
||||
|
||||
|
||||
def test_strand_positions_falls_back_to_legacy_positions_in():
|
||||
assert specformat.strand_positions(WP1_LAYOUT_OLD) == \
|
||||
[3.5, 16.5, 40.5, 59.5, 87.5, 109.5, 132.5]
|
||||
|
||||
|
||||
def test_strand_positions_empty_for_a_blank_layout():
|
||||
assert specformat.strand_positions({}) == []
|
||||
assert specformat.strand_positions(None) == []
|
||||
|
||||
|
||||
def test_strand_row_marks_reads_an_explicit_strands_list():
|
||||
row = {'from_bottom_in': 1.75, 'strands': ['X', '.', 'X']}
|
||||
assert specformat.strand_row_marks(row, 3) == [True, False, True]
|
||||
|
||||
|
||||
def test_strand_row_marks_a_legacy_row_with_no_strands_key_is_read_as_full():
|
||||
row = {'from_bottom_in': 1.75, 'note': '1 3/4"'}
|
||||
assert specformat.strand_row_marks(row, 7) == [True] * 7
|
||||
|
||||
|
||||
def test_strand_row_marks_treats_various_blank_spellings_as_empty():
|
||||
row = {'strands': ['X', '', 'null', 'None', '.', '-', '0']}
|
||||
assert specformat.strand_row_marks(row, 7) == \
|
||||
[True, False, False, False, False, False, False]
|
||||
|
||||
|
||||
def test_strand_row_marks_short_row_pads_with_false():
|
||||
row = {'strands': ['X']}
|
||||
assert specformat.strand_row_marks(row, 3) == [True, False, False]
|
||||
|
||||
|
||||
def test_strand_diagram_matches_wp1s_real_geometry():
|
||||
lines = specformat.strand_diagram(WP1_LAYOUT_NEW)
|
||||
# datum line, ruler, one line per row (furthest from bottom first), and
|
||||
# the columns summary.
|
||||
assert 'left_rail' in lines[0]
|
||||
assert '143' in lines[0]
|
||||
assert lines[1].startswith('+') and lines[1].endswith('+')
|
||||
assert '6.25" from bottom (7 strands)' in lines[2]
|
||||
assert '1.75" from bottom (7 strands)' in lines[3]
|
||||
assert lines[-1] == ('columns (in, from datum): '
|
||||
'3.5, 16.5, 40.5, 59.5, 87.5, 109.5, 132.5')
|
||||
|
||||
|
||||
def test_strand_diagram_row_order_is_furthest_from_bottom_first():
|
||||
# The picture reads top-down like the elevation it stands in for.
|
||||
lines = specformat.strand_diagram(WP1_LAYOUT_NEW)
|
||||
row_lines = [ln for ln in lines if 'from bottom' in ln]
|
||||
heights = [float(ln.split('"')[0].split()[-1]) for ln in row_lines]
|
||||
assert heights == sorted(heights, reverse=True)
|
||||
|
||||
|
||||
def test_strand_diagram_incomplete_data_says_so_instead_of_guessing():
|
||||
lines = specformat.strand_diagram({'datum': 'left_rail'})
|
||||
assert any('not enough data' in ln for ln in lines)
|
||||
|
||||
|
||||
def test_strand_diagram_not_a_dict_is_handled():
|
||||
assert specformat.strand_diagram(None) == ['(no strand layout)']
|
||||
|
||||
|
||||
def test_strand_diagram_marks_a_partial_row_with_dots():
|
||||
layout = dict(WP1_LAYOUT_NEW)
|
||||
layout['rows'] = [
|
||||
{'from_bottom_in': 1.75, 'strands': ['X', 'X', '.', 'X', 'X', 'X', 'X']},
|
||||
]
|
||||
lines = specformat.strand_diagram(layout)
|
||||
row_line = [ln for ln in lines if 'from bottom' in ln][0]
|
||||
assert '(6 strands)' in row_line
|
||||
|
||||
|
||||
# ------------------------------------------------------------ spec_text IO
|
||||
|
||||
def _sample_spec():
|
||||
spec = _blank_spec()
|
||||
spec['design_number'] = 'WP1'
|
||||
spec['piece_description'] = '8" HORIZONTAL SOLID WALL'
|
||||
spec['source']['extractor'] = 'regex'
|
||||
spec['concrete'] = {'fc_psi': 6000, 'fci_psi': 3500,
|
||||
'unit_weight_pcf': 150, 'weight_class': 'NORMAL'}
|
||||
spec['thickness_in'] = 8
|
||||
spec['reinforcing']['mild_grade_ksi'] = None
|
||||
spec['reinforcing']['strand_groups'] = [
|
||||
{'qty': 14, 'dia_in': 0.5, 'grade_ksi': 270, 'it_lb': 28920,
|
||||
'role': None, 'note': None},
|
||||
]
|
||||
spec['reinforcing']['strand_layout'] = dict(WP1_LAYOUT_NEW)
|
||||
spec['reinforcing']['strand_layout']['note'] = None
|
||||
spec['unresolved'] = [{'text': 'STRAND PATTERN',
|
||||
'reason': 'no extraction rule matched this note'}]
|
||||
return spec
|
||||
|
||||
|
||||
def test_spec_text_round_trips_every_value():
|
||||
spec = _sample_spec()
|
||||
text = specformat.spec_text(spec)
|
||||
back = specformat.parse_spec_text(text)
|
||||
assert back['design_number'] == 'WP1'
|
||||
assert back['concrete']['fc_psi'] == 6000
|
||||
assert back['concrete']['fci_psi'] == 3500
|
||||
assert back['reinforcing']['strand_groups'][0]['it_lb'] == 28920
|
||||
assert back['reinforcing']['strand_layout']['columns_in'] == \
|
||||
WP1_LAYOUT_NEW['columns_in']
|
||||
assert back['reinforcing']['strand_layout']['rows'][0]['strands'] == \
|
||||
['X'] * 7
|
||||
assert back['reinforcing']['strand_layout']['total_in'] == 143.0
|
||||
assert back['unresolved'] == spec['unresolved']
|
||||
|
||||
|
||||
def test_spec_text_carries_the_matrix_diagram_as_comments_only():
|
||||
spec = _sample_spec()
|
||||
text = specformat.spec_text(spec)
|
||||
assert '# datum: left_rail' in text
|
||||
assert '# columns (in, from datum): 3.5, 16.5' in text
|
||||
# Comments must not survive the read back into data.
|
||||
back = specformat.parse_spec_text(text)
|
||||
assert 'datum: left_rail (rail on the left' not in json.dumps(back)
|
||||
|
||||
|
||||
def test_spec_text_diagram_reflects_the_matrix_it_sits_above():
|
||||
# Change a row height and the rendered diagram in the header changes with
|
||||
# it -- the picture cannot drift from the data because it is drawn from
|
||||
# the data every time spec_text() runs.
|
||||
spec = _sample_spec()
|
||||
spec['reinforcing']['strand_layout']['rows'][1]['from_bottom_in'] = 5.0
|
||||
text = specformat.spec_text(spec)
|
||||
assert '5" from bottom' in text
|
||||
assert '6.25" from bottom' not in text
|
||||
|
||||
|
||||
def test_spec_text_says_machine_read_and_unverified():
|
||||
text = specformat.spec_text(_sample_spec())
|
||||
assert 'read by machine' in text
|
||||
assert 'reviewed_by' in text
|
||||
|
||||
|
||||
def test_spec_text_handles_a_missing_design_number():
|
||||
text = specformat.spec_text({})
|
||||
assert 'WillTools design spec -- ?' in text
|
||||
|
||||
|
||||
def test_parse_spec_text_of_an_empty_string_is_an_empty_dict():
|
||||
assert specformat.parse_spec_text('') == {}
|
||||
|
||||
|
||||
# ------------------------------------------------------------- design_key
|
||||
#
|
||||
# Moved here from `designspec` (which imports the Revit API at module scope)
|
||||
# so that `reinforcebind` can ask "are these the same design?" both inside
|
||||
# Revit and in `tools/reinforce_offline.py`, which runs with no Revit at all.
|
||||
# One implementation, one answer -- a second spelling of this question is how
|
||||
# two answers start. `designspec.design_key` re-exports it and every existing
|
||||
# caller is unchanged.
|
||||
|
||||
|
||||
def test_a_packet_suffix_does_not_split_a_design():
|
||||
"""The nomenclature ruling: the cover sheet, the `.A` note and the bare
|
||||
number on a piece are all ONE design."""
|
||||
for spelling in ('WP1', 'WP1.0', 'WP1.A', 'WP1.B1', 'wp 1.0'):
|
||||
assert specformat.design_key(spelling) == 'WP1'
|
||||
|
||||
|
||||
def test_two_designs_stay_two_designs():
|
||||
assert specformat.design_key('WP1.0') != specformat.design_key('WP2.0')
|
||||
assert specformat.design_key('WP1.0') != specformat.design_key('IWP1.0')
|
||||
|
||||
|
||||
def test_something_the_pattern_cannot_read_is_none_not_a_guess():
|
||||
"""None is COULD NOT READ THIS. It is not a design, and two of them are
|
||||
not a match -- see `reinforcebind.DonorFilter._design_reject`."""
|
||||
for junk in (None, '', ' ', '...', 123):
|
||||
assert specformat.design_key(junk) is None
|
||||
|
||||
|
||||
def test_a_pattern_may_be_handed_in_instead_of_a_knowledge_tree():
|
||||
"""`reinforcebind` has no knowledge layer offline, so the plant's
|
||||
`nomenclature.design_number_re` is passed as a value."""
|
||||
assert specformat.design_key('WP1.0', None, r'^([A-Za-z]+)') == 'WP'
|
||||
# A pattern that will not compile falls back rather than raising: this is
|
||||
# a reduction, not a place to take a run down over a typo in a YAML file.
|
||||
assert specformat.design_key('WP1.0', None, '([') == 'WP1'
|
||||
@@ -0,0 +1,274 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for `vibe.specsave` -- where an extracted spec is copied to, and what
|
||||
the button says is still unresolved in it.
|
||||
|
||||
The module is deliberately Revit-free (paths and text in, paths and text out),
|
||||
so all of it is reachable from host CPython through the conftest trick. What
|
||||
is worth testing here is not that it formats a string: it is the three rules
|
||||
that would be invisible if they broke --
|
||||
|
||||
* the file name box has to MEAN something for a single design, and the
|
||||
folder has to be what is used for several;
|
||||
* a `file:///` href with forward slashes, because a bare Windows path is a
|
||||
dead link in pyRevit's output panel and a dead link looks exactly like a
|
||||
live one;
|
||||
* a blank field must NOT force a fold open, or every section opens on every
|
||||
press and the fold's contract is worth nothing.
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
|
||||
from vibe import specsave
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- file names
|
||||
|
||||
def test_safe_name_keeps_a_design_number_readable():
|
||||
assert specsave.safe_name('PNL1.0') == 'PNL1.0'
|
||||
assert specsave.safe_name('WP 12-A') == 'WP 12-A'
|
||||
|
||||
|
||||
def test_safe_name_replaces_what_a_path_cannot_hold():
|
||||
assert specsave.safe_name('PNL/1') == 'PNL_1'
|
||||
assert specsave.safe_name('') == 'unnamed'
|
||||
|
||||
|
||||
def test_default_name_is_the_design_when_there_is_one():
|
||||
assert specsave.default_name(['PNL1']) == 'PNL1.yaml'
|
||||
|
||||
|
||||
def test_default_name_counts_when_there_are_several():
|
||||
assert specsave.default_name(['PNL1', 'PNL2', 'PNL3']) == \
|
||||
'design-extraction-3-designs.yaml'
|
||||
|
||||
|
||||
def test_default_name_survives_an_empty_list():
|
||||
assert specsave.default_name([]) == 'design-extraction.yaml'
|
||||
|
||||
|
||||
# --------------------------------------------------------------- where it goes
|
||||
|
||||
def test_one_design_is_written_to_exactly_the_path_that_was_typed():
|
||||
# The name box has to mean something. A dialog whose file name is quietly
|
||||
# ignored is the trap CLAUDE.md names about the config file.
|
||||
got = specsave.targets(r'C:\jobs\my-reading.yaml', ['PNL1'])
|
||||
assert got == [('PNL1', r'C:\jobs\my-reading.yaml')]
|
||||
|
||||
|
||||
def test_several_designs_take_the_folder_and_keep_their_own_names():
|
||||
got = specsave.targets(os.path.join('C:', 'jobs', 'whatever.yaml'),
|
||||
['PNL1', 'PNL2'])
|
||||
assert [d for d, _p in got] == ['PNL1', 'PNL2']
|
||||
assert [os.path.basename(p) for _d, p in got] == ['PNL1.yaml', 'PNL2.yaml']
|
||||
assert set(os.path.dirname(p) for _d, p in got) == \
|
||||
{os.path.join('C:', 'jobs')}
|
||||
|
||||
|
||||
def test_several_designs_keep_the_extension_that_was_chosen():
|
||||
got = specsave.targets(os.path.join('C:', 'jobs', 'x.txt'),
|
||||
['PNL1', 'PNL2'])
|
||||
assert [os.path.basename(p) for _d, p in got] == ['PNL1.txt', 'PNL2.txt']
|
||||
|
||||
|
||||
def test_no_path_means_no_targets():
|
||||
assert specsave.targets('', ['PNL1']) == []
|
||||
assert specsave.targets('C:/x.yaml', []) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- links
|
||||
|
||||
def test_href_is_a_url_not_a_windows_path():
|
||||
# This is the bug the fix exists for: backslashes and no scheme render as
|
||||
# a link and navigate nowhere.
|
||||
assert specsave.href(r'C:\jobs\PNL1.yaml') == 'file:///C:/jobs/PNL1.yaml'
|
||||
|
||||
|
||||
def test_same_file_spots_a_save_onto_the_original(tmp_path):
|
||||
# The dialog opens on the folder the specs already live in, so pressing
|
||||
# Save without moving is an ordinary accident. It must not be reported as
|
||||
# a copy to somewhere else.
|
||||
one = str(tmp_path / 'PNL1.yaml')
|
||||
assert specsave.same_file(one, one)
|
||||
# The same file reached the other way round the separator.
|
||||
assert specsave.same_file(one, one.replace('\\', '/'))
|
||||
assert not specsave.same_file(one, str(tmp_path / 'other' / 'PNL1.yaml'))
|
||||
|
||||
|
||||
def test_no_path_is_never_the_same_file():
|
||||
assert not specsave.same_file('', '')
|
||||
assert not specsave.same_file('C:/x.yaml', '')
|
||||
|
||||
|
||||
def test_link_shows_the_real_path_and_hrefs_the_url():
|
||||
got = specsave.link(r'C:\jobs\PNL1.yaml')
|
||||
assert got == r'[C:\jobs\PNL1.yaml](file:///C:/jobs/PNL1.yaml)'
|
||||
# The visible half keeps the backslashes -- that is what gets copied into
|
||||
# Explorer.
|
||||
assert got.split('](')[0] == r'[C:\jobs\PNL1.yaml'
|
||||
|
||||
|
||||
# ------------------------------------------------------- remembering a folder
|
||||
|
||||
def test_recall_is_empty_when_nothing_was_ever_saved(tmp_path):
|
||||
assert specsave.recall_dir(str(tmp_path)) == ''
|
||||
|
||||
|
||||
def test_remember_then_recall(tmp_path):
|
||||
folder = tmp_path / 'somewhere'
|
||||
folder.mkdir()
|
||||
specsave.remember_dir(str(tmp_path), str(folder))
|
||||
assert specsave.recall_dir(str(tmp_path)) == str(folder)
|
||||
|
||||
|
||||
def test_a_pointer_to_a_folder_that_is_gone_is_dropped(tmp_path):
|
||||
# Seeding a dialog with a path that no longer exists is how a file lands
|
||||
# somewhere nobody meant.
|
||||
pointer = tmp_path / specsave.POINTER
|
||||
with io.open(str(pointer), 'w', encoding='utf-8') as fh:
|
||||
fh.write(u'Z:\\not\\here')
|
||||
assert specsave.recall_dir(str(tmp_path)) == ''
|
||||
|
||||
|
||||
def test_remembering_never_raises(tmp_path):
|
||||
assert specsave.remember_dir(str(tmp_path / 'no-such-dir'), 'C:\\x') == ''
|
||||
assert specsave.remember_dir(str(tmp_path), '') == ''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- unresolved
|
||||
|
||||
def test_unresolved_entries_read_text_and_reason():
|
||||
spec = {'unresolved': [{'text': 'USE MC STANDARD', 'reason': 'no rule'}]}
|
||||
assert specsave.unresolved_entries(spec) == [('USE MC STANDARD', 'no rule')]
|
||||
|
||||
|
||||
def test_a_bare_string_entry_still_counts():
|
||||
# An older extractor could write a plain string. The one list whose job is
|
||||
# not losing lines must not lose a line to its own shape.
|
||||
assert specsave.unresolved_entries({'unresolved': ['SOMETHING']}) == \
|
||||
[('SOMETHING', '')]
|
||||
|
||||
|
||||
def test_entries_with_no_text_are_dropped():
|
||||
spec = {'unresolved': [{'reason': 'why'}, {'text': '', 'reason': 'x'}]}
|
||||
assert specsave.unresolved_entries(spec) == []
|
||||
|
||||
|
||||
def test_no_unresolved_section_at_all_is_not_an_error():
|
||||
assert specsave.unresolved_entries({}) == []
|
||||
assert specsave.unresolved_entries({'unresolved': None}) == []
|
||||
|
||||
|
||||
def test_shared_reason_when_every_note_gives_the_same_one():
|
||||
entries = [('a', 'no rule matched'), ('b', 'no rule matched')]
|
||||
assert specsave.shared_reason(entries) == 'no rule matched'
|
||||
|
||||
|
||||
def test_no_shared_reason_when_they_differ():
|
||||
assert specsave.shared_reason([('a', 'one'), ('b', 'two')]) == ''
|
||||
assert specsave.shared_reason([]) == ''
|
||||
|
||||
|
||||
# --------------------------------------------------------------- blank fields
|
||||
|
||||
def _filled():
|
||||
return {
|
||||
'concrete': {'fc_psi': 6000, 'fci_psi': 3500, 'unit_weight_pcf': 150},
|
||||
'thickness_in': 8,
|
||||
'envelope': {'length': {'max_ft': 49.25}, 'width': {'max_ft': 10}},
|
||||
'reinforcing': {
|
||||
'wwf': {'mesh': '6x6 W2.9'},
|
||||
'strand_groups': [{'qty': 14, 'dia_in': 0.5}],
|
||||
'strand_layout': {'columns_in': [4, 16, 28]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_a_full_spec_has_nothing_blank():
|
||||
assert specsave.blank_fields(_filled()) == []
|
||||
|
||||
|
||||
def test_a_missing_number_is_named_by_its_dotted_path():
|
||||
spec = _filled()
|
||||
spec['concrete']['fci_psi'] = None
|
||||
assert specsave.blank_fields(spec) == ['concrete.fci_psi']
|
||||
|
||||
|
||||
def test_an_empty_list_counts_as_blank():
|
||||
spec = _filled()
|
||||
spec['reinforcing']['strand_layout']['columns_in'] = []
|
||||
assert specsave.blank_fields(spec) == \
|
||||
['reinforcing.strand_layout.columns_in']
|
||||
|
||||
|
||||
def test_an_empty_spec_names_every_field_rather_than_erroring():
|
||||
assert len(specsave.blank_fields({})) == \
|
||||
len(specsave.KEY_FIELDS) + len(specsave.LIST_FIELDS)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ what opens a fold
|
||||
|
||||
def test_nothing_wrong_means_no_problem_sentence():
|
||||
assert specsave.problem_sentence([], []) is None
|
||||
|
||||
|
||||
def test_unresolved_notes_open_the_section():
|
||||
sentence = specsave.problem_sentence([('a', 'x')], [])
|
||||
assert sentence and '1 note' in sentence
|
||||
|
||||
|
||||
def test_validation_problems_open_the_section():
|
||||
sentence = specsave.problem_sentence([], ["missing concrete.fc_psi"])
|
||||
assert sentence and 'validation problem' in sentence
|
||||
|
||||
|
||||
def test_blank_fields_alone_do_not_open_the_section():
|
||||
# THE REGRESSION THIS GUARDS. The mechanical reader leaves the strand
|
||||
# matrix empty on every spec it writes, so opening on a blank field would
|
||||
# open every section on every press, and "if nothing is open, nothing
|
||||
# needs you" would stop being true.
|
||||
assert specsave.problem_sentence([], []) is None
|
||||
|
||||
|
||||
def test_summary_line_says_both_numbers():
|
||||
assert specsave.summary_line([('a', '')], ['x', 'y']) == \
|
||||
'1 unresolved note, 2 fields blank'
|
||||
|
||||
|
||||
# ------------------------------------------- nobody branches on the old value
|
||||
#
|
||||
# Specs on disk carry `source.extractor` values written by earlier versions
|
||||
# of the reader. Every reader has to tolerate any of them, and the way to
|
||||
# keep that true is to have no reader that compares the field to a literal
|
||||
# at all.
|
||||
|
||||
# Two shapes: a comparison on a line that names the field, and a comparison
|
||||
# against one of the values only this field ever holds. 'regex' is NOT in the
|
||||
# second list on purpose -- `ticketqc` compares against it legitimately
|
||||
# (whether a spec FILE existed at all is answered by `source == 'regex'`),
|
||||
# and a guard that fires on a legal comparison is a guard that gets deleted.
|
||||
_EXT_COMPARE = re.compile(r"extractor[^\n]{0,60}[=!]=|"
|
||||
r"[=!]=[^\n]{0,60}extractor|"
|
||||
r"[=!]=\s*u?['\"](legacy|manual|older)['\"]")
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def test_no_code_branches_on_an_extractor_literal():
|
||||
offenders = []
|
||||
for folder, dirs, files in os.walk(_ROOT):
|
||||
dirs[:] = [d for d in dirs if d not in ('__pycache__', 'tests')]
|
||||
for name in files:
|
||||
if not name.endswith('.py'):
|
||||
continue
|
||||
path = os.path.join(folder, name)
|
||||
with io.open(path, 'r', encoding='utf-8') as fh:
|
||||
for n, line in enumerate(fh, 1):
|
||||
if line.lstrip().startswith('#'):
|
||||
continue
|
||||
if _EXT_COMPARE.search(line):
|
||||
offenders.append('{0}:{1}'.format(path, n))
|
||||
assert offenders == [], (
|
||||
'these compare an extractor value to a literal; a spec written by an '
|
||||
'older build says "claude-code" and would take the other branch: '
|
||||
+ ', '.join(offenders))
|
||||
@@ -0,0 +1,197 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The handling stress limit, from the window down to the job file.
|
||||
|
||||
`handling.tension_limit_sqrt_fc` multiplies sqrt(f'c) to give the tensile
|
||||
allowable in psi, and there are two defensible answers: 5.0 -- the modulus of
|
||||
rupture over a 1.5 safety factor, "no discernible cracking" -- and 7.5, the
|
||||
rupture modulus itself, first cracking with no margin. They are a factor of
|
||||
1.5 apart on EVERY stress verdict, which is the largest single source of
|
||||
disagreement between this tool and a commercial check of the same panel.
|
||||
|
||||
The owner would not pick one for everybody, 2026-08-30:
|
||||
|
||||
"应力极限写入 projectconfig 里面的 lifting&handling 板块里面去,让用户自己
|
||||
填写。I can see it both ways."
|
||||
|
||||
So it moved OUT of `configform.NOT_SHOWN` and INTO the lifting & handling
|
||||
module as a number box. What is tested here is the half that makes that
|
||||
honest: the key a person types into is the key `liftexport` reads, and the
|
||||
value reaches every stage of the job file panellift is handed. A field that
|
||||
writes a key nothing reads is the drift `tools/knowledge_drift.py` exists to
|
||||
catch, and it has caught it before.
|
||||
"""
|
||||
from vibe import configform, configmodel, knowledge, liftexport
|
||||
|
||||
from test_liftexport import FACE_AND_EDGE_LIFTERS, make_job, make_know
|
||||
|
||||
|
||||
KEY = 'handling.tension_limit_sqrt_fc'
|
||||
|
||||
|
||||
# ------------------------------------------------------- it is in the window
|
||||
|
||||
def test_the_stress_limit_is_a_field_in_lifting_and_handling():
|
||||
"""The owner's ruling, as a placement. It used to be in NOT_SHOWN with
|
||||
the reason 'a stress limit is argued for once and then left alone' --
|
||||
which was an argument for having a doctrine, and he declined to have
|
||||
one."""
|
||||
assert configform.MODULE_OF[KEY] == configform.LIFTING
|
||||
assert KEY not in configform.NOT_SHOWN
|
||||
|
||||
|
||||
def test_it_is_a_box_you_type_a_number_into():
|
||||
field = [f for f in configform.settings_fields() if f['key'] == KEY][0]
|
||||
assert field['kind'] == 'number'
|
||||
ok, value, _why = configform.parse(field, '7.5')
|
||||
assert ok and value == 7.5
|
||||
|
||||
|
||||
def test_the_row_note_puts_both_reference_values_in_front_of_the_typist():
|
||||
"""A box that says 'stress limit' and nothing else asks a person to
|
||||
remember which two numbers are argued about. The whole reason this is a
|
||||
field rather than a doctrine is that the choice is the engineer's, so
|
||||
the window has to show what is being chosen between."""
|
||||
field = [f for f in configform.settings_fields() if f['key'] == KEY][0]
|
||||
note = field['note']
|
||||
assert '5.0' in note and '7.5' in note
|
||||
assert "sqrt(f'c)" in note # what the number multiplies
|
||||
assert 'psi' in note
|
||||
assert 'cracking' in note # and what each end of it means
|
||||
|
||||
|
||||
def test_the_full_view_says_where_it_is_edited_now():
|
||||
"""`configpage` reads `placement()`, so the two halves of the answer
|
||||
come from one table. Before the move this key was absent from it and
|
||||
the page said 'the file'."""
|
||||
assert configform.placement()[KEY] == 'Lifting and handling'
|
||||
|
||||
|
||||
def test_nothing_fell_through_the_move():
|
||||
"""The guard that makes the not-shown pool honest: every editable
|
||||
setting is in a module or has a written reason for not being."""
|
||||
assert configform.unmapped() == []
|
||||
assert len(configform.not_shown_rows()) >= 8
|
||||
assert KEY not in [key for _label, key, _why
|
||||
in configform.not_shown_rows()]
|
||||
|
||||
|
||||
# ------------------------------------------------------------ what it refuses
|
||||
|
||||
def test_a_zero_or_negative_limit_is_refused():
|
||||
"""The only refusal, and it is not an opinion: a zero allowable fails
|
||||
every piece at any stress, and a negative one inverts the comparison so
|
||||
that every piece passes."""
|
||||
assert configmodel.validate(KEY, 0)
|
||||
assert configmodel.validate(KEY, -5.0)
|
||||
assert 'greater than zero' in ' '.join(configmodel.validate(KEY, 0))
|
||||
|
||||
|
||||
def test_both_schools_are_accepted_without_argument():
|
||||
"""5.0 and 7.5 are each somebody's defensible position. The validator
|
||||
does not get to have a doctrine the owner declined to have."""
|
||||
assert configmodel.validate(KEY, 5.0) == []
|
||||
assert configmodel.validate(KEY, 7.5) == []
|
||||
|
||||
|
||||
def test_the_extremes_are_warned_about_and_still_allowed():
|
||||
"""Above 7.5 and below 3 are worth saying something about; neither is
|
||||
refused, because a refusal here would be the tool taking the position
|
||||
the owner left to the engineer."""
|
||||
assert configmodel.validate(KEY, 9.0) == []
|
||||
assert configmodel.validate(KEY, 2.0) == []
|
||||
high = ' '.join(configmodel.warnings(KEY, 9.0))
|
||||
low = ' '.join(configmodel.warnings(KEY, 2.0))
|
||||
assert 'rupture' in high and high
|
||||
assert 'conservative' in low and low
|
||||
|
||||
|
||||
def test_seven_point_five_says_what_it_is():
|
||||
said = ' '.join(configmodel.warnings(KEY, 7.5))
|
||||
assert 'first cracking' in said and 'no margin' in said
|
||||
|
||||
|
||||
def test_the_ordinary_values_say_nothing():
|
||||
"""A warning on every value is a warning nobody reads."""
|
||||
assert configmodel.warnings(KEY, 5.0) == []
|
||||
assert configmodel.warnings(KEY, 6.0) == []
|
||||
assert configmodel.warnings(KEY, 3.5) == []
|
||||
|
||||
|
||||
# ------------------------------------------------- it reaches the calculation
|
||||
|
||||
def test_the_default_is_still_five():
|
||||
"""Byte-identity when nobody sets it. Every job configured today has no
|
||||
override, and this is the number they have all been running."""
|
||||
assert knowledge.get(knowledge.DEFAULTS, KEY) == 5.0
|
||||
|
||||
|
||||
def test_the_job_layer_value_lands_on_every_stage_of_the_job_file():
|
||||
"""The chain, as one assertion: knowledge -> default_stages -> the
|
||||
stages panellift is handed. This is the link that makes the field real
|
||||
rather than a knob nothing turns."""
|
||||
know = make_know()
|
||||
know['handling']['tension_limit_sqrt_fc'] = 7.5
|
||||
stages, _rows, _problems = liftexport.default_stages(
|
||||
make_job(FACE_AND_EDGE_LIFTERS), know)
|
||||
assert stages
|
||||
assert all(s['tension_limit_sqrt_fc'] == 7.5 for s in stages)
|
||||
|
||||
|
||||
def test_an_unset_limit_writes_the_five_it_always_wrote():
|
||||
stages, _rows, _problems = liftexport.default_stages(
|
||||
make_job(FACE_AND_EDGE_LIFTERS), make_know())
|
||||
assert all(s['tension_limit_sqrt_fc'] == 5.0 for s in stages)
|
||||
assert all(s['tension_limit_source'] == 'job' for s in stages)
|
||||
|
||||
|
||||
def test_a_stage_that_states_its_own_limit_is_marked_as_the_override():
|
||||
"""A `handling.stages` entry beats the job-wide field. The engineer who
|
||||
typed 5.0 into Project Config and reads 7.5 on the report has to be able
|
||||
to see why on the page rather than by opening the YAML."""
|
||||
know = make_know()
|
||||
know['handling']['tension_limit_sqrt_fc'] = 5.0
|
||||
know['handling']['stages'][0]['tension_limit_sqrt_fc'] = 7.5
|
||||
stages, _rows, _problems = liftexport.default_stages(
|
||||
make_job(FACE_AND_EDGE_LIFTERS), know)
|
||||
assert stages[0]['tension_limit_sqrt_fc'] == 7.5
|
||||
assert stages[0]['tension_limit_source'] == 'stage'
|
||||
assert stages[1]['tension_limit_sqrt_fc'] == 5.0
|
||||
assert stages[1]['tension_limit_source'] == 'job'
|
||||
|
||||
|
||||
def test_the_provenance_words_are_the_ones_panellift_reads():
|
||||
"""Written on one side, refused on the other if they disagree. panellift
|
||||
raises on an unknown word rather than printing it, so the two lists have
|
||||
to be the same list."""
|
||||
stages, _rows, _problems = liftexport.default_stages(
|
||||
make_job(FACE_AND_EDGE_LIFTERS), make_know())
|
||||
assert set(s['tension_limit_source'] for s in stages) <= \
|
||||
set(['job', 'stage', 'default'])
|
||||
|
||||
|
||||
# ------------------------------------------------- and the warning is spoken
|
||||
|
||||
def _button_source():
|
||||
import io
|
||||
from test_configform import SCRIPT
|
||||
return io.open(SCRIPT, encoding='utf-8').read()
|
||||
|
||||
|
||||
def test_the_window_actually_calls_warnings():
|
||||
"""`configmodel.warnings()` had no caller outside the test suite -- every
|
||||
sentence in it was written and never shown. This setting is the reason it
|
||||
needs one: 5.0 and 7.5 are both defensible so neither may be refused, and
|
||||
the only thing left to do is say what the number means before it is
|
||||
written."""
|
||||
source = _button_source()
|
||||
assert 'configmodel.warnings(' in source
|
||||
|
||||
|
||||
def test_a_warning_is_not_dressed_as_a_refusal():
|
||||
"""The two read the same on screen and mean opposite things. The window
|
||||
paints the line so it gets read and words it so it is not mistaken for a
|
||||
rejection, and it survives the window closing into the summary."""
|
||||
source = _button_source()
|
||||
assert 'ALLOWED, WITH A NOTE' in source
|
||||
assert 'Allowed, with a note' in source
|
||||
assert 'self.cautioned' in source
|
||||
@@ -0,0 +1,366 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The design-summary vocabulary -- rows 8.1 and 5.9.
|
||||
|
||||
WHAT THIS FILE IS PINNING. Row 8.1 reported SEVEN uninterpreted lines on a
|
||||
real insulated piece and row 5.9 stood down on every press because one of
|
||||
those seven WAS the requirement it needed. The grammar in
|
||||
`ticketpage.parse_summary_line` closes both, and every production in it was
|
||||
drawn from a corpus rather than from one line:
|
||||
|
||||
(a) the live design-summary lines the evidence files recorded as
|
||||
unresolved -- 9 distinct, 7 of them on the piece the owner read;
|
||||
(b) 54 distinct `... @ <spacing> O.C. ...` call-outs over 75 occurrences
|
||||
in the plant standards manual extractions.
|
||||
|
||||
Both counted 2026-08-31. The lines below are quoted for GRAMMAR only: a bar
|
||||
size, a product code and a pitch are naming conventions, not identities
|
||||
(CLAUDE.md ss3).
|
||||
|
||||
THE TWO INVARIANTS THIS FILE EXISTS TO KEEP, and they are the ones a future
|
||||
"tidy up" will attack first:
|
||||
|
||||
* a line the grammar cannot place STAYS an 8.1 finding, verbatim;
|
||||
* a line it places but cannot VALUE (`@ "S" O.C.`, `@ __ O.C.`) is not a
|
||||
line it read -- it carries `spacing_in: None` and a reason, and nothing
|
||||
may compare a piece against it.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import conftest # noqa: F401 -- installs the bare `vibe` package stub
|
||||
|
||||
from vibe import ticketpage as TP # noqa: E402
|
||||
from vibe import knowledge as K # noqa: E402
|
||||
|
||||
|
||||
# The seven lines of one real design summary, as the evidence file recorded
|
||||
# them. This is the golden the whole exercise is measured against.
|
||||
LIVE_SUMMARY = [
|
||||
u'STRAND PATTERN',
|
||||
u'CL STRANDS',
|
||||
u'#3S @ 18" O.C. +(2)@3" EA. END',
|
||||
u'CL #7 BARS NEEDED WHEN THERE ARE OPENINGS',
|
||||
u'CL STRANDS & #7 BARS',
|
||||
u"HK ST100'S @1'-4\" O.C.",
|
||||
u'CL #3 BARS@ TOP WYTHE',
|
||||
]
|
||||
|
||||
|
||||
def test_the_seven_lines_the_owner_read_are_all_placed():
|
||||
"""8.1 went from seven uninterpreted lines to none, and it did it by
|
||||
understanding them -- not by dropping any."""
|
||||
placed, unplaced = TP.classify_summary_lines(LIVE_SUMMARY)
|
||||
assert unplaced == []
|
||||
assert len(placed) == 7
|
||||
kinds = sorted([p['kind'] for p in placed])
|
||||
assert kinds == ['conditional', 'label', 'placement', 'placement',
|
||||
'placement', 'spacing', 'spacing']
|
||||
|
||||
|
||||
def test_the_inventory_names_what_each_line_was_taken_to_be():
|
||||
"""A row that went from seven red lines to green with no sentence under
|
||||
it would have swapped one silence for a quieter one."""
|
||||
placed, _ = TP.classify_summary_lines(LIVE_SUMMARY)
|
||||
said = TP.summary_requirement_inventory(placed)
|
||||
assert '2 spacing requirements' in said
|
||||
assert '3 placement statements' in said
|
||||
assert '1 conditional' in said
|
||||
assert '1 group label' in said
|
||||
|
||||
|
||||
# ----------------------------------------------------- the spacing production
|
||||
|
||||
def test_the_field_spacing_is_the_first_at_and_the_rest_is_a_qualifier():
|
||||
"""`#3S @ 18" O.C. +(2)@3" EA. END` states ONE field spacing and then an
|
||||
end densification. A reader that took the LAST `@` would report the end
|
||||
pair as the requirement and fail every piece that is correct."""
|
||||
req = TP.parse_summary_line(u'#3S @ 18" O.C. +(2)@3" EA. END')
|
||||
assert req['kind'] == 'spacing'
|
||||
assert req['spacing_in'] == 18.0
|
||||
assert req['product'] == '#3S'
|
||||
assert req['qualifiers'] == [u'+(2)@3" EA. END']
|
||||
|
||||
|
||||
def test_feet_and_inches_and_bare_inches_and_fractions_all_read():
|
||||
"""22 of the 54 manual call-outs write feet-and-inches, 18 write bare
|
||||
inches, and several of those carry a vulgar fraction glyph -- which is
|
||||
the one that silently turns 1 1/2 into 1 if nobody spells it out."""
|
||||
cases = [(u"HK ST100'S @1'-4\" O.C.", 16.0),
|
||||
(u'#3 E.L. @ 4\'-0" O.C.', 48.0),
|
||||
(u'#4 REBAR @ 48" O.C.', 48.0),
|
||||
(u'(2) #4 E.L. @ 1½" O.C.', 1.5),
|
||||
(u'(2) #4 E.L. @ 1 1/2" O.C.', 1.5),
|
||||
(u'30 SPACES @ 7½" O.C.', 7.5)]
|
||||
for text, inches in cases:
|
||||
req = TP.parse_summary_line(text)
|
||||
assert req is not None and req['kind'] == 'spacing', text
|
||||
assert req['spacing_in'] == inches, text
|
||||
|
||||
|
||||
def test_a_symbolic_spacing_is_recognised_and_never_valued():
|
||||
"""`@ "S" O.C.` is a COLUMN OF A TABLE on the sheet -- 7 occurrences over
|
||||
5 distinct call-outs in the corpus. A reader that scanned for the first
|
||||
digits would answer it from the plate size to the left of the `@`."""
|
||||
req = TP.parse_summary_line(u'PLATE 3/8"x4" x 0\'-4" (P410) @ "S" O.C.')
|
||||
assert req['kind'] == 'spacing'
|
||||
assert req['spacing_in'] is None
|
||||
assert 'table' in req['spacing_unread_because']
|
||||
|
||||
|
||||
def test_a_blank_spacing_is_recognised_and_never_valued():
|
||||
"""The sheet carries a literal `__`. The design has stated the SHAPE of
|
||||
the requirement and not its value, and those are different."""
|
||||
req = TP.parse_summary_line(u'#4 REBAR U-BEND @ __ O.C.')
|
||||
assert req['kind'] == 'spacing'
|
||||
assert req['spacing_in'] is None
|
||||
assert 'blank' in req['spacing_unread_because']
|
||||
|
||||
|
||||
def test_an_unvalued_spacing_is_called_out_in_the_inventory():
|
||||
"""It must not hide inside a count of lines the tool "read"."""
|
||||
placed, _ = TP.classify_summary_lines(
|
||||
[u'PLATE (P410) @ "S" O.C.', u'#4 @ 12" O.C.'])
|
||||
said = TP.summary_requirement_inventory(placed)
|
||||
assert 'carry no numeric spacing' in said
|
||||
assert 'P410' in said
|
||||
|
||||
|
||||
# --------------------------------------------- placement, conditional, label
|
||||
|
||||
def test_cl_is_a_placement_and_the_wythe_is_its_zone():
|
||||
"""`CL #3 BARS@ TOP WYTHE` is a bar size, a placement (centred) and a
|
||||
wythe -- QNA/proposed-checks-2026-08-31.md P12. The `@` here carries the
|
||||
zone and not a spacing, which is why the spacing production must not
|
||||
match a line with no `O.C.` on it."""
|
||||
req = TP.parse_summary_line(u'CL #3 BARS@ TOP WYTHE')
|
||||
assert req['kind'] == 'placement'
|
||||
assert req['placement'] == 'centred'
|
||||
assert req['items'] == ['#3 BARS']
|
||||
assert req['zone'] == 'TOP WYTHE'
|
||||
|
||||
|
||||
def test_a_placement_naming_two_things_keeps_both():
|
||||
req = TP.parse_summary_line(u'CL STRANDS & #7 BARS')
|
||||
assert req['items'] == ['STRANDS', '#7 BARS']
|
||||
assert req['zone'] is None
|
||||
|
||||
|
||||
def test_a_conditional_wraps_the_requirement_it_qualifies():
|
||||
"""The tool STATES this and does not evaluate it. What it must not do is
|
||||
lose the requirement inside the condition."""
|
||||
req = TP.parse_summary_line(u'CL #7 BARS NEEDED WHEN THERE ARE OPENINGS')
|
||||
assert req['kind'] == 'conditional'
|
||||
assert req['condition'] == 'THERE ARE OPENINGS'
|
||||
assert req['requirement']['kind'] == 'placement'
|
||||
assert req['requirement']['items'] == ['#7 BARS']
|
||||
|
||||
|
||||
def test_the_conditionals_are_printed_and_marked_as_unevaluated():
|
||||
placed, _ = TP.classify_summary_lines(LIVE_SUMMARY)
|
||||
said = TP.summary_requirement_inventory(placed)
|
||||
assert 'does not evaluate' in said
|
||||
assert 'NEEDED WHEN THERE ARE OPENINGS' in said
|
||||
|
||||
|
||||
def test_a_deferral_is_not_a_label_and_stays_unplaced():
|
||||
"""`REINFORCING PER DESIGN` is three capitalised words with no digits --
|
||||
exactly the shape of a heading -- and it is a DEFERRAL. Filing it as a
|
||||
label would be the tool claiming to have understood a line that says the
|
||||
answer is somewhere else."""
|
||||
assert TP.parse_summary_line(u'REINFORCING PER DESIGN') is None
|
||||
placed, unplaced = TP.classify_summary_lines(
|
||||
[u'STRAND PATTERN', u'REINFORCING PER DESIGN'])
|
||||
assert [p['kind'] for p in placed] == ['label']
|
||||
assert unplaced == [u'REINFORCING PER DESIGN']
|
||||
|
||||
|
||||
def test_an_unplaceable_line_comes_back_verbatim_and_in_its_own_shape():
|
||||
"""8.1 prints it and a reader has to be able to find it on the sheet, so
|
||||
a `{text, reason}` entry must come back as that entry and not as a
|
||||
string this function invented."""
|
||||
entry = {'text': u'SOMETHING NOBODY HAS TAUGHT THIS 42', 'reason': u'why'}
|
||||
placed, unplaced = TP.classify_summary_lines([entry])
|
||||
assert placed == []
|
||||
assert unplaced == [entry]
|
||||
assert unplaced[0] is entry
|
||||
|
||||
|
||||
# ----------------------------------------------- 5.9: the design side, joined
|
||||
|
||||
def _plant():
|
||||
"""A plant that declares connectors the way the real one does: part
|
||||
numbers of its own, and an alias onto a published product."""
|
||||
return {
|
||||
'connectors': {
|
||||
'marks': {'group_1': ['R80', 'R81', 'R82'],
|
||||
'group_2': ['R87']},
|
||||
'catalogue_alias': {'R80': 'HK Composites (ST50)',
|
||||
'R82': 'HK Composites (ST100)',
|
||||
'R81': None, 'R87': None},
|
||||
},
|
||||
'tolerances': {'connector_spacing_in': 0.5},
|
||||
}
|
||||
|
||||
|
||||
def test_the_summary_line_joins_to_the_plants_connector_through_its_code():
|
||||
"""The whole point of the exercise: the design says `HK ST100'S`, the
|
||||
plant says `R82 = HK Composites (ST100)`, and ST100 is what joins them.
|
||||
Nothing in the code knows what an ST100 is."""
|
||||
placed, _ = TP.classify_summary_lines(LIVE_SUMMARY)
|
||||
req, why = K.connector_spacing_requirement(_plant(), placed)
|
||||
assert req['spacing_in'] == 16.0
|
||||
assert 'ST100' in why and 'R82' in why
|
||||
|
||||
|
||||
def test_the_join_is_exact_so_one_product_never_answers_for_another():
|
||||
"""`ST50` and `ST100` are both 'HK Composites' and they are not the same
|
||||
part. A substring join would let either answer for the other, which is
|
||||
the `connector_product` lesson one join along."""
|
||||
placed, _ = TP.classify_summary_lines([u"HK ST50'S @2'-0\" O.C."])
|
||||
req, why = K.connector_spacing_requirement(_plant(), placed)
|
||||
assert req['spacing_in'] == 24.0
|
||||
assert 'ST50' in why and 'R80' in why
|
||||
assert 'R82' not in why
|
||||
|
||||
|
||||
def test_a_spacing_that_is_not_about_a_connector_is_refused_by_name():
|
||||
"""A bar's pitch read as a connector's would be a confident wrong
|
||||
answer, and 5.9 FAILS on a shortfall."""
|
||||
placed, _ = TP.classify_summary_lines([u'#3S @ 18" O.C.'])
|
||||
req, why = K.connector_spacing_requirement(_plant(), placed)
|
||||
assert req is None
|
||||
assert 'none of them names a part number' in why
|
||||
assert '#3S @ 18" O.C.' in why
|
||||
|
||||
|
||||
def test_a_connector_line_with_no_number_stands_the_row_down_saying_so():
|
||||
"""The one case a reader must not mistake for agreement: the
|
||||
requirement is real, it is about a connector, and it has no value."""
|
||||
placed, _ = TP.classify_summary_lines([u'HK ST100 PINS @ "S" O.C.'])
|
||||
req, why = K.connector_spacing_requirement(_plant(), placed)
|
||||
assert req is None
|
||||
assert 'ST100' in why
|
||||
assert 'no number to hold this piece to' in why
|
||||
|
||||
|
||||
def test_a_design_that_states_nothing_says_that_and_not_something_else():
|
||||
req, why = K.connector_spacing_requirement(_plant(), [])
|
||||
assert req is None
|
||||
assert 'no line this tool could read as a requirement' in why
|
||||
|
||||
|
||||
def test_a_plant_that_declares_no_connector_cannot_recognise_one():
|
||||
placed, _ = TP.classify_summary_lines(LIVE_SUMMARY)
|
||||
req, why = K.connector_spacing_requirement({}, placed)
|
||||
assert req is None
|
||||
assert 'no layer declares a connector part number' in why
|
||||
|
||||
|
||||
def test_the_tolerance_is_a_key_with_its_provenance_in_the_sentence():
|
||||
"""It was a `+ 0.5` literal inside the comparison. A tolerance that
|
||||
cannot be named cannot be argued with, and one that cannot be resolved
|
||||
must stop the comparison rather than fall back on a constant."""
|
||||
value, why = K.connector_spacing_tolerance(_plant())
|
||||
assert value == 0.5
|
||||
assert 'tolerances.connector_spacing_in' in why
|
||||
value, why = K.connector_spacing_tolerance({'tolerances': {}})
|
||||
assert value is None
|
||||
assert 'tolerances.connector_spacing_in is unset' in why
|
||||
|
||||
|
||||
def test_the_measured_piece_passes_end_to_end():
|
||||
"""THE CALIBRATION, and it is the reason the comparison is against the
|
||||
WIDEST gap. Measured 2026-08-31 on one insulated piece: 16.0 in across
|
||||
the field -- which IS the summary's 1'-4" -- with 9.5 and 6.5 in pairs
|
||||
at the ends. End densification is normal and must never be flagged."""
|
||||
placed, _ = TP.classify_summary_lines(LIVE_SUMMARY)
|
||||
know = _plant()
|
||||
req, why = K.connector_spacing_requirement(know, placed)
|
||||
tolerance, tolerance_why = K.connector_spacing_tolerance(know)
|
||||
severity, message = TP.connectors_sufficient_status(
|
||||
115, None, spacing_in=[9.5, 16.0, 16.0, 16.0, 6.5],
|
||||
required_spacing_in=req['spacing_in'], requirement_note=why,
|
||||
tolerance_in=tolerance, tolerance_why=tolerance_why)
|
||||
assert severity == 'OK'
|
||||
assert '16.0' in message
|
||||
assert 'ST100' in message
|
||||
assert 'tolerances.connector_spacing_in' in message
|
||||
|
||||
|
||||
# ------------------------------------------------- 8.1's two clean answers
|
||||
|
||||
def _spec(lines):
|
||||
return {'unresolved': [{'text': t, 'reason': 'r'} for t in lines]}
|
||||
|
||||
|
||||
def _checks():
|
||||
"""`ticketchecks` needs the inert Revit stub; borrow it and put it back.
|
||||
|
||||
`sys.modules` is process-wide and `test_reinforceoffline.py` asserts that
|
||||
no `Autodesk*` module is loaded, so leaving it behind would make this
|
||||
file tell that test a lie.
|
||||
"""
|
||||
import types
|
||||
added = []
|
||||
if 'Autodesk.Revit.DB' not in sys.modules:
|
||||
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',
|
||||
'StorageType', 'ElementId', 'XYZ', 'Transform',
|
||||
'Options', 'Solid', 'BoundingBoxXYZ'):
|
||||
setattr(db, name, type(str(name), (object,), {}))
|
||||
autodesk.Revit = revit
|
||||
revit.DB = db
|
||||
sys.modules['Autodesk'] = autodesk
|
||||
sys.modules['Autodesk.Revit'] = revit
|
||||
sys.modules['Autodesk.Revit.DB'] = db
|
||||
added = ['Autodesk.Revit.DB', 'Autodesk.Revit', 'Autodesk']
|
||||
try:
|
||||
from vibe import ticketchecks
|
||||
return ticketchecks
|
||||
finally:
|
||||
for name in added:
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
|
||||
def test_81_tells_its_two_clean_answers_apart():
|
||||
""""the extractor placed every line" and "the extractor could not place
|
||||
seven and this vocabulary recognised all seven" are different claims,
|
||||
and the second is a reading. The reader is entitled to know which one he
|
||||
is looking at."""
|
||||
TC = _checks()
|
||||
strong = TC.check_spec_unresolved({}, {}, {}, _spec([]))[0]
|
||||
assert strong.severity == 'OK'
|
||||
assert 'was interpreted' in strong.message
|
||||
weaker = TC.check_spec_unresolved({}, {}, {}, _spec(LIVE_SUMMARY))[0]
|
||||
assert weaker.severity == 'OK'
|
||||
assert 'the summary vocabulary categorised those' in weaker.message
|
||||
assert '7 line(s) categorised' in weaker.message
|
||||
|
||||
|
||||
def test_81_still_warns_and_still_enumerates_what_it_could_not_place():
|
||||
"""The row's job is unparsed lines and it must never learn to shrug."""
|
||||
TC = _checks()
|
||||
found = TC.check_spec_unresolved(
|
||||
{}, {}, {}, _spec([u'STRAND PATTERN', u'REINFORCING PER DESIGN',
|
||||
u'SOMETHING ELSE ENTIRELY 42']))[0]
|
||||
assert found.severity == 'WARN'
|
||||
assert found.message.startswith('2 line(s)')
|
||||
assert 'REINFORCING PER DESIGN' in found.message
|
||||
assert 'SOMETHING ELSE ENTIRELY 42' in found.message
|
||||
# and what it DID place is still visible on the same row
|
||||
assert 'group label' in found.message
|
||||
|
||||
|
||||
def test_81_leaves_the_specs_verbatim_record_alone():
|
||||
"""`spec['unresolved']` is the EXTRACTOR's account of what its own rules
|
||||
could not place. A later reader's opinion written back over it would
|
||||
stop it being evidence."""
|
||||
TC = _checks()
|
||||
spec = _spec(LIVE_SUMMARY)
|
||||
before = [dict(entry) for entry in spec['unresolved']]
|
||||
TC.check_spec_unresolved({}, {}, {}, spec)
|
||||
assert spec['unresolved'] == before
|
||||
@@ -0,0 +1,187 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""`tools/sweep_in_revit.py` obeys the DECLARED scope, and says what it left.
|
||||
|
||||
WHY THIS FILE IS SHAPED LIKE THIS
|
||||
----------------------------------
|
||||
The sweep script cannot be imported. It is written to be sent to a live Revit
|
||||
by `rvt.py exec`, so it imports the Revit API at module scope and its last
|
||||
line is `result = run(doc)` against a `doc` the bridge injects. Importing it
|
||||
here would fail twice over.
|
||||
|
||||
So the source is read, cut at that last line, and the rest executed against
|
||||
stubs for the three modules that genuinely need Revit -- `Autodesk.Revit.DB`,
|
||||
`vibe.designspec` and `vibe.ticketqc`. `vibe.scope` is the REAL module: the
|
||||
whole point is to prove that the declaration in the job file, and nothing
|
||||
else, decides what this sweep measures.
|
||||
|
||||
What is still not covered: `measure()` and `run()`, which need a document.
|
||||
Every mark below is a placeholder -- `PNL` for the pieces in scope, `PNLA`
|
||||
for the near miss, `XX` for somebody else's. A real prefix is job
|
||||
information and belongs in the job knowledge file, never here.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
SWEEP = REPO / 'tools' / 'sweep_in_revit.py'
|
||||
SENTINEL = 'result = run(doc)'
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the harness
|
||||
|
||||
class FakeAssembly(object):
|
||||
def __init__(self, name, eid):
|
||||
self.Name = name
|
||||
self._eid = eid
|
||||
|
||||
|
||||
def _install(monkeypatch, assemblies_by_design):
|
||||
"""Stub only what truly needs Revit, and put it back afterwards."""
|
||||
revit_db = types.ModuleType('Autodesk.Revit.DB')
|
||||
revit_db.ElementId = lambda value: value
|
||||
revit = types.ModuleType('Autodesk.Revit')
|
||||
revit.DB = revit_db
|
||||
autodesk = types.ModuleType('Autodesk')
|
||||
autodesk.Revit = revit
|
||||
monkeypatch.setitem(sys.modules, 'Autodesk', autodesk)
|
||||
monkeypatch.setitem(sys.modules, 'Autodesk.Revit', revit)
|
||||
monkeypatch.setitem(sys.modules, 'Autodesk.Revit.DB', revit_db)
|
||||
|
||||
designspec = types.ModuleType('vibe.designspec')
|
||||
designspec.sub_dir = lambda doc, name: str(doc)
|
||||
|
||||
ticketqc = types.ModuleType('vibe.ticketqc')
|
||||
ticketqc.assemblies_by_design = lambda doc, know: assemblies_by_design
|
||||
ticketqc.eid = lambda asm: asm._eid
|
||||
ticketqc.collect_context = lambda doc: ({}, {})
|
||||
|
||||
vibe = sys.modules['vibe']
|
||||
for name, module in (('designspec', designspec), ('ticketqc', ticketqc)):
|
||||
monkeypatch.setitem(sys.modules, 'vibe.' + name, module)
|
||||
monkeypatch.setattr(vibe, name, module, raising=False)
|
||||
|
||||
source = SWEEP.read_text(encoding='utf-8')
|
||||
assert SENTINEL in source, 'the sweep no longer ends the way this reads it'
|
||||
namespace = {'__name__': 'sweep_in_revit_under_test'}
|
||||
exec(compile(source.split(SENTINEL)[0], str(SWEEP), 'exec'), namespace)
|
||||
return namespace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sweep(monkeypatch):
|
||||
def build(assemblies_by_design):
|
||||
return _install(monkeypatch, assemblies_by_design)
|
||||
return build
|
||||
|
||||
|
||||
def _pieces():
|
||||
return {'PNL1': [FakeAssembly('PNL101', 11), FakeAssembly('PNL102', 12)],
|
||||
'PNLA1': [FakeAssembly('PNLA101', 21)],
|
||||
'XX1': [FakeAssembly('XX101', 31), FakeAssembly('XX102', 32)]}
|
||||
|
||||
|
||||
def _run_enumerate(sweep, know, tmp_path):
|
||||
module = sweep(_pieces())
|
||||
todo_path = str(tmp_path / 'todo.json')
|
||||
scope_path = str(tmp_path / 'scope.json')
|
||||
todo, info = module['enumerate_pieces'](object(), know, todo_path,
|
||||
scope_path)
|
||||
return module, todo, info, json.loads((tmp_path / 'scope.json')
|
||||
.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
# -------------------------------------------------- the declaration governs
|
||||
|
||||
def test_the_declaration_decides_what_is_measured(sweep, tmp_path):
|
||||
_m, todo, _info, _saved = _run_enumerate(
|
||||
sweep, {'scope': {'mark_prefixes': ['PNL']}}, tmp_path)
|
||||
assert sorted(r['mark'] for r in todo) == ['PNL101', 'PNL102']
|
||||
|
||||
|
||||
def test_changing_the_declaration_changes_the_sweep(sweep, tmp_path):
|
||||
"""The hole this closes. The old code filtered on a literal, so editing
|
||||
the job file moved nothing -- the declaration was decoration here while
|
||||
every other consumer honoured it."""
|
||||
_m, todo, _i, _s = _run_enumerate(
|
||||
sweep, {'scope': {'mark_prefixes': ['XX']}}, tmp_path)
|
||||
assert sorted(r['mark'] for r in todo) == ['XX101', 'XX102']
|
||||
|
||||
|
||||
def test_no_declaration_sweeps_everything(sweep, tmp_path):
|
||||
"""An undeclared scope is not an empty scope -- and a sweep that
|
||||
measured nothing at all would look like a model with nothing in it."""
|
||||
_m, todo, info, _s = _run_enumerate(sweep, {}, tmp_path)
|
||||
assert len(todo) == 5
|
||||
assert info['declared'] is False
|
||||
assert 'No scope is declared' in info['line']
|
||||
|
||||
|
||||
def test_a_mark_with_no_readable_prefix_is_swept_not_dropped(sweep, tmp_path):
|
||||
module = sweep({'D1': [FakeAssembly('12345', 41)]})
|
||||
todo, info = module['enumerate_pieces'](
|
||||
object(), {'scope': {'mark_prefixes': ['PNL']}},
|
||||
str(tmp_path / 'todo.json'), str(tmp_path / 'scope.json'))
|
||||
assert [r['mark'] for r in todo] == ['12345']
|
||||
assert 'no readable mark prefix' in info['line']
|
||||
|
||||
|
||||
# ---------------------------------------------- and it says what it skipped
|
||||
|
||||
def test_what_was_left_alone_is_counted_named_and_written_beside_the_jobs(
|
||||
sweep, tmp_path):
|
||||
_m, _todo, info, saved = _run_enumerate(
|
||||
sweep, {'scope': {'mark_prefixes': ['PNL']}}, tmp_path)
|
||||
assert info['left_alone_count'] == 3
|
||||
assert info['assemblies_seen'] == 5
|
||||
assert sorted(info['left_alone']) == [('PNLA', 1), ('XX', 2)]
|
||||
assert 'NOT JUDGED, OUT OF SCOPE' in info['line']
|
||||
assert saved['line'] == info['line'] # the file, not just the return
|
||||
# Named by kind on disk too, where a tuple has become a JSON list.
|
||||
assert sorted(saved['left_alone']) == [['PNLA', 1], ['XX', 2]]
|
||||
|
||||
|
||||
def test_the_near_miss_reaches_the_sweep_output(sweep, tmp_path):
|
||||
_m, _todo, info, _saved = _run_enumerate(
|
||||
sweep, {'scope': {'mark_prefixes': ['PNL']}}, tmp_path)
|
||||
assert 'PNLA' in info['near_miss']
|
||||
assert 'NEAR MISS' in info['headline']
|
||||
|
||||
|
||||
def test_the_headline_fronts_every_return_value_because_the_caller_truncates(
|
||||
sweep, tmp_path):
|
||||
"""`tools/sweep.py` prints `str(out)[:200]`. The scope has to be in
|
||||
those 200 characters or nobody reads it."""
|
||||
module, _todo, info, _saved = _run_enumerate(
|
||||
sweep, {'scope': {'mark_prefixes': ['PNL']}}, tmp_path)
|
||||
headline = module['_scope_headline'](info)
|
||||
assert headline.startswith('NOT JUDGED, OUT OF SCOPE')
|
||||
assert len(headline) < 200
|
||||
|
||||
|
||||
def test_a_sweep_folder_with_no_scope_file_says_so_rather_than_assuming(
|
||||
sweep, tmp_path):
|
||||
"""An older folder was enumerated by the literal. What it left out is
|
||||
genuinely unknown, and inventing a reassuring line would be the silent
|
||||
exclusion this whole mechanism refuses."""
|
||||
module = sweep({})
|
||||
said = module['_scope_headline'](None)
|
||||
assert 'NOT RECORDED' in said
|
||||
assert 're-run' in said
|
||||
|
||||
|
||||
# --------------------------------------------------------- no job literals
|
||||
|
||||
def test_the_sweep_carries_no_hardcoded_mark_prefix():
|
||||
"""The other half of the hole: a job's naming baked into a repository
|
||||
that must not carry one. Catches `startswith('AB')` and its family."""
|
||||
source = SWEEP.read_text(encoding='utf-8')
|
||||
body = source.split('"""', 2)[-1] # past the module docstring
|
||||
assert re.search(r"""startswith\(\s*['"][A-Za-z]""", body) is None
|
||||
assert 'vibe import scope' in source or 'from vibe import' in source
|
||||
assert 'scopelib.of(know)' in source
|
||||
@@ -0,0 +1,206 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""`tools/sweep.py` refuses job files measured from a different model.
|
||||
|
||||
THE INCIDENT, 2026-09-01
|
||||
------------------------
|
||||
A whole sweep -- 184 job files -- carried `source.model` naming one model,
|
||||
while the document open in Revit was a detached copy that had since DIVERGED
|
||||
from it: a notch measured 3.5 in in one and 2.5 in in the other. Nothing
|
||||
looked wrong. Every report rendered, every weight reconciled, and it
|
||||
reconciled because a job file is the complete input to the check: the numbers
|
||||
agree with the file they came from whether or not that file is about the piece
|
||||
in front of you. The two were told apart by hand, and the field that would
|
||||
have answered it in one line was sitting in every job file unread.
|
||||
|
||||
So the guard is a REFUSAL with both model names in it, not a warning line, and
|
||||
"could not ask Revit" is a third answer that is printed and never mistaken for
|
||||
agreement.
|
||||
|
||||
`tools/sweep.py` is plain host CPython -- it drives Revit through `rvt.py` as
|
||||
a subprocess and imports nothing from the API -- so unlike `sweep_in_revit.py`
|
||||
it can simply be loaded by path and called.
|
||||
"""
|
||||
import importlib.util
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
SWEEP_PY = REPO / 'tools' / 'sweep.py'
|
||||
|
||||
|
||||
def _load():
|
||||
spec = importlib.util.spec_from_file_location('_sweep_under_test',
|
||||
str(SWEEP_PY))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
sweep = _load()
|
||||
|
||||
|
||||
def job(model, mark='PNL101'):
|
||||
return (mark, {'panel': {'mark': mark},
|
||||
'source': ({} if model is None else {'model': model})})
|
||||
|
||||
|
||||
# ----------------------------------------------------------- the comparison
|
||||
|
||||
@pytest.mark.parametrize('name,stem', [
|
||||
('sample_job.rvt', 'sample_job'),
|
||||
('sample_job', 'sample_job'),
|
||||
('SAMPLE_JOB.RVT', 'sample_job'),
|
||||
(r'C:\models\sample_job.rvt', 'sample_job'),
|
||||
(' sample_job.rvt ', 'sample_job'),
|
||||
])
|
||||
def test_the_extension_and_the_path_are_not_part_of_the_name(name, stem):
|
||||
"""`source.model` is a basename with `.rvt` on it and `doc.Title` is not,
|
||||
so a literal comparison would call every job file a mismatch."""
|
||||
assert sweep.model_stem(name) == stem
|
||||
|
||||
|
||||
@pytest.mark.parametrize('empty', [None, '', ' ', '.rvt'])
|
||||
def test_a_nameless_model_is_none_and_never_a_match(empty):
|
||||
assert sweep.model_stem(empty) is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the mismatch
|
||||
|
||||
def test_a_job_from_another_model_is_a_mismatch():
|
||||
mismatched, unstamped, reason = sweep.check_source_model(
|
||||
[job('sample_job.rvt')], 'sample_job_detached')
|
||||
assert reason is None and unstamped == []
|
||||
assert mismatched == [('PNL101', 'sample_job.rvt')]
|
||||
|
||||
|
||||
def test_the_same_model_is_not_a_mismatch():
|
||||
mismatched, unstamped, reason = sweep.check_source_model(
|
||||
[job('sample_job.rvt')], 'sample_job')
|
||||
assert (mismatched, unstamped, reason) == ([], [], None)
|
||||
|
||||
|
||||
def test_the_refusal_names_both_models():
|
||||
"""The one thing the message must do. A refusal that says 'wrong model'
|
||||
without saying which two leaves the reader exactly where they were."""
|
||||
mismatched, _unstamped, _reason = sweep.check_source_model(
|
||||
[job('sample_job.rvt'), job('sample_job.rvt', 'PNL102')],
|
||||
'sample_job_detached')
|
||||
message = sweep.model_refusal(mismatched, 'sample_job_detached', 2)
|
||||
assert 'sample_job.rvt' in message
|
||||
assert 'sample_job_detached' in message
|
||||
assert '2 of 2' in message
|
||||
assert 'PNL101' in message
|
||||
assert 'Nothing was checked' in message
|
||||
assert '--any-model' in message
|
||||
|
||||
|
||||
def test_a_job_with_no_source_model_is_unjudged_not_cleared():
|
||||
""""Written before the field existed" is not "measured from this model".
|
||||
It comes back in its own list so the caller can say so."""
|
||||
mismatched, unstamped, reason = sweep.check_source_model(
|
||||
[job(None)], 'sample_job')
|
||||
assert mismatched == [] and reason is None
|
||||
assert unstamped == ['PNL101']
|
||||
|
||||
|
||||
def test_no_open_document_is_could_not_judge_and_says_why():
|
||||
"""The empty `mismatched` here means DID NOT ASK. Reading it as agreement
|
||||
is the whole failure this guard exists for, one level up."""
|
||||
mismatched, unstamped, reason = sweep.check_source_model(
|
||||
[job('sample_job.rvt')], None)
|
||||
assert mismatched == []
|
||||
assert unstamped == ['PNL101']
|
||||
assert reason and 'no open document' in reason
|
||||
|
||||
|
||||
# ------------------------------------------------ the refusal actually fires
|
||||
|
||||
class _Recorder(object):
|
||||
"""A sweep folder on disk is a lot of scaffolding for one question, so
|
||||
`check()` is exercised against a jobs dir and stopped before it ever
|
||||
reaches panellift -- the refusal is meant to happen first."""
|
||||
|
||||
def __init__(self):
|
||||
self.ran = 0
|
||||
|
||||
|
||||
def _sweep_folder(tmp_path, model):
|
||||
jobs = tmp_path / 'jobs'
|
||||
jobs.mkdir()
|
||||
(tmp_path / 'meta').mkdir()
|
||||
(jobs / 'PNL101.job.json').write_text(
|
||||
'{"panel": {"mark": "PNL101"}, "source": {"model": "%s"}}' % model,
|
||||
encoding='utf-8')
|
||||
(tmp_path / 'meta' / 'PNL101.json').write_text(
|
||||
'{"mark": "PNL101", "status": "exported"}', encoding='utf-8')
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_check_refuses_before_it_computes_anything(tmp_path, monkeypatch):
|
||||
root = _sweep_folder(tmp_path, 'sample_job.rvt')
|
||||
seen = _Recorder()
|
||||
|
||||
def explode(*args, **kwargs):
|
||||
seen.ran += 1
|
||||
raise AssertionError('panellift must not run on a mismatched sweep')
|
||||
|
||||
monkeypatch.setattr(sweep.subprocess, 'run', explode)
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
sweep.check(str(root), 'sample_job_detached')
|
||||
assert 'sample_job.rvt' in str(excinfo.value)
|
||||
assert 'sample_job_detached' in str(excinfo.value)
|
||||
assert seen.ran == 0
|
||||
|
||||
|
||||
def test_any_model_proceeds_and_carries_the_caveat_into_the_report(tmp_path,
|
||||
monkeypatch):
|
||||
"""The override is not a way to make the sentence go away: it goes to the
|
||||
top of problems.txt, where `render` puts it."""
|
||||
root = _sweep_folder(tmp_path, 'sample_job.rvt')
|
||||
|
||||
class _Result(object):
|
||||
stdout = '{"verdict": "OK", "stages": []}'
|
||||
stderr = ''
|
||||
|
||||
monkeypatch.setattr(sweep.subprocess, 'run',
|
||||
lambda *a, **k: _Result())
|
||||
rows, notes = sweep.check(str(root), 'sample_job_detached',
|
||||
any_model=True)
|
||||
assert len(rows) == 1
|
||||
assert any('--any-model' in note for note in notes)
|
||||
assert notes[0].startswith('!!')
|
||||
assert notes[0] in sweep.render(rows, [], notes)
|
||||
|
||||
|
||||
def test_an_unreachable_revit_says_the_model_was_not_verified(tmp_path,
|
||||
monkeypatch):
|
||||
"""It does not refuse -- there is nothing to refuse against -- and it does
|
||||
not stay quiet either."""
|
||||
root = _sweep_folder(tmp_path, 'sample_job.rvt')
|
||||
|
||||
class _Result(object):
|
||||
stdout = '{"verdict": "OK", "stages": []}'
|
||||
stderr = ''
|
||||
|
||||
monkeypatch.setattr(sweep.subprocess, 'run',
|
||||
lambda *a, **k: _Result())
|
||||
_rows, notes = sweep.check(str(root), None)
|
||||
assert any('NOT verified' in note for note in notes)
|
||||
|
||||
|
||||
def test_a_matching_model_adds_no_caveat_at_all(tmp_path, monkeypatch):
|
||||
"""The ordinary run is unchanged, down to the absence of a banner."""
|
||||
root = _sweep_folder(tmp_path, 'sample_job.rvt')
|
||||
|
||||
class _Result(object):
|
||||
stdout = '{"verdict": "OK", "stages": []}'
|
||||
stderr = ''
|
||||
|
||||
monkeypatch.setattr(sweep.subprocess, 'run',
|
||||
lambda *a, **k: _Result())
|
||||
_rows, notes = sweep.check(str(root), 'sample_job')
|
||||
assert notes == []
|
||||
@@ -0,0 +1,277 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The takeoff's Revit-free half: classification, both totals, and the refusals.
|
||||
|
||||
No Revit, no model, no index. Every fixture here is a synthetic index cache
|
||||
in the shape `reinforcecore.piece_record` produces, with invented family
|
||||
names -- the point of the split is that the counting rules can be wrong on
|
||||
paper and caught here, before anybody opens a model.
|
||||
|
||||
The rules under test are the ones this project has already paid to learn:
|
||||
a mark is not a piece, a nested family is counted twice, an attached bar is
|
||||
not a separate order, and a family nobody could classify is a finding rather
|
||||
than a silence.
|
||||
"""
|
||||
# pylint: disable=import-error,invalid-name
|
||||
from vibe import takeoffcore as core
|
||||
|
||||
|
||||
def piece(name, families, **extra):
|
||||
record = {'name': name, 'families': families, 'features': [],
|
||||
'error': None, 'design_number': extra.pop('design', None)}
|
||||
record.update(extra)
|
||||
return record
|
||||
|
||||
|
||||
def cache(*records):
|
||||
return {'schema': 3, 'built_at': 'once',
|
||||
'pieces': dict((r['name'], r) for r in records)}
|
||||
|
||||
|
||||
CFG = core.settings({})
|
||||
|
||||
|
||||
# --------------------------------------------------------------- classifying
|
||||
|
||||
def test_a_bar_prefix_family_is_a_bar_and_keeps_its_mark():
|
||||
assert core.classify('SR9001', CFG) == ('bar', 'SR9001', 'rule:bar_prefix')
|
||||
|
||||
|
||||
def test_a_part_number_is_dug_out_of_a_longer_family_name():
|
||||
klass, key, basis = core.classify('MAKER_EMBED_P70 with bar', CFG)
|
||||
assert (klass, key, basis) == ('hardware', 'P70', 'rule:part_prefix')
|
||||
|
||||
|
||||
def test_the_longer_prefix_wins_so_one_series_is_not_read_as_another():
|
||||
"""SP70 must never come back as the P70 inside it."""
|
||||
assert core.classify('SP70', CFG)[1] == 'SP70'
|
||||
|
||||
|
||||
def test_a_bend_shape_family_is_reinforcing_with_no_mark():
|
||||
"""A shape family carries a different bar mark on every piece.
|
||||
|
||||
It must not borrow a mark from its own name, and it must not be dropped:
|
||||
the instances are real steel whose SKU this index cannot name.
|
||||
"""
|
||||
klass, key, basis = core.classify('99A_(SCHEDULED)', CFG)
|
||||
assert klass == core.BAR_SHAPE
|
||||
assert key == '99A_(SCHEDULED)'
|
||||
assert basis == 'rule:shape_marker'
|
||||
|
||||
|
||||
def test_a_shape_marker_beats_a_bar_prefix_in_the_family_name():
|
||||
assert core.classify('SR9001_(SCHEDULED)', CFG)[0] == core.BAR_SHAPE
|
||||
|
||||
|
||||
def test_an_unknown_family_is_unclassified_and_keeps_its_own_name():
|
||||
assert core.classify('WIDGET_THING', CFG) == (
|
||||
'unclassified', 'WIDGET_THING', 'none')
|
||||
|
||||
|
||||
def test_a_declared_family_beats_every_pattern():
|
||||
cfg = core.settings({'takeoff': {'family_class': {'SR9001': 'cast'}}})
|
||||
assert core.classify('SR9001', cfg) == ('cast', 'SR9001', 'declared')
|
||||
|
||||
|
||||
def test_not_ordered_is_a_substring_declaration():
|
||||
cfg = core.settings({'takeoff': {'not_ordered': ['PANEL_HOST']}})
|
||||
assert core.classify('PANEL_HOST_FLAT', cfg)[0] == core.NOT_ORDERED
|
||||
|
||||
|
||||
def test_strand_and_mesh_come_from_the_existing_family_rules():
|
||||
assert core.classify('X_STRAND', CFG, is_strand=lambda f: 'STRAND' in f)[0] \
|
||||
== core.STRAND
|
||||
assert core.classify('WWF_6X6', CFG, is_mesh=lambda f: f.startswith('WWF'))[0] \
|
||||
== core.MESH
|
||||
|
||||
|
||||
# ---------------------------------------------------- the control multiplier
|
||||
|
||||
def test_per_mark_and_per_job_are_both_reported_and_differ_by_the_multiplier():
|
||||
out = core.build(cache(piece('A', {'P70': 4})), CFG,
|
||||
multipliers={'A': (3, 'ticket_files')})
|
||||
row = out['totals'][0]
|
||||
assert row['per_mark_total'] == 4
|
||||
assert row['per_job_total'] == 12
|
||||
assert row['multiplier'] == 3.0
|
||||
|
||||
|
||||
def test_a_mark_with_no_sourced_multiplier_is_one_and_is_named_an_undercount():
|
||||
"""Counted once, and listed, because a multiplier nobody sourced is not 1."""
|
||||
out = core.build(cache(piece('A', {'P70': 4})), CFG)
|
||||
assert out['totals'][0]['per_job_total'] == 4
|
||||
assert out['undercounted'] == ['A']
|
||||
assert out['by_piece']['A']['multiplier_basis'] == 'assumed_one'
|
||||
|
||||
|
||||
def test_counting_marks_instead_of_pieces_is_the_under_order_this_prevents():
|
||||
"""Two marks, one of them three pieces: 4 per mark, 8 in the job."""
|
||||
out = core.build(
|
||||
cache(piece('A', {'P70': 2}), piece('B', {'P70': 2})), CFG,
|
||||
multipliers={'A': (3, 'ticket_files'), 'B': (1, 'ticket_files')})
|
||||
row = out['totals'][0]
|
||||
assert (row['per_mark_total'], row['per_job_total']) == (4, 8)
|
||||
assert out['pieces_on_the_floor'] == 4
|
||||
|
||||
|
||||
# --------------------------------------------------------------- attached bars
|
||||
|
||||
def test_an_attached_bar_is_counted_and_kept_off_the_order_by_default():
|
||||
out = core.build(cache(piece('A', {'SR9001': 6, 'P70': 2})), CFG,
|
||||
attached={'SR9001': ['P70']})
|
||||
order, ships, _other = core.order_rows(out['totals'],
|
||||
out['order_attached_separately'])
|
||||
assert [r['key'] for r in ships] == ['SR9001']
|
||||
assert [r['key'] for r in order] == ['P70']
|
||||
assert ships[0]['per_mark_total'] == 6 # counted, not dropped
|
||||
assert ships[0]['attached_to'] == ['P70']
|
||||
|
||||
|
||||
def test_the_switch_puts_attached_bars_back_on_the_order():
|
||||
cfg = core.settings({'takeoff': {'order_attached_bars_separately': True}})
|
||||
out = core.build(cache(piece('A', {'SR9001': 6})), cfg,
|
||||
attached={'SR9001': ['P70']})
|
||||
order, ships, _other = core.order_rows(out['totals'],
|
||||
out['order_attached_separately'])
|
||||
assert [r['key'] for r in order] == ['SR9001']
|
||||
assert ships == []
|
||||
|
||||
|
||||
def test_attached_parents_are_read_from_the_drawing_knowledge():
|
||||
know = {'drawings': {'annotation_covered_by_parent': {'SR9001': ['P70']}}}
|
||||
assert core.attached_parents(CFG, know) == {'SR9001': ['P70']}
|
||||
|
||||
|
||||
def test_a_single_parent_written_as_a_scalar_still_reads():
|
||||
know = {'drawings': {'annotation_covered_by_parent': {'SR9001': 'P70'}}}
|
||||
assert core.attached_parents(CFG, know) == {'SR9001': ['P70']}
|
||||
|
||||
|
||||
# ------------------------------------------------------ what it will not count
|
||||
|
||||
def test_an_unclassified_family_is_listed_by_name_never_bucketed():
|
||||
out = core.build(cache(piece('A', {'WIDGET_THING': 5})), CFG,
|
||||
multipliers={'A': (2, 'ticket_files')})
|
||||
assert [r['family'] for r in out['unclassified']] == ['WIDGET_THING']
|
||||
assert out['unclassified'][0]['per_job_total'] == 10
|
||||
assert out['unclassified'][0]['examples'] == ['A']
|
||||
|
||||
|
||||
def test_unclassified_is_never_orderable():
|
||||
out = core.build(cache(piece('A', {'WIDGET_THING': 5})), CFG)
|
||||
order, _ships, other = core.order_rows(out['totals'])
|
||||
assert order == []
|
||||
assert [r['class'] for r in other] == ['unclassified']
|
||||
|
||||
|
||||
def test_a_piece_with_no_families_is_could_not_judge_not_an_empty_piece():
|
||||
"""The distinction the whole index is built around, kept on this side."""
|
||||
record = piece('A', {})
|
||||
record['error'] = 'members unreadable'
|
||||
record['features'] = None
|
||||
out = core.build(cache(record), CFG)
|
||||
assert out['unjudged'] == [{'piece': 'A', 'reason': 'members unreadable'}]
|
||||
assert out['by_piece']['A']['judged'] is False
|
||||
assert out['totals'] == []
|
||||
|
||||
|
||||
def test_a_measured_piece_with_no_openings_is_still_counted():
|
||||
"""features=None is about GEOMETRY. A takeoff does not need geometry."""
|
||||
record = piece('A', {'P70': 2})
|
||||
record['features'] = None
|
||||
record['error'] = 'no concrete solid'
|
||||
out = core.build(cache(record), CFG)
|
||||
assert out['unjudged'] == []
|
||||
assert out['totals'][0]['per_mark_total'] == 2
|
||||
|
||||
|
||||
def test_coverage_names_the_marks_the_index_never_built():
|
||||
out = core.build(cache(piece('A', {'P70': 1})), CFG,
|
||||
population={'assemblies': 5})
|
||||
assert out['coverage']['indexed_marks'] == 1
|
||||
assert out['coverage']['marks_not_indexed'] == 4
|
||||
assert out['coverage']['complete'] is False
|
||||
|
||||
|
||||
def test_every_row_records_what_decided_it():
|
||||
out = core.build(cache(piece('A', {'P70': 1, 'WIDGET': 1})), CFG)
|
||||
assert out['bases'] == {'rule:part_prefix': 1, 'none': 1}
|
||||
|
||||
|
||||
# -------------------------------------------------------------- nesting
|
||||
|
||||
def test_a_family_that_follows_another_count_for_count_is_flagged():
|
||||
out = core.suspected_nesting({
|
||||
'A': {'families': {'PARENT': 4, 'CHILD': 4}},
|
||||
'B': {'families': {'PARENT': 8, 'CHILD': 8}},
|
||||
'C': {'families': {'PARENT': 2, 'CHILD': 2}},
|
||||
})
|
||||
assert len(out) == 1
|
||||
assert set([out[0]['child'], out[0]['parent']]) == set(['PARENT', 'CHILD'])
|
||||
assert out[0]['direction'] == 'undecided'
|
||||
assert out[0]['agreement'] == 1.0
|
||||
|
||||
|
||||
def test_two_parts_that_are_one_each_are_not_evidence_of_anything():
|
||||
"""The filter that took this list from forty rows of noise to three."""
|
||||
assert core.suspected_nesting({
|
||||
'A': {'families': {'ONE': 1, 'TWO': 1}},
|
||||
'B': {'families': {'ONE': 1, 'TWO': 1}},
|
||||
'C': {'families': {'ONE': 1, 'TWO': 1}},
|
||||
}) == []
|
||||
|
||||
|
||||
def test_a_pair_that_disagrees_on_one_mark_still_surfaces_with_its_agreement():
|
||||
out = core.suspected_nesting({
|
||||
'A': {'families': {'PARENT': 4, 'CHILD': 4}},
|
||||
'B': {'families': {'PARENT': 8, 'CHILD': 8}},
|
||||
'C': {'families': {'PARENT': 2, 'CHILD': 2}},
|
||||
'D': {'families': {'PARENT': 2, 'CHILD': 1}},
|
||||
'E': {'families': {'PARENT': 4, 'CHILD': 4}},
|
||||
'F': {'families': {'PARENT': 8, 'CHILD': 8}},
|
||||
'G': {'families': {'PARENT': 2, 'CHILD': 2}},
|
||||
'H': {'families': {'PARENT': 4, 'CHILD': 4}},
|
||||
'I': {'families': {'PARENT': 8, 'CHILD': 8}},
|
||||
'J': {'families': {'PARENT': 2, 'CHILD': 2}},
|
||||
})
|
||||
pair = [r for r in out if r['child'] == 'CHILD' or r['parent'] == 'CHILD']
|
||||
assert pair and pair[0]['agreement'] < 1.0
|
||||
|
||||
|
||||
def test_nothing_is_ever_subtracted_on_a_suspicion():
|
||||
"""The total is unchanged by the detector. It only prints."""
|
||||
pieces = cache(piece('A', {'PARENT': 4, 'CHILD': 4}),
|
||||
piece('B', {'PARENT': 8, 'CHILD': 8}),
|
||||
piece('C', {'PARENT': 2, 'CHILD': 2}))
|
||||
out = core.build(pieces, CFG)
|
||||
total = dict((r['key'], r['per_mark_total']) for r in out['totals'])
|
||||
assert total['CHILD'] == 14
|
||||
assert out['nesting_suspects']
|
||||
|
||||
|
||||
def test_a_declared_nesting_leaves_the_order_list():
|
||||
cfg = core.settings({'takeoff': {'nested_in': {'CHILD': 'PARENT'}}})
|
||||
out = core.build(cache(piece('A', {'CHILD': 4})), cfg)
|
||||
assert out['totals'][0]['class'] == core.NESTED
|
||||
order, _ships, other = core.order_rows(out['totals'])
|
||||
assert order == []
|
||||
assert other[0]['key'] == 'CHILD'
|
||||
|
||||
|
||||
# ------------------------------------------- the index field that does not exist
|
||||
|
||||
def test_a_marks_counter_resolves_bend_shapes_into_real_bar_marks():
|
||||
"""The one-field index change, exercised before the index has it.
|
||||
|
||||
With `marks` on the record the shape families drop out and named bar
|
||||
marks take their place. Nothing else about the takeoff changes, which is
|
||||
the whole reason it was written this way.
|
||||
"""
|
||||
record = piece('A', {'99A_(SCHEDULED)': 12, 'P70': 2})
|
||||
record['marks'] = {'SR9001': 8, 'SR9002': 4, 'P70': 2}
|
||||
out = core.build(cache(record), CFG)
|
||||
got = dict(((r['class'], r['key']), r['per_mark_total'])
|
||||
for r in out['totals'])
|
||||
assert got[('bar', 'SR9001')] == 8
|
||||
assert got[('bar', 'SR9002')] == 4
|
||||
assert got[('hardware', 'P70')] == 2 # counted once, not twice
|
||||
assert ('bar_shape', '99A_(SCHEDULED)') not in got
|
||||
@@ -0,0 +1,336 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The control-number multiplier: where it comes from and what it refuses to say.
|
||||
|
||||
This is the one number that decides whether the takeoff is right, so the
|
||||
tests are about its FAILURE modes rather than its arithmetic. A source that
|
||||
returns a confident 1 for a mark it knows nothing about is the bug this
|
||||
module exists to make impossible.
|
||||
|
||||
Filenames here are invented. The shape they exercise -- job, control number,
|
||||
mark -- is the plant's export convention, not a job's data.
|
||||
"""
|
||||
# pylint: disable=import-error,invalid-name
|
||||
from vibe import takeoffcount as count
|
||||
|
||||
|
||||
# ---------------------------------------------------------- ticket filenames
|
||||
|
||||
def test_a_ticket_filename_yields_its_mark_and_its_control_number():
|
||||
assert count.parse_ticket_name('11111-20001_PNL101.pdf') == \
|
||||
('PNL101', '20001')
|
||||
|
||||
|
||||
def test_a_control_number_with_no_digit_in_it_is_not_a_control_number():
|
||||
"""The export folder also holds the detail sheets, and their names take
|
||||
the identical three-part shape. Twelve of them were being counted as
|
||||
marks until `join_quality` said so."""
|
||||
assert count.parse_ticket_name('11111-SHEET_X4-1.pdf') == (None, None)
|
||||
assert count.from_ticket_files(['11111-SHEET_X4-1.pdf']) == {}
|
||||
assert count.unparsed_ticket_files(
|
||||
['11111-SHEET_X4-1.pdf', '11111-20001_PNL101.pdf']) == \
|
||||
['11111-SHEET_X4-1.pdf']
|
||||
|
||||
|
||||
def test_a_lettered_control_number_is_still_a_control_number():
|
||||
assert count.parse_ticket_name('11111-A20001_PNL101.pdf') == \
|
||||
('PNL101', 'A20001')
|
||||
|
||||
|
||||
def test_a_file_named_by_mark_alone_is_ignored_not_counted_as_one_piece():
|
||||
"""The older export was named by mark. One file per mark is exactly how
|
||||
one piece per mark came to be believed, so it must contribute nothing."""
|
||||
assert count.parse_ticket_name('PNL101.pdf') == (None, None)
|
||||
assert count.from_ticket_files(['PNL101.pdf']) == {}
|
||||
|
||||
|
||||
def test_a_revision_is_a_reissued_ticket_not_a_second_piece():
|
||||
assert count.from_ticket_files([
|
||||
'11111-20001_PNL101.pdf',
|
||||
'11111-20001_PNL101_R1.pdf',
|
||||
]) == {'PNL101': (1, 'ticket_files')}
|
||||
|
||||
|
||||
def test_several_control_numbers_on_one_mark_are_several_pieces():
|
||||
got = count.from_ticket_files([
|
||||
'11111-20001_PNL101.pdf',
|
||||
'11111-20003_PNL101.pdf',
|
||||
'11111-20005_PNL101.pdf',
|
||||
'11111-20007_PNL102.pdf',
|
||||
])
|
||||
assert got == {'PNL101': (3, 'ticket_files'), 'PNL102': (1, 'ticket_files')}
|
||||
|
||||
|
||||
# ------------------------------------------------------- the other two sources
|
||||
|
||||
def test_duplicate_assembly_names_are_a_multiplier():
|
||||
assert count.from_assembly_names(
|
||||
['PNL101', 'PNL101', 'PNL102']) == {'PNL101': (2, 'assembly_instances')}
|
||||
|
||||
|
||||
def test_a_name_that_appears_once_is_absence_of_evidence_not_a_count_of_one():
|
||||
"""The bug this caught, kept as a test so it cannot come back.
|
||||
|
||||
Measured through the bridge on a real job: 755 assemblies, 755 distinct
|
||||
names, zero duplicates. An earlier version returned 1 for each, every
|
||||
mark got a confident basis, and every per-job figure silently equalled
|
||||
its per-mark figure -- a whole-job undercount that looked sourced.
|
||||
"""
|
||||
assert count.from_assembly_names(['A', 'B', 'C']) == {}
|
||||
|
||||
|
||||
def test_control_numbers_off_the_model_elements_are_the_real_source():
|
||||
got, unreadable = count.from_piece_records([
|
||||
{'id': 1, 'mark': 'PNL101', 'control': '20001'},
|
||||
{'id': 2, 'mark': 'PNL101', 'control': '20003'},
|
||||
{'id': 3, 'mark': 'PNL102', 'control': '20005'},
|
||||
])
|
||||
assert got == {'PNL101': (2, 'control_numbers'),
|
||||
'PNL102': (1, 'control_numbers')}
|
||||
assert unreadable == []
|
||||
|
||||
|
||||
def test_a_control_number_on_several_elements_is_still_one_piece():
|
||||
"""2480 control-numbered elements resolved to 1776 pieces on this job.
|
||||
The difference is exactly this sharing, and counting elements would
|
||||
over-order by it."""
|
||||
got, _bad = count.from_piece_records([
|
||||
{'id': 1, 'mark': 'PNL101', 'control': '20001'},
|
||||
{'id': 2, 'mark': 'PNL101', 'control': '20001'},
|
||||
])
|
||||
assert got == {'PNL101': (1, 'control_numbers')}
|
||||
|
||||
|
||||
def test_a_printed_range_is_expanded_by_the_one_shared_expander():
|
||||
"""`50120-50123` is four pieces. The expansion is jobrecon's, not a
|
||||
second copy of it living here."""
|
||||
got, _bad = count.from_piece_records(
|
||||
[{'id': 1, 'mark': 'PNL101', 'control': '50120-50123'}])
|
||||
assert got == {'PNL101': (4, 'control_numbers')}
|
||||
|
||||
|
||||
def test_an_element_with_a_control_number_and_no_mark_is_named_not_dropped():
|
||||
got, unreadable = count.from_piece_records(
|
||||
[{'id': 7, 'mark': '', 'control': '20001'}])
|
||||
assert got == {}
|
||||
assert unreadable[0]['id'] == 7
|
||||
assert 'no mark' in unreadable[0]['why']
|
||||
|
||||
|
||||
def test_an_unreadable_control_field_is_returned_rather_than_swallowed():
|
||||
got, unreadable = count.from_piece_records(
|
||||
[{'id': 9, 'mark': 'PNL101', 'control': 'PL 1/4" X 28"'}])
|
||||
assert got == {}
|
||||
assert unreadable and unreadable[0]['id'] == 9
|
||||
|
||||
|
||||
def test_a_mark_with_no_readable_control_numbers_is_left_out_entirely():
|
||||
"""Not zero, and not one: absent, so the next source gets a turn."""
|
||||
assert count.from_control_numbers({'PNL101': [], 'PNL102': ['20001']}) == {
|
||||
'PNL102': (1, 'control_numbers')}
|
||||
|
||||
|
||||
def test_control_numbers_are_counted_distinct():
|
||||
assert count.from_control_numbers(
|
||||
{'PNL101': ['20001', '20001', '20003']})['PNL101'][0] == 2
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- precedence
|
||||
|
||||
def test_the_model_control_numbers_outrank_every_other_source():
|
||||
merged = count.merge(
|
||||
{'PNL101': (3, 'control_numbers')},
|
||||
{'PNL101': (1, 'ticket_files')},
|
||||
{'PNL101': (9, 'assembly_instances')})
|
||||
assert merged['PNL101'] == (3, 'control_numbers')
|
||||
|
||||
|
||||
def test_assembly_instances_is_ranked_last_of_the_three():
|
||||
assert count.ORDER[-1] == 'assembly_instances'
|
||||
merged = count.merge({'PNL101': (9, 'assembly_instances')},
|
||||
{'PNL101': (2, 'ticket_files')})
|
||||
assert merged['PNL101'] == (2, 'ticket_files')
|
||||
|
||||
|
||||
def test_a_worse_source_still_covers_a_mark_the_better_one_missed():
|
||||
merged = count.merge({'PNL101': (3, 'control_numbers')},
|
||||
{'PNL102': (2, 'ticket_files')})
|
||||
assert merged['PNL102'] == (2, 'ticket_files')
|
||||
|
||||
|
||||
def test_the_sources_are_compared_because_nothing_else_can_check_them():
|
||||
got = count.disagreements({'PNL101': (2, 'control_numbers')},
|
||||
{'PNL101': (3, 'ticket_files')})
|
||||
assert got == [{'mark': 'PNL101',
|
||||
'left': 2, 'left_basis': 'control_numbers',
|
||||
'right': 3, 'right_basis': 'ticket_files'}]
|
||||
|
||||
|
||||
def test_agreement_is_silent():
|
||||
assert count.disagreements({'PNL101': (3, 'control_numbers')},
|
||||
{'PNL101': (3, 'ticket_files')}) == []
|
||||
|
||||
|
||||
# ------------------------------------------- what one exported ticket covers
|
||||
#
|
||||
# `nomenclature.ticket_unit` decides whether a folder of exported tickets is
|
||||
# a count of PIECES or a count of DRAWINGS, and the same folder reads to two
|
||||
# different numbers under the two answers. The tests below are the pin: for
|
||||
# the declared value the numbers must be exactly what they were when the
|
||||
# ranking was a constant, and every other state must be a visible refusal
|
||||
# rather than a quiet fallback to it.
|
||||
|
||||
# One fixture, exercised by every case, built so that all three sources have
|
||||
# something to say and each of them is the ONLY source for at least one mark.
|
||||
# A mark that only the tickets cover is the whole question: it is the mark
|
||||
# that changes value if the ticket unit is guessed.
|
||||
CONTROL = {'PNL101': (3, 'control_numbers'), 'PNL102': (1, 'control_numbers')}
|
||||
TICKETS = {'PNL101': (2, 'ticket_files'), 'PNL103': (4, 'ticket_files')}
|
||||
ASSEMBLIES = {'PNL101': (9, 'assembly_instances'),
|
||||
'PNL104': (2, 'assembly_instances')}
|
||||
SOURCES = {'control_numbers': CONTROL, 'ticket_files': TICKETS,
|
||||
'assembly_instances': ASSEMBLIES}
|
||||
|
||||
# What the ranking produced when it was hardcoded. Written down rather than
|
||||
# recomputed, because a pin that recomputes both sides pins nothing.
|
||||
AS_IT_ALWAYS_WAS = {
|
||||
'PNL101': (3, 'control_numbers'),
|
||||
'PNL102': (1, 'control_numbers'),
|
||||
'PNL103': (4, 'ticket_files'),
|
||||
'PNL104': (2, 'assembly_instances'),
|
||||
}
|
||||
|
||||
|
||||
def test_one_ticket_per_control_number_ranks_all_three_sources():
|
||||
plan = count.counting_plan('control_number', SOURCES)
|
||||
assert plan['order'] == count.ORDER
|
||||
assert plan['withdrawn'] == ()
|
||||
assert plan['refusal'] is None
|
||||
assert 'one ticket per control number' in plan['reason']
|
||||
|
||||
|
||||
def test_the_declared_unit_does_not_move_a_single_count():
|
||||
"""The pin. Wiring the key changed the SHAPE and must not change a number
|
||||
on a job that declares the value the ranking used to assume."""
|
||||
plan = count.counting_plan('control_number', SOURCES)
|
||||
merged = count.merge(CONTROL, TICKETS, ASSEMBLIES, order=plan['order'])
|
||||
assert merged == AS_IT_ALWAYS_WAS
|
||||
assert count.summarise(merged)['pieces'] == 10
|
||||
|
||||
|
||||
def test_merge_with_no_order_is_still_the_full_ranking():
|
||||
"""Every caller that has not asked the question gets what it always got."""
|
||||
assert count.merge(CONTROL, TICKETS, ASSEMBLIES) == AS_IT_ALWAYS_WAS
|
||||
|
||||
|
||||
def test_one_ticket_per_mark_withdraws_the_exported_tickets():
|
||||
"""A ticket covering a whole mark carries ONE control number in its
|
||||
filename and several pieces on its face. Counting the filename would
|
||||
return a confident 1 for a mark that is really seven."""
|
||||
plan = count.counting_plan('piece_mark', SOURCES)
|
||||
assert plan['withdrawn'] == ('ticket_files',)
|
||||
assert plan['refusal'] is None # somebody declared it
|
||||
merged = count.merge(CONTROL, TICKETS, ASSEMBLIES, order=plan['order'])
|
||||
assert 'PNL103' not in merged # the tickets' own mark is gone
|
||||
assert merged['PNL101'] == (3, 'control_numbers')
|
||||
|
||||
|
||||
def test_an_undeclared_ticket_unit_refuses_rather_than_assuming_one():
|
||||
"""The judgement call, pinned. Falling back to `control_number` would
|
||||
under-count a job ticketed the other way, and it would do it wearing a
|
||||
source's name instead of `assumed_one`."""
|
||||
plan = count.counting_plan(None, SOURCES)
|
||||
assert plan['declared'] is False
|
||||
assert plan['withdrawn'] == ('ticket_files',)
|
||||
assert plan['refusal']
|
||||
assert 'nomenclature.ticket_unit' in plan['refusal']
|
||||
assert 'control_number' in plan['refusal'] # names both accepted values
|
||||
assert 'piece_mark' in plan['refusal']
|
||||
|
||||
|
||||
def test_an_unrecognised_value_is_a_refusal_not_a_fallback():
|
||||
"""A key set to something nobody implemented is an intention, misspelt.
|
||||
Reading it as the default does the opposite of what was asked."""
|
||||
plan = count.counting_plan('one per truck', SOURCES)
|
||||
assert plan['declared'] is True
|
||||
assert plan['recognised'] is False
|
||||
assert 'one per truck' in plan['refusal']
|
||||
assert plan['withdrawn'] == ('ticket_files',)
|
||||
|
||||
|
||||
def test_a_withdrawn_source_is_dropped_and_never_ranked_last():
|
||||
"""Ranking it 99th would let it through on exactly the marks nothing else
|
||||
covers -- which is where a wrong multiplier does its damage."""
|
||||
plan = count.counting_plan(None)
|
||||
merged = count.merge(CONTROL, TICKETS, ASSEMBLIES, order=plan['order'])
|
||||
assert 'PNL103' not in merged
|
||||
got = count.summarise(merged, marks=['PNL101', 'PNL102', 'PNL103'])
|
||||
assert got['by_basis']['assumed_one'] == 1
|
||||
assert got['assumed_one'] == 1
|
||||
|
||||
|
||||
def test_the_plan_says_what_the_withdrawal_actually_cost():
|
||||
"""A rule and a finding are different things: the report opens on the
|
||||
marks lost, not on the key being unset."""
|
||||
plan = count.counting_plan(None, SOURCES)
|
||||
assert plan['withdrawn_marks'] == 2 # PNL101 and PNL103
|
||||
assert plan['cost'] == 1 # only PNL103 had nothing else
|
||||
|
||||
|
||||
def test_an_undeclared_unit_with_no_tickets_to_withdraw_costs_nothing():
|
||||
plan = count.counting_plan(None, {'control_numbers': CONTROL})
|
||||
assert plan['refusal']
|
||||
assert plan['cost'] == 0
|
||||
|
||||
|
||||
def test_the_other_two_sources_do_not_turn_on_the_ticket_unit():
|
||||
"""A control number off a model element is one physical piece however the
|
||||
plant tickets it, and a duplicated assembly name is a duplicated piece
|
||||
either way. Only the ticket files read differently."""
|
||||
for unit in (None, 'control_number', 'piece_mark', 'nonsense'):
|
||||
order = count.counting_plan(unit)['order']
|
||||
assert 'control_numbers' in order and 'assembly_instances' in order
|
||||
assert order[0] == 'control_numbers'
|
||||
|
||||
|
||||
def test_every_state_carries_a_sentence_to_print():
|
||||
for unit in (None, 'control_number', 'piece_mark', 'nonsense'):
|
||||
plan = count.counting_plan(unit)
|
||||
assert plan['reason']
|
||||
assert plan['key'] == 'nomenclature.ticket_unit'
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ summary
|
||||
|
||||
def test_a_mark_nobody_could_source_is_counted_and_named_assumed_one():
|
||||
got = count.summarise({'PNL101': (3, 'ticket_files')},
|
||||
marks=['PNL101', 'PNL102'])
|
||||
assert got['marks'] == 2
|
||||
assert got['pieces'] == 4
|
||||
assert got['assumed_one'] == 1
|
||||
assert got['by_basis'] == {'ticket_files': 1, 'assumed_one': 1}
|
||||
|
||||
|
||||
def test_the_average_multiplier_is_reported_so_a_reader_can_smell_it():
|
||||
got = count.summarise({'A': (2, 'ticket_files'), 'B': (4, 'ticket_files')})
|
||||
assert got['multiplier'] == 3.0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- the join
|
||||
|
||||
def test_a_source_naming_things_the_takeoff_never_heard_of_is_visible():
|
||||
"""The silent-and-total failure mode: the two sides key on different
|
||||
strings, every mark falls through to assumed_one, and the source still
|
||||
reports hundreds of marks found."""
|
||||
got = count.join_quality({'OTHER_1': (2, 'control_numbers')},
|
||||
marks=['PNL101', 'PNL102'])
|
||||
assert got['matched'] == 0
|
||||
assert got['unmatched_source'] == ['OTHER_1']
|
||||
assert got['unsourced'] == ['PNL101', 'PNL102']
|
||||
|
||||
|
||||
def test_a_clean_join_reports_no_strays():
|
||||
got = count.join_quality({'PNL101': (2, 'control_numbers')},
|
||||
marks=['PNL101'])
|
||||
assert got['matched'] == 1
|
||||
assert got['unmatched_source'] == []
|
||||
assert got['unsourced'] == []
|
||||
@@ -0,0 +1,518 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Rendering a takeoff. The tests are about what the page REFUSES to hide.
|
||||
|
||||
A takeoff is trusted on sight, so the things worth testing are that an
|
||||
undercount says the word, that no quantity ever appears without both of its
|
||||
figures, and -- since the page now folds -- that folding never turns a
|
||||
caveat into an appendix. Layout is not under test; disclosure is.
|
||||
|
||||
THE FOLDING CONTRACT, WHICH IS THE POINT OF HALF OF THIS FILE
|
||||
---------------------------------------------------------------
|
||||
The owner's rule (`vibe.foldreport`) is that the answer is the headline and
|
||||
everything else starts closed, so that an OPEN section means "this needs
|
||||
you". A takeoff can break that contract in two directions and both are worse
|
||||
than the flat report was:
|
||||
|
||||
* folding something that bites -- the refusal, an undercount, an
|
||||
unclassified family -- which hides it behind a click nobody makes;
|
||||
* opening something routine, which spends the contract until an open
|
||||
section stops meaning anything.
|
||||
|
||||
So the tests assert both: a clean takeoff has NOTHING open below the
|
||||
headline, and each thing that bites opens itself and says why on its header.
|
||||
"""
|
||||
# pylint: disable=import-error,invalid-name
|
||||
from vibe import foldreport as fold
|
||||
from vibe import takeoffcore as core
|
||||
from vibe import takeoffreport as report
|
||||
|
||||
CFG = core.settings({})
|
||||
|
||||
|
||||
def cache(pieces):
|
||||
return {'schema': 3, 'built_at': 'once', 'pieces': pieces}
|
||||
|
||||
|
||||
def one(families, **extra):
|
||||
record = {'name': 'A', 'families': families, 'features': [], 'error': None,
|
||||
'design_number': None}
|
||||
record.update(extra)
|
||||
return cache({'A': record})
|
||||
|
||||
|
||||
def test_the_quantities_are_the_headline_and_carry_both_figures():
|
||||
"""The order is the answer, so it is what the page opens with."""
|
||||
out = core.build(one({'P70': 1}), CFG)
|
||||
head = report.sections(out)[0]
|
||||
assert head.headline is True
|
||||
text = '\n'.join(b[1] for b in head.blocks if b[0] == fold.MD)
|
||||
assert 'Hardware, by part number' in text
|
||||
columns = [b[1] for b in head.blocks if b[0] == fold.TABLE][0]
|
||||
assert 'per_mark' in columns and 'per_job' in columns
|
||||
|
||||
|
||||
def test_an_unsourced_multiplier_says_the_word_undercount():
|
||||
out = core.build(one({'P70': 1}), CFG)
|
||||
text = report.markdown(out, counts={
|
||||
'marks': 1, 'pieces': 1, 'multiplier': 1.0,
|
||||
'by_basis': {'assumed_one': 1}, 'assumed_one': 1})
|
||||
assert 'UNDERCOUNT' in text
|
||||
|
||||
|
||||
def test_no_multiplier_source_at_all_is_stated_rather_than_implied():
|
||||
out = core.build(one({'P70': 1}), CFG)
|
||||
assert 'undercount' in report.markdown(out, counts=None)
|
||||
|
||||
|
||||
def test_an_unclassified_family_is_named_on_the_page():
|
||||
out = core.build(one({'WIDGET_THING': 5}), CFG)
|
||||
text = report.markdown(out)
|
||||
assert 'WIDGET_THING' in text
|
||||
assert 'refuses to count' in text
|
||||
|
||||
|
||||
def test_the_unclassified_list_is_not_repeated_as_a_declared_exclusion():
|
||||
"""Two sections saying the same thing reads as two findings."""
|
||||
out = core.build(one({'WIDGET_THING': 5}), CFG)
|
||||
text = report.markdown(out)
|
||||
assert text.count('WIDGET_THING') == 1
|
||||
|
||||
|
||||
def test_an_attached_bar_appears_with_its_parent_named():
|
||||
out = core.build(one({'SR9001': 6}), CFG, attached={'SR9001': ['P70']})
|
||||
text = report.markdown(out)
|
||||
assert 'arrive attached' in text
|
||||
assert 'P70' in text.split('arrive attached')[1]
|
||||
|
||||
|
||||
def test_every_csv_row_carries_both_figures():
|
||||
out = core.build(one({'P70': 4}), CFG,
|
||||
multipliers={'A': (3, 'ticket_files')})
|
||||
lines = report.csv_totals(out).splitlines()
|
||||
assert lines[0].startswith('class,key,per_mark_total,per_job_total')
|
||||
assert lines[1].startswith('hardware,P70,4,12')
|
||||
|
||||
|
||||
def test_the_per_piece_csv_marks_a_piece_nobody_could_judge():
|
||||
record = {'name': 'A', 'families': {}, 'features': None,
|
||||
'error': 'members unreadable', 'design_number': None}
|
||||
out = core.build(cache({'A': record}), CFG)
|
||||
assert 'COULD_NOT_JUDGE' in report.csv_by_piece(out)
|
||||
|
||||
|
||||
def test_a_comma_in_a_family_name_does_not_shift_a_csv_column():
|
||||
out = core.build(one({'ODD, NAME': 1}), CFG)
|
||||
assert '"ODD, NAME"' in report.csv_by_piece(out)
|
||||
|
||||
|
||||
def test_a_nesting_suspicion_says_nothing_was_subtracted():
|
||||
pieces = {}
|
||||
for i, n in enumerate([2, 4, 8]):
|
||||
pieces['P%d' % i] = {'name': 'P%d' % i, 'features': [], 'error': None,
|
||||
'design_number': None,
|
||||
'families': {'PARENT': n, 'CHILD': n}}
|
||||
out = core.build(cache(pieces), CFG)
|
||||
text = report.markdown(out)
|
||||
assert 'Nothing has been subtracted' in text
|
||||
|
||||
|
||||
def test_an_unmapped_multiplier_role_is_printed_as_a_refusal():
|
||||
"""Not as a job in which every mark happens to be one piece."""
|
||||
out = core.build(one({'P70': 1}), CFG)
|
||||
text = report.markdown(out, counts={
|
||||
'marks': 1, 'pieces': 1, 'multiplier': 1.0,
|
||||
'by_basis': {'assumed_one': 1}, 'assumed_one': 1,
|
||||
'refusal': 'the role is not mapped'})
|
||||
assert 'could not be sourced' in text
|
||||
assert text.index('could not be sourced') < text.index('Quantities to order')
|
||||
|
||||
|
||||
def test_a_source_that_matches_nothing_is_shouted_about():
|
||||
out = core.build(one({'P70': 1}), CFG)
|
||||
text = report.markdown(out, counts={
|
||||
'marks': 1, 'pieces': 1, 'multiplier': 1.0,
|
||||
'by_basis': {'assumed_one': 1}, 'assumed_one': 1,
|
||||
'join': {'source_marks': 400, 'matched': 0,
|
||||
'unmatched_source': ['OTHER_1'], 'unsourced': ['A']}})
|
||||
assert 'do not match any mark in this takeoff' in text
|
||||
|
||||
|
||||
def test_the_categories_the_multiplier_searched_are_named_on_the_page():
|
||||
out = core.build(one({'P70': 1}), CFG)
|
||||
text = report.markdown(out, counts=None, extras={
|
||||
'multiplier': {'categories': ['OST_StructuralFraming']}})
|
||||
assert 'OST_StructuralFraming' in text
|
||||
|
||||
|
||||
def test_an_unreadable_control_field_is_named_on_the_page():
|
||||
out = core.build(one({'P70': 1}), CFG)
|
||||
text = report.markdown(out, counts=None, extras={
|
||||
'multiplier': {'unreadable': [
|
||||
{'id': 9, 'mark': 'A', 'token': 'PL 1/4', 'why': 'not readable'}]}})
|
||||
assert 'could not turn into pieces' in text
|
||||
assert 'PL 1/4' in text
|
||||
|
||||
|
||||
# ------------------------------------------------------- the declared scope
|
||||
#
|
||||
# A takeoff is an ORDER. Counting somebody else's pieces into it is not an
|
||||
# extra service, it is a wrong order -- and a takeoff that quietly counted
|
||||
# only some of the job would be worse still. So the narrowing is stated on
|
||||
# the page, above the quantities, in all three states.
|
||||
|
||||
from vibe import scope as scopelib
|
||||
from vibe import takeoffgather as gather
|
||||
|
||||
|
||||
def _pop(names):
|
||||
return {'assemblies': len(set(names)), 'assembly_instances': len(names),
|
||||
'names': list(names)}
|
||||
|
||||
|
||||
def test_apply_scope_narrows_index_and_population_together():
|
||||
"""Narrowing one and not the other makes the report claim a hole in the
|
||||
job where there is only somebody else's work."""
|
||||
the_cache = cache({'PNL001': {}, 'XX001': {}, 'YY001': {}})
|
||||
new_cache, new_pop, info = gather._apply_scope(
|
||||
scopelib.Scope(prefixes=('PNL',)), the_cache,
|
||||
_pop(['PNL001', 'XX001', 'YY001']))
|
||||
assert sorted(new_cache['pieces']) == ['PNL001']
|
||||
assert new_pop['names'] == ['PNL001']
|
||||
assert info['left_alone'] == [('XX', 1), ('YY', 1)]
|
||||
assert info['counted_marks'] == 1
|
||||
|
||||
|
||||
def test_apply_scope_never_edits_the_shared_index_in_place():
|
||||
the_cache = cache({'PNL001': {}, 'XX001': {}})
|
||||
gather._apply_scope(scopelib.Scope(prefixes=('PNL',)), the_cache,
|
||||
_pop(['PNL001', 'XX001']))
|
||||
assert sorted(the_cache['pieces']) == ['PNL001', 'XX001']
|
||||
|
||||
|
||||
def test_apply_scope_undeclared_counts_everything_and_still_says_so():
|
||||
the_cache = cache({'PNL001': {}, 'XX001': {}})
|
||||
new_cache, _pop_out, info = gather._apply_scope(
|
||||
scopelib.Scope(), the_cache, _pop(['PNL001', 'XX001']))
|
||||
assert sorted(new_cache['pieces']) == ['PNL001', 'XX001']
|
||||
assert info['declared'] is False
|
||||
assert 'No scope is declared' in info['line']
|
||||
|
||||
|
||||
def test_the_page_prints_what_it_did_not_count():
|
||||
takeoff = core.build(one({'SR100': 1}), CFG)
|
||||
_c, _p, info = gather._apply_scope(
|
||||
scopelib.Scope(prefixes=('PNL',), note='cladding only'),
|
||||
cache({'PNL001': {}, 'XX001': {}, 'XX002': {}}),
|
||||
_pop(['PNL001', 'XX001', 'XX002']))
|
||||
text = report.markdown(takeoff, extras={'scope': info})
|
||||
assert 'What this takeoff did not count' in text
|
||||
assert 'XX 2' in text
|
||||
assert 'cladding only' in text
|
||||
# And it stays separate from what the index could not see.
|
||||
assert 'What this takeoff could not see' in text
|
||||
|
||||
|
||||
def test_the_page_says_so_when_nothing_was_left_out():
|
||||
takeoff = core.build(one({'SR100': 1}), CFG)
|
||||
_c, _p, info = gather._apply_scope(
|
||||
scopelib.Scope(prefixes=('PNL',)), cache({'PNL001': {}}),
|
||||
_pop(['PNL001']))
|
||||
text = report.markdown(takeoff, extras={'scope': info})
|
||||
assert 'nothing was set aside' in text
|
||||
|
||||
|
||||
# ------------------------------------------------------- the folding contract
|
||||
#
|
||||
# "If nothing below the headline is open, nothing needs me." Everything from
|
||||
# here down is that sentence, tested from both sides.
|
||||
|
||||
def _clean():
|
||||
"""A takeoff with nothing wrong with it: every family classified, every
|
||||
mark in the index, every multiplier sourced."""
|
||||
the_cache = cache({'A': {'name': 'A', 'families': {'P70': 2},
|
||||
'features': [], 'error': None,
|
||||
'design_number': '101'}})
|
||||
return core.build(the_cache, CFG,
|
||||
multipliers={'A': (3, 'control_numbers')},
|
||||
population={'assemblies': 1, 'assembly_instances': 1,
|
||||
'names': ['A']})
|
||||
|
||||
|
||||
CLEAN_COUNTS = {'marks': 1, 'pieces': 3, 'multiplier': 3.0,
|
||||
'by_basis': {'control_numbers': 1}, 'assumed_one': 0,
|
||||
'sources': {'control_numbers': 1, 'ticket_files': 0,
|
||||
'assembly_instances': 0},
|
||||
'join': {'source_marks': 1, 'matched': 1,
|
||||
'unmatched_source': [], 'unsourced': []}}
|
||||
|
||||
|
||||
def _tail(takeoff, counts=None, extras=None):
|
||||
return report.sections(takeoff, counts, extras=extras)[1:]
|
||||
|
||||
|
||||
def _open(takeoff, counts=None, extras=None):
|
||||
return [s.key for s in fold.sections_with_problems(
|
||||
_tail(takeoff, counts, extras))]
|
||||
|
||||
|
||||
def test_a_takeoff_with_nothing_wrong_opens_nothing_below_the_headline():
|
||||
"""The contract itself. If a routine press opens a section, an open
|
||||
section stops meaning 'this needs you' and the whole page is furniture."""
|
||||
assert _open(_clean(), CLEAN_COUNTS) == []
|
||||
|
||||
|
||||
def test_every_folded_section_says_what_it_holds():
|
||||
"""A header that says only 'details' tells the reader nothing about
|
||||
whether to open it, and folded is only acceptable while it is not gone."""
|
||||
for section in _tail(_clean(), CLEAN_COUNTS):
|
||||
assert section.summary, section.key
|
||||
|
||||
|
||||
def test_the_headline_says_what_multiplied_the_per_job_column():
|
||||
"""The totals can be lifted off this page alone, so the thing that scaled
|
||||
them has to be on it -- not only in the section that owns it."""
|
||||
head = report.sections(_clean(), CLEAN_COUNTS)[0]
|
||||
text = '\n'.join(b[1] for b in head.blocks if b[0] == fold.MD)
|
||||
assert 'control_numbers 1' in text
|
||||
assert 'per_job' in text and 'per_mark' in text
|
||||
|
||||
|
||||
def test_a_refused_multiplier_is_open_and_printed_whole():
|
||||
"""The refusal is the one sentence on the page saying the numbers were
|
||||
never sourced. Folding it into an appendix would be the exact failure the
|
||||
flat report existed to prevent."""
|
||||
out = core.build(one({'P70': 1}), CFG)
|
||||
counts = dict(CLEAN_COUNTS)
|
||||
counts['refusal'] = ('the role `member.control_number` is not mapped, so '
|
||||
'the model was not asked how many pieces each mark '
|
||||
'is')
|
||||
sections = report.sections(out, counts)
|
||||
multiplier = [s for s in sections if s.key == 'multiplier'][0]
|
||||
assert multiplier.is_open is True
|
||||
assert 'not asked how many pieces' in '\n'.join(
|
||||
b[1] for b in multiplier.blocks if b[0] == fold.MD)
|
||||
# and the headline itself does not let the totals be read without it
|
||||
head = '\n'.join(b[1] for b in sections[0].blocks if b[0] == fold.MD)
|
||||
assert 'UNDERCOUNT' in head
|
||||
|
||||
|
||||
def test_a_refused_multiplier_survives_a_dead_toggle():
|
||||
"""pyRevit's panel is an IE-engine WebBrowser and the fold is an inline
|
||||
onclick. If the script does not run, an open section must still be on the
|
||||
page as plain HTML."""
|
||||
out = core.build(one({'P70': 1}), CFG)
|
||||
counts = dict(CLEAN_COUNTS)
|
||||
counts['refusal'] = 'nobody mapped the control-number role'
|
||||
html = fold.html(_tail(out, counts))
|
||||
assert 'nobody mapped the control-number role' in html
|
||||
assert 'display:block' in html
|
||||
|
||||
|
||||
def test_the_panel_html_is_one_line_and_uses_nothing_this_engine_lacks():
|
||||
html = fold.html(_tail(_clean(), CLEAN_COUNTS))
|
||||
assert chr(10) not in html
|
||||
assert '<details' not in html and 'classList' not in html
|
||||
|
||||
|
||||
def test_an_unclassified_family_opens_its_own_section():
|
||||
out = core.build(one({'WIDGET_THING': 5}), CFG)
|
||||
assert 'unclassified' in _open(out, CLEAN_COUNTS)
|
||||
|
||||
|
||||
def test_marks_counted_once_open_and_are_named():
|
||||
out = core.build(one({'P70': 1}), CFG)
|
||||
counts = dict(CLEAN_COUNTS)
|
||||
counts['assumed_one'] = 1
|
||||
section = [s for s in _tail(out, counts)
|
||||
if s.key == 'multiplier-assumed'][0]
|
||||
assert section.is_open is True
|
||||
text = '\n'.join(b[1] for b in section.blocks if b[0] == fold.MD)
|
||||
assert 'UNDERCOUNT' in text
|
||||
assert 'A' in text # the mark itself, not a tally
|
||||
|
||||
|
||||
def test_a_source_whose_marks_match_nothing_opens():
|
||||
out = core.build(one({'P70': 1}), CFG)
|
||||
counts = dict(CLEAN_COUNTS)
|
||||
counts['join'] = {'source_marks': 400, 'matched': 0,
|
||||
'unmatched_source': ['OTHER_1'], 'unsourced': ['A']}
|
||||
assert 'multiplier-join' in _open(out, counts)
|
||||
|
||||
|
||||
def test_an_unreadable_control_field_opens_rather_than_folding():
|
||||
out = core.build(one({'P70': 1}), CFG)
|
||||
assert 'multiplier-unreadable' in _open(
|
||||
out, CLEAN_COUNTS,
|
||||
{'multiplier': {'unreadable': [{'id': 9, 'mark': 'A',
|
||||
'token': 'PL 1/4',
|
||||
'why': 'not readable'}]}})
|
||||
|
||||
|
||||
def test_where_the_multiplier_swept_is_routine_and_stays_folded():
|
||||
"""Naming the categories is provenance, not a problem. It opens nothing."""
|
||||
assert _open(_clean(), CLEAN_COUNTS,
|
||||
{'multiplier': {'categories': ['OST_StructuralFraming'],
|
||||
'elements': 12}}) == []
|
||||
|
||||
|
||||
def test_a_nesting_suspicion_opens_itself():
|
||||
pieces = {}
|
||||
for i, n in enumerate([2, 4, 8]):
|
||||
pieces['P%d' % i] = {'name': 'P%d' % i, 'features': [], 'error': None,
|
||||
'design_number': None,
|
||||
'families': {'PARENT': n, 'CHILD': n}}
|
||||
out = core.build(cache(pieces), CFG)
|
||||
assert 'nesting' in _open(out, CLEAN_COUNTS)
|
||||
|
||||
|
||||
def test_a_mark_in_the_model_the_index_never_saw_opens_coverage():
|
||||
"""Those pieces are missing from the order. A hole in the job is not a
|
||||
routine coverage line, whatever else coverage usually is."""
|
||||
out = core.build(one({'P70': 1}), CFG,
|
||||
population={'assemblies': 9, 'assembly_instances': 9,
|
||||
'names': ['A']})
|
||||
assert 'coverage' in _open(out, CLEAN_COUNTS)
|
||||
|
||||
|
||||
def test_a_piece_the_index_could_not_judge_opens_coverage():
|
||||
record = {'name': 'A', 'families': {}, 'features': None,
|
||||
'error': 'members unreadable', 'design_number': None}
|
||||
out = core.build(cache({'A': record}), CFG,
|
||||
population={'assemblies': 1, 'assembly_instances': 1,
|
||||
'names': ['A']})
|
||||
assert 'coverage' in _open(out, CLEAN_COUNTS)
|
||||
|
||||
|
||||
# ------------------------------------------- what one exported ticket covers
|
||||
#
|
||||
# `nomenclature.ticket_unit` decides whether the exported tickets counted at
|
||||
# all. Declared, it is one routine sentence and opens nothing. Undeclared, it
|
||||
# is a refusal -- and it opens the section at the point where the missing
|
||||
# declaration stopped being a formality and started costing marks.
|
||||
|
||||
def _with_unit(unit, **plan):
|
||||
counts = dict(CLEAN_COUNTS)
|
||||
base = {'key': 'nomenclature.ticket_unit', 'unit': unit,
|
||||
'declared': unit is not None, 'recognised': True,
|
||||
'order': ('control_numbers', 'ticket_files',
|
||||
'assembly_instances'),
|
||||
'withdrawn': (), 'withdrawn_marks': 0, 'cost': 0,
|
||||
'reason': 'one ticket per control number, so a ticket is a piece',
|
||||
'refusal': None}
|
||||
base.update(plan)
|
||||
counts['ticket_unit'] = base
|
||||
return counts
|
||||
|
||||
|
||||
def test_a_declared_ticket_unit_is_stated_and_opens_nothing():
|
||||
"""Somebody answered the question. That is not a problem, and spending an
|
||||
open section on it would spend the folding contract."""
|
||||
counts = _with_unit('control_number')
|
||||
assert _open(_clean(), counts) == []
|
||||
text = report.markdown(_clean(), counts)
|
||||
assert 'one ticket per control number' in text
|
||||
|
||||
|
||||
def test_the_ticket_unit_is_on_the_folded_header_either_way():
|
||||
"""A reader who never opens the multiplier section still learns whether
|
||||
the run knew what a ticket was."""
|
||||
section = [s for s in _tail(_clean(), _with_unit('control_number'))
|
||||
if s.key == 'multiplier'][0]
|
||||
assert 'ticket unit: control_number' in section.summary
|
||||
undeclared = [s for s in _tail(_clean(), _with_unit(
|
||||
None, declared=False, refusal='nobody declared it',
|
||||
reason='nobody declared it', withdrawn=('ticket_files',)))
|
||||
if s.key == 'multiplier'][0]
|
||||
assert 'NOT DECLARED' in undeclared.summary
|
||||
|
||||
|
||||
def test_an_undeclared_ticket_unit_that_cost_marks_opens_the_section():
|
||||
counts = _with_unit(None, declared=False, cost=7, withdrawn_marks=9,
|
||||
withdrawn=('ticket_files',),
|
||||
order=('control_numbers', 'assembly_instances'),
|
||||
refusal='`nomenclature.ticket_unit` is not declared',
|
||||
reason='`nomenclature.ticket_unit` is not declared')
|
||||
assert 'multiplier' in _open(_clean(), counts)
|
||||
section = [s for s in _tail(_clean(), counts)
|
||||
if s.key == 'multiplier'][0]
|
||||
assert '7 mark(s) lost their only multiplier source' in section.problem
|
||||
text = '\n'.join(b[1] for b in section.blocks if b[0] == fold.MD)
|
||||
assert 'nomenclature.ticket_unit' in text
|
||||
|
||||
|
||||
def test_an_undeclared_ticket_unit_that_cost_nothing_is_said_but_not_opened():
|
||||
"""No exported tickets to withdraw means the missing declaration moved no
|
||||
number. It is still printed -- folded is not gone."""
|
||||
counts = _with_unit(None, declared=False, cost=0,
|
||||
withdrawn=('ticket_files',),
|
||||
order=('control_numbers', 'assembly_instances'),
|
||||
refusal='not declared, so the tickets were not used',
|
||||
reason='not declared, so the tickets were not used')
|
||||
assert _open(_clean(), counts) == []
|
||||
assert 'not declared, so the tickets were not used' in report.markdown(
|
||||
_clean(), counts)
|
||||
|
||||
|
||||
def test_a_withdrawn_source_is_shown_as_withdrawn_not_as_one_that_found_none():
|
||||
"""It knew 57 marks and contributed none of them. A table showing only
|
||||
what it knew would read as if it had."""
|
||||
counts = _with_unit('piece_mark', withdrawn=('ticket_files',),
|
||||
order=('control_numbers', 'assembly_instances'),
|
||||
reason='one ticket covers a whole mark')
|
||||
counts['sources'] = {'control_numbers': 1, 'ticket_files': 57,
|
||||
'assembly_instances': 0}
|
||||
section = [s for s in _tail(_clean(), counts)
|
||||
if s.key == 'multiplier'][0]
|
||||
rows = [b[2] for b in section.blocks if b[0] == fold.TABLE][-1]
|
||||
assert ['ticket_files', '57', 'NO'] in rows
|
||||
assert ['control_numbers', '1', 'yes'] in rows
|
||||
# and a declaration nobody has to act on does not open the section
|
||||
assert _open(_clean(), counts) == []
|
||||
|
||||
|
||||
def test_the_page_carries_no_ticket_unit_claim_when_it_was_given_none():
|
||||
"""The report never invents provenance it was not handed -- the same rule
|
||||
`extras` already follows."""
|
||||
text = report.markdown(_clean(), CLEAN_COUNTS)
|
||||
assert 'ticket unit' not in text
|
||||
|
||||
|
||||
def test_the_declared_scope_is_folded_because_somebody_declared_it():
|
||||
"""Work left alone on purpose is not a problem. It is still printed."""
|
||||
takeoff = _clean()
|
||||
_c, _p, info = gather._apply_scope(
|
||||
scopelib.Scope(prefixes=('A',), note='cladding only'),
|
||||
cache({'A': {}, 'XX001': {}}), _pop(['A', 'XX001']))
|
||||
keys = dict((s.key, s) for s in _tail(takeoff, CLEAN_COUNTS,
|
||||
{'scope': info}))
|
||||
assert keys['scope'].is_open is False
|
||||
assert 'left alone' in keys['scope'].summary
|
||||
|
||||
|
||||
def test_the_long_tables_stay_folded_however_long_they_get():
|
||||
"""The by-piece breakdown is the biggest thing on the page and the least
|
||||
read. Length is not a problem; it is just length."""
|
||||
pieces = dict(('P%03d' % i,
|
||||
{'name': 'P%03d' % i, 'families': {'P70': i + 1},
|
||||
'features': [], 'error': None, 'design_number': None})
|
||||
for i in range(150))
|
||||
out = core.build(cache(pieces), CFG,
|
||||
population={'assemblies': 150, 'assembly_instances': 150,
|
||||
'names': sorted(pieces)})
|
||||
counts = dict(CLEAN_COUNTS)
|
||||
counts['by_basis'] = {'control_numbers': 150}
|
||||
keys = dict((s.key, s) for s in _tail(out, counts))
|
||||
assert keys['by-piece'].is_open is False
|
||||
assert keys['judgements'].is_open is False
|
||||
assert keys['attached'].is_open is False
|
||||
assert '150 piece(s)' in keys['by-piece'].summary
|
||||
|
||||
|
||||
def test_the_written_report_still_carries_every_folded_sentence():
|
||||
"""Folded is not gone: the `.md` beside the model is the unfolded copy,
|
||||
so a fold that will not open costs a file open and never a sentence."""
|
||||
text = report.markdown(_clean(), CLEAN_COUNTS)
|
||||
assert 'rule:*' in text # the judgements section
|
||||
assert 'One row per piece' in text # the by-piece section
|
||||
assert 'None of the counted bar marks' in text # the attached section
|
||||
assert 'marks in the model but NOT indexed' in text
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,546 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for the shop-ticket revision delta.
|
||||
|
||||
Two modules under test, split so that neither needs a PDF:
|
||||
|
||||
* `vibe.ticketdelta` is pure -- it takes two already-extracted tickets as
|
||||
plain dicts and returns findings. Everything about WHAT COUNTS AS A CHANGE
|
||||
is tested here on hand-written dicts.
|
||||
* `vibe.ticketextract` imports pymupdf only inside `read_spans`, so its
|
||||
parsing and region logic import and test fine on a machine with no PDF
|
||||
library at all.
|
||||
|
||||
Most of these are regressions against false positives measured on the real
|
||||
old/new issue of one job, and each one names the ticket it came from. That is
|
||||
deliberate: for a diff tool the interesting failures are not "missed a change"
|
||||
but "invented forty", and a test that does not pin the invented ones lets them
|
||||
back in the next time the matcher is touched.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe import ticketdelta as td
|
||||
from vibe import ticketextract as tx
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# dimension parsing
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize('text,inches', [
|
||||
('22\'-11"', 275.0),
|
||||
('22\' - 11"', 275.0),
|
||||
('2\' - 5 1/2"', 29.5),
|
||||
('11 1/2"', 11.5),
|
||||
('8"', 8.0),
|
||||
('3/4"', 0.75),
|
||||
('1\'-0"', 12.0),
|
||||
])
|
||||
def test_parse_dim_accepts_every_printed_form(text, inches):
|
||||
assert tx.parse_dim(text) == pytest.approx(inches)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('text', [
|
||||
'',
|
||||
None,
|
||||
'BED PLAN',
|
||||
'P76 W/ SR5602 (S.I.F.)',
|
||||
'(5) @ 1\'-6" = 7\'-6"', # a note that contains dimensions
|
||||
'#3 X 11\'-8"', # a BOM description
|
||||
'20132',
|
||||
])
|
||||
def test_parse_dim_rejects_things_that_merely_contain_a_dimension(text):
|
||||
assert tx.parse_dim(text) is None
|
||||
|
||||
|
||||
def test_the_two_printed_spellings_of_one_length_compare_equal():
|
||||
"""The bed plan prints 1' - 0"; the handling diagrams print 1'-0".
|
||||
|
||||
Comparing the strings instead of the values would report a change on
|
||||
every ticket in the set.
|
||||
"""
|
||||
assert tx.parse_dim('1\' - 0"') == tx.parse_dim('1\'-0"')
|
||||
|
||||
|
||||
def test_format_inches_round_trips_through_parse():
|
||||
for v in (0.75, 8.0, 29.5, 275.0, 221.5):
|
||||
assert tx.parse_dim(tx.format_inches(v)) == pytest.approx(v)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# pairing the two issues
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_parse_name_reads_both_namings():
|
||||
old = td.parse_name('WP199.pdf')
|
||||
new = td.parse_name('25881-20132_WP199.pdf')
|
||||
assert old['mark'] == 'WP199' and old['control'] is None
|
||||
assert new['mark'] == 'WP199'
|
||||
assert new['job'] == '25881' and new['control'] == '20132'
|
||||
|
||||
|
||||
def test_parse_name_reads_a_revision_suffix():
|
||||
assert td.parse_name('25881-20107_WP179_R1.pdf')['revision'] == 'R1'
|
||||
|
||||
|
||||
def test_a_naming_change_between_issues_is_itself_reported():
|
||||
plan = td.pair_sets(['WP199.pdf'], ['25881-20132_WP199.pdf'])
|
||||
assert plan['naming']['changed'] is True
|
||||
assert plan['naming']['old_has_control'] == 0
|
||||
assert plan['naming']['new_has_control'] == 1
|
||||
|
||||
|
||||
def test_one_old_file_pairs_with_every_new_control_number():
|
||||
"""One ticket per control number: three identical panels, three tickets.
|
||||
|
||||
The old issue named files by mark and so had exactly one WP215. Reporting
|
||||
only the first of the three new ones would leave two issued tickets
|
||||
undiffed.
|
||||
"""
|
||||
plan = td.pair_sets(
|
||||
['WP215.pdf'],
|
||||
['25881-20161_WP215.pdf', '25881-20163_WP215.pdf',
|
||||
'25881-20165_WP215.pdf'])
|
||||
assert plan['naming']['fan_out'] == {'WP215': 3}
|
||||
assert len(plan['pairs']) == 1
|
||||
assert len(plan['pairs'][0][1]) == 3
|
||||
|
||||
|
||||
def test_a_mark_in_only_one_issue_is_named_not_dropped():
|
||||
plan = td.pair_sets(['WP199.pdf', 'WP999.pdf'], ['25881-20132_WP199.pdf',
|
||||
'25881-20999_WP888.pdf'])
|
||||
assert plan['old_only'] == ['WP999.pdf']
|
||||
assert plan['new_only'] == ['25881-20999_WP888.pdf']
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# helpers for building fake tickets
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def span(text, page=0, x=100.0, y=100.0, vertical=False, size=9.5):
|
||||
return {'page': page, 'text': text, 'x': x, 'y': y, 'x1': x + 30,
|
||||
'y1': y + 8, 'size': size, 'font': 'Arial', 'vertical': vertical,
|
||||
'block': 0}
|
||||
|
||||
|
||||
def chain(texts, page=0, vertical=False, pos=100.0, along=50.0, step=40.0):
|
||||
items = []
|
||||
for i, t in enumerate(texts):
|
||||
if vertical:
|
||||
items.append(span(t, page=page, x=pos, y=along + i * step,
|
||||
vertical=True))
|
||||
else:
|
||||
items.append(span(t, page=page, x=along + i * step, y=pos))
|
||||
return {'page': page, 'vertical': vertical, 'pos': pos, 'along': along,
|
||||
'items': items}
|
||||
|
||||
|
||||
def ticket(**kw):
|
||||
base = {'pages': 2, 'hazards': [], 'title_block': {}, 'bom': [],
|
||||
'handling': {}, 'notes': [], 'chains': [], 'view_titles': [],
|
||||
'callouts': [], 'timestamps': [], 'bom_bbox': None}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def diff(a, b):
|
||||
return td.compare(a, b, tx.parse_dim, tx.format_inches, tx.locate)
|
||||
|
||||
|
||||
def of(findings, kind=None, severity=None):
|
||||
return [f for f in findings
|
||||
if (kind is None or f['kind'] == kind)
|
||||
and (severity is None or f['severity'] == severity)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# title block
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _field(v, ok=True):
|
||||
return {'value': v, 'ok': ok, 'page': 0, 'x': 1000.0, 'y': 700.0}
|
||||
|
||||
|
||||
def test_a_changed_length_is_material():
|
||||
a = ticket(title_block={'LENGTH:': _field('22\'-11"')})
|
||||
b = ticket(title_block={'LENGTH:': _field('24\'-0"')})
|
||||
f = of(diff(a, b), 'TITLE')
|
||||
assert len(f) == 1
|
||||
assert f[0]['severity'] == td.MATERIAL
|
||||
assert f[0]['old'] == '22\'-11"' and f[0]['new'] == '24\'-0"'
|
||||
|
||||
|
||||
def test_a_changed_drafter_is_housekeeping_not_material():
|
||||
a = ticket(title_block={'DRAWN BY:': _field('AB')})
|
||||
b = ticket(title_block={'DRAWN BY:': _field('CD')})
|
||||
assert of(diff(a, b), 'TITLE')[0]['severity'] == td.HOUSEKEEP
|
||||
|
||||
|
||||
def test_a_title_value_that_fails_its_own_shape_is_uncertain_not_a_change():
|
||||
"""A cell read from the wrong side of a boundary is plausible and wrong.
|
||||
|
||||
Two plausible wrong strings diffed against each other is the worst output
|
||||
this tool could produce, so a value that does not look like a weight is
|
||||
reported as unreadable instead.
|
||||
"""
|
||||
a = ticket(title_block={'WEIGHT (lbs.):': _field('24345')})
|
||||
b = ticket(title_block={'WEIGHT (lbs.):': _field('PTAC(PTAC)', ok=False)})
|
||||
f = of(diff(a, b), 'TITLE')
|
||||
assert len(f) == 1 and f[0]['severity'] == td.UNCERTAIN
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# bill of material
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _row(qty, mark, desc='', y=100.0):
|
||||
return {'qty': qty, 'mark': mark, 'desc': desc, 'page': 0, 'x': 960.0,
|
||||
'y': y}
|
||||
|
||||
|
||||
def test_a_bom_quantity_change_is_material_and_names_the_part():
|
||||
a = ticket(bom=[_row('4', 'P76', 'Erection Anchor - 8 ton')])
|
||||
b = ticket(bom=[_row('6', 'P76', 'Erection Anchor - 8 ton')])
|
||||
f = of(diff(a, b), 'BOM')
|
||||
assert len(f) == 1
|
||||
assert f[0]['severity'] == td.MATERIAL
|
||||
assert 'P76' in f[0]['where']
|
||||
assert (f[0]['old'], f[0]['new']) == ('4', '6')
|
||||
|
||||
|
||||
def test_a_bar_substitution_shows_as_one_out_one_in():
|
||||
a = ticket(bom=[_row('2', 'SR4183')])
|
||||
b = ticket(bom=[_row('2', 'SR4192')])
|
||||
f = of(diff(a, b), 'BOM')
|
||||
assert {x['what'] for x in f} == {'item removed from the bill of material',
|
||||
'item added to the bill of material'}
|
||||
|
||||
|
||||
def test_overlaid_text_in_the_bom_downgrades_every_bom_finding():
|
||||
"""Measured on a real reissue: the earlier file draws the strand row on
|
||||
top of the bar row above it, and the text layer hands back the wrong mark
|
||||
for the right quantity. Two phantom add/remove pairs and a 376 -> 4
|
||||
quantity, all completely plausible. They are reported -- as uncertain."""
|
||||
haz = [{'mode': 'overlaid-text', 'page': 0, 'x': 990.0, 'y': 384.0,
|
||||
'texts': ['0010001', 'SR5602'], 'detail': 'x'}]
|
||||
box = {'page': 0, 'x0': 950.0, 'y0': 30.0, 'x1': 1200.0, 'y1': 400.0}
|
||||
a = ticket(bom=[_row('376', 'SR5602')], hazards=haz, bom_bbox=box)
|
||||
b = ticket(bom=[_row('4', 'SR5602')], bom_bbox=box)
|
||||
f = of(diff(a, b), 'BOM')
|
||||
assert f and all(x['severity'] == td.UNCERTAIN for x in f)
|
||||
assert all('artefact' in (x['note'] or '') for x in f)
|
||||
|
||||
|
||||
def test_a_clean_bom_is_not_downgraded():
|
||||
box = {'page': 0, 'x0': 950.0, 'y0': 30.0, 'x1': 1200.0, 'y1': 400.0}
|
||||
a = ticket(bom=[_row('376', 'SR5602')], bom_bbox=box)
|
||||
b = ticket(bom=[_row('4', 'SR5602')], bom_bbox=box)
|
||||
assert of(diff(a, b), 'BOM')[0]['severity'] == td.MATERIAL
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# handling diagrams
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_the_dunnage_correction_is_reported_in_printed_order():
|
||||
"""WP199, the engineer's own fix: 4'-9"/4'-9" -> 5'-0"/3'-0".
|
||||
|
||||
The order is the order they are PRINTED in, left to right. Reading the
|
||||
pair the other way round was measured to turn this corrected ticket back
|
||||
into a failure elsewhere in this codebase, and a diff that argues with a
|
||||
fix its reader has already made is a diff he stops opening.
|
||||
"""
|
||||
a = ticket(handling={'DUNNAGE': [span('4\'-9"', x=820), span('4\'-9"', x=915)]})
|
||||
b = ticket(handling={'DUNNAGE': [span('5\'-0"', x=820), span('3\'-0"', x=915)]})
|
||||
f = of(diff(a, b), 'HANDLING')
|
||||
assert len(f) == 2
|
||||
assert all(x['severity'] == td.MATERIAL for x in f)
|
||||
assert (f[0]['old'], f[0]['new']) == ('4\'-9"', '5\'-0"')
|
||||
assert 'dimension 1 of 2' in f[0]['where']
|
||||
assert (f[1]['old'], f[1]['new']) == ('4\'-9"', '3\'-0"')
|
||||
|
||||
|
||||
def test_an_unchanged_handling_diagram_reports_nothing():
|
||||
d = {'DUNNAGE': [span('4\'-9"', x=820), span('4\'-9"', x=915)]}
|
||||
assert of(diff(ticket(handling=d), ticket(handling=d)), 'HANDLING') == []
|
||||
|
||||
|
||||
def test_the_same_station_spelled_two_ways_is_not_a_change():
|
||||
a = ticket(handling={'ERECTION': [span('2\' - 3"')]})
|
||||
b = ticket(handling={'ERECTION': [span('2\'-3"')]})
|
||||
assert of(diff(a, b), 'HANDLING') == []
|
||||
|
||||
|
||||
def test_a_diagram_gaining_a_dimension_is_reported_whole():
|
||||
a = ticket(handling={'PICKING/STRIPPING': [span('2\'-0"', x=820)]})
|
||||
b = ticket(handling={'PICKING/STRIPPING': [span('2\'-0"', x=820),
|
||||
span('6\'-6"', x=860)]})
|
||||
f = of(diff(a, b), 'HANDLING')
|
||||
assert len(f) == 1 and f[0]['severity'] == td.MATERIAL
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# dimension chains -- the false-positive regressions
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_a_replot_that_moves_every_chain_reports_nothing():
|
||||
"""WP194: adding one production note pushed the bed plan 89 points up the
|
||||
page. Every chain changed position; none changed value. The first version
|
||||
of this tool reported 40 material changes on that ticket."""
|
||||
texts = [['1\' - 9"', '7\' - 5"', '4\' - 7"'], ['8"'], ['2\' - 10 1/2"']]
|
||||
a = ticket(chains=[chain(t, pos=200.0 + 20 * i) for i, t in enumerate(texts)])
|
||||
b = ticket(chains=[chain(t, pos=111.0 + 20 * i) for i, t in enumerate(texts)])
|
||||
assert of(diff(a, b), 'DIM') == []
|
||||
|
||||
|
||||
def test_a_redistribution_says_the_total_did_not_change():
|
||||
"""WP219/WP220: a pair of P76 anchors pulled from 1'-0" to 1'-6" apart.
|
||||
|
||||
Two segments moved and the run they sit on is the same length as before.
|
||||
Saying so is the difference between "two numbers changed" and "the anchors
|
||||
moved, the panel did not get longer".
|
||||
"""
|
||||
a = ticket(chains=[chain(['3\' - 3"', '1\' - 0"', '4\' - 0"'])])
|
||||
b = ticket(chains=[chain(['2\' - 9"', '1\' - 6"', '4\' - 0"'])])
|
||||
f = of(diff(a, b), 'DIM')
|
||||
assert len(f) == 2
|
||||
assert all(x['severity'] == td.MATERIAL for x in f)
|
||||
assert (f[0]['old'], f[0]['new']) == ('3\' - 3"', '2\' - 9"')
|
||||
assert 'segment 1 of 3' in f[0]['where']
|
||||
assert 'total unchanged' in f[0]['note']
|
||||
|
||||
|
||||
def test_a_real_size_change_reports_the_new_total():
|
||||
a = ticket(chains=[chain(['1\' - 0"', '4\' - 0"'])])
|
||||
b = ticket(chains=[chain(['1\' - 6"', '4\' - 0"'])])
|
||||
f = of(diff(a, b), 'DIM')
|
||||
assert len(f) == 1
|
||||
assert '5\'-0"' in f[0]['note'] and '5\'-6"' in f[0]['note']
|
||||
|
||||
|
||||
def test_regrouping_the_same_values_is_not_a_change():
|
||||
"""WP196 and WP412: page 2 carries byte-identical dimension text on both
|
||||
issues, and the section detail was still re-laid-out enough that the
|
||||
chain grouping came out differently. Reported as chains that was five
|
||||
confident wrong material findings on two unchanged tickets."""
|
||||
a = ticket(chains=[chain(['4 1/2"', '5 3/4"', '2 1/4"'], vertical=True,
|
||||
pos=186.0),
|
||||
chain(['1 3/4"', '1 3/4"'], vertical=True, pos=197.0)])
|
||||
b = ticket(chains=[chain(['4 1/2"'], vertical=True, pos=150.0),
|
||||
chain(['5 3/4"', '2 1/4"', '1 3/4"', '1 3/4"'],
|
||||
vertical=True, pos=162.0)])
|
||||
assert of(diff(a, b), 'DIM') == []
|
||||
|
||||
|
||||
def test_a_dimension_that_genuinely_appears_is_still_reported_after_regrouping():
|
||||
a = ticket(chains=[chain(['4 1/2"', '5 3/4"'], vertical=True, pos=186.0)])
|
||||
b = ticket(chains=[chain(['4 1/2"'], vertical=True, pos=150.0),
|
||||
chain(['5 3/4"', '6\' - 9"'], vertical=True, pos=162.0)])
|
||||
f = of(diff(a, b), 'DIM')
|
||||
assert len(f) == 1
|
||||
assert f[0]['new'] == '6\' - 9"' and f[0]['severity'] == td.MATERIAL
|
||||
|
||||
|
||||
def test_chains_on_different_pages_are_never_matched_to_each_other():
|
||||
"""8" on page 1 and 1'-0" on page 2 swapping places is two changes, one on
|
||||
each page -- never one chain on page 1 matched to one on page 2."""
|
||||
a = ticket(chains=[chain(['8"'], page=0), chain(['1\'-0"'], page=1)])
|
||||
b = ticket(chains=[chain(['1\'-0"'], page=0), chain(['8"'], page=1)])
|
||||
f = of(diff(a, b), 'DIM')
|
||||
assert len(f) == 2
|
||||
assert {x['loc_old']['page'] for x in f} == {0, 1}
|
||||
for x in f:
|
||||
assert x['loc_old']['page'] == x['loc_new']['page']
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# callouts and notes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_a_reworded_part_callout_is_one_material_finding_not_two_advisory():
|
||||
"""WP199: P106 (S.I.F.)(E.S.) became P106 (S.I.F.).
|
||||
|
||||
As two multiset lines that is noise a reader skims. As one finding naming
|
||||
the part, it is an instruction about where an embed goes.
|
||||
"""
|
||||
a = ticket(callouts=[span('P106 (S.I.F.)(E.S.)')])
|
||||
b = ticket(callouts=[span('P106 (S.I.F.)')])
|
||||
f = of(diff(a, b), 'CALLOUT')
|
||||
assert len(f) == 1
|
||||
assert f[0]['severity'] == td.MATERIAL
|
||||
assert 'P106' in f[0]['what']
|
||||
|
||||
|
||||
def test_a_callout_naming_no_part_stays_advisory():
|
||||
a = ticket(callouts=[span('BLOCKOUT')])
|
||||
b = ticket(callouts=[span('POCKET')])
|
||||
assert all(x['severity'] == td.ADVISORY for x in of(diff(a, b), 'CALLOUT'))
|
||||
|
||||
|
||||
def test_a_plot_stamp_is_housekeeping():
|
||||
a = ticket(callouts=[span('8/26/2026 3:40:39 PM')])
|
||||
b = ticket(callouts=[span('8/27/2026 9:10:48 AM')])
|
||||
assert all(x['severity'] == td.HOUSEKEEP for x in of(diff(a, b), 'CALLOUT'))
|
||||
|
||||
|
||||
def test_a_callout_that_only_moved_is_not_a_change():
|
||||
a = ticket(callouts=[span('SR333 (E.L.) TYP.', x=470.0, y=490.0)])
|
||||
b = ticket(callouts=[span('SR333 (E.L.) TYP.', x=478.0, y=402.0)])
|
||||
assert of(diff(a, b), 'CALLOUT') == []
|
||||
|
||||
|
||||
def _note(text, y=300.0):
|
||||
return {'text': text, 'page': 0, 'x': 500.0, 'y': y}
|
||||
|
||||
|
||||
def test_an_added_note_arrives_as_one_paragraph():
|
||||
a = ticket(notes=[])
|
||||
b = ticket(notes=[_note('PRODUCTION NOTE: POST POUR CHECK MUST ENSURE '
|
||||
'THAT ALL THREADED EMBEDS ARE CLEAN')])
|
||||
f = of(diff(a, b), 'NOTE')
|
||||
assert len(f) == 1 and f[0]['what'] == 'note added'
|
||||
|
||||
|
||||
def test_a_reworded_note_is_one_finding_with_both_versions():
|
||||
a = ticket(notes=[_note('PRODUCTION NOTE: GROMMET STRAND & SR603')])
|
||||
b = ticket(notes=[_note('PRODUCTION NOTE: GROMMET STRAND & SR625')])
|
||||
f = of(diff(a, b), 'NOTE')
|
||||
assert len(f) == 1
|
||||
assert f[0]['what'] == 'note reworded'
|
||||
assert 'SR603' in f[0]['old'] and 'SR625' in f[0]['new']
|
||||
|
||||
|
||||
def test_a_note_starts_at_its_heading_not_at_its_pdf_block():
|
||||
"""Measured: on page 2 of one ticket the PDF block holding a PRODUCTION
|
||||
NOTE also holds a 6" dimension and a bar tag drawn nearby. Taking the
|
||||
block whole reported the note as removed on one issue and added on the
|
||||
other, because the bar tag next to it had moved."""
|
||||
ss = [span('6"', y=10.0), span('SR567 (E.L.)', y=20.0),
|
||||
span('PRODUCTION NOTE:', y=30.0),
|
||||
span('3/4" CHAMFERS EA. FACE', y=40.0)]
|
||||
got = tx.notes(ss)
|
||||
assert len(got) == 1
|
||||
assert got[0]['text'] == 'PRODUCTION NOTE: 3/4" CHAMFERS EA. FACE'
|
||||
|
||||
|
||||
def test_two_notes_in_one_block_come_out_as_two_notes():
|
||||
ss = [span('PRODUCTION NOTE:', y=10.0), span('GROMMET STRAND', y=20.0),
|
||||
span('PRODUCTION NOTE:', y=30.0), span('3/4" CHAMFERS', y=40.0)]
|
||||
assert [n['text'] for n in tx.notes(ss)] == [
|
||||
'PRODUCTION NOTE: GROMMET STRAND', 'PRODUCTION NOTE: 3/4" CHAMFERS']
|
||||
|
||||
|
||||
def test_the_page_number_never_lands_inside_a_note():
|
||||
ss = [span('Notes:', y=10.0), span('1. 1/2" DIA. 270 ksi', y=20.0),
|
||||
span('PAGE 2 OF 2', y=30.0)]
|
||||
assert tx.notes(ss)[0]['text'] == 'Notes: 1. 1/2" DIA. 270 ksi'
|
||||
|
||||
|
||||
def test_a_changed_page_number_is_housekeeping():
|
||||
a = ticket(callouts=[span('PAGE 1 OF 2')])
|
||||
b = ticket(callouts=[span('PAGE 2 OF 2')])
|
||||
assert all(x['severity'] == td.HOUSEKEEP for x in of(diff(a, b), 'CALLOUT'))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# hazards
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_the_four_text_layer_failure_modes_are_each_detected():
|
||||
spans = [
|
||||
span('1316"'), # collapsed fraction
|
||||
span(u'\x04\x05\x06'), # cipher-shifted font
|
||||
span('SR5602', x=500.0, y=380.0), # overlaid pair ...
|
||||
span('0010001', x=500.4, y=380.2), # ... same point
|
||||
]
|
||||
modes = {h['mode'] for h in tx.hazards(spans, npages=1)}
|
||||
assert 'collapsed-fraction' in modes
|
||||
assert 'cipher-font' in modes
|
||||
assert 'overlaid-text' in modes
|
||||
|
||||
|
||||
def test_a_page_with_almost_no_text_is_flagged_as_unread_not_unchanged():
|
||||
haz = tx.hazards([span('BED PLAN')], npages=2)
|
||||
thin = [h for h in haz if h['mode'] == 'thin-text-layer']
|
||||
assert len(thin) == 2
|
||||
assert 'unread' in thin[0]['detail']
|
||||
|
||||
|
||||
def test_a_clean_file_produces_no_hazards():
|
||||
spans = [span('BED PLAN', x=10.0 * i, y=20.0 * i) for i in range(30)]
|
||||
assert tx.hazards(spans, npages=1) == []
|
||||
|
||||
|
||||
def test_every_hazard_reaches_the_report_as_uncertain():
|
||||
a = ticket(hazards=[{'mode': 'overlaid-text', 'page': 0, 'x': 10.0,
|
||||
'y': 10.0, 'texts': ['a', 'b'], 'detail': 'd'}])
|
||||
f = of(diff(a, ticket()), 'HAZARD')
|
||||
assert len(f) == 1 and f[0]['severity'] == td.UNCERTAIN
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# report shape
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_material_findings_sort_above_everything_else():
|
||||
a = ticket(title_block={'LENGTH:': _field('22\'-11"'),
|
||||
'DRAWN BY:': _field('AB')},
|
||||
notes=[_note('PRODUCTION NOTE: A')])
|
||||
b = ticket(title_block={'LENGTH:': _field('24\'-0"'),
|
||||
'DRAWN BY:': _field('CD')},
|
||||
notes=[_note('PRODUCTION NOTE: B')])
|
||||
sev = [f['severity'] for f in diff(a, b)]
|
||||
assert sev[0] == td.MATERIAL
|
||||
assert sev == sorted(sev, key=lambda s: td._ORDER[s])
|
||||
|
||||
|
||||
def test_a_page_count_change_is_material():
|
||||
f = of(diff(ticket(pages=2), ticket(pages=3)), 'SHEET')
|
||||
assert len(f) == 1 and f[0]['severity'] == td.MATERIAL
|
||||
|
||||
|
||||
def test_two_identical_tickets_produce_nothing_at_all():
|
||||
t = ticket(title_block={'LENGTH:': _field('22\'-11"')},
|
||||
bom=[_row('4', 'P76')],
|
||||
handling={'DUNNAGE': [span('4\'-9"')]},
|
||||
chains=[chain(['1\'-0"', '4\'-0"'])],
|
||||
notes=[_note('PRODUCTION NOTE: A')],
|
||||
callouts=[span('BLOCKOUT')])
|
||||
assert diff(t, t) == []
|
||||
|
||||
|
||||
def test_summarise_counts_every_severity():
|
||||
a = ticket(title_block={'LENGTH:': _field('22\'-11"')})
|
||||
b = ticket(title_block={'LENGTH:': _field('24\'-0"')})
|
||||
counts = td.summarise(diff(a, b))
|
||||
assert counts[td.MATERIAL] == 1
|
||||
assert set(counts) == {td.MATERIAL, td.ADVISORY, td.HOUSEKEEP,
|
||||
td.UNCERTAIN}
|
||||
|
||||
|
||||
def test_a_finding_can_always_be_pointed_at_on_the_sheet():
|
||||
"""Every material finding carries the coordinates its crop is rendered
|
||||
from. "3 differences" sends someone hunting; a picture does not."""
|
||||
a = ticket(chains=[chain(['3\' - 3"', '4\' - 0"'])])
|
||||
b = ticket(chains=[chain(['2\' - 9"', '4\' - 0"'])])
|
||||
f = of(diff(a, b), 'DIM')[0]
|
||||
assert f['loc_old']['page'] == 0 and f['loc_new']['page'] == 0
|
||||
assert f['loc_old']['x'] is not None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# locators
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_a_view_title_is_a_drawing_name_not_a_section_bubble():
|
||||
spans = [span('BED PLAN', size=12.6), span('A', size=12.6),
|
||||
span('MK', size=12.6), span('1/4" = 1\' - 0"', size=9.9),
|
||||
span('SECTION', page=1, size=12.6)]
|
||||
titles = tx.view_titles(spans)
|
||||
assert {t['text'] for t in titles} == {'BED PLAN', 'SECTION'}
|
||||
|
||||
|
||||
def test_locate_names_the_view_and_the_corner():
|
||||
titles = tx.view_titles([span('BED PLAN', size=12.6, x=350.0, y=620.0)])
|
||||
where = tx.locate(titles, 0, 100.0, 100.0)
|
||||
assert 'BED PLAN' in where and 'page 1' in where and 'upper left' in where
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,721 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The TicketQC evidence file -- migration step 1.
|
||||
|
||||
WHAT THIS FILE IS DEFENDING
|
||||
----------------------------
|
||||
`ticketqc.ticket_to_json` turns one ticket into a JSON document that carries
|
||||
no live Revit object. Nothing in the extension reads that document yet, which
|
||||
is exactly why it can ship: a file nobody reads cannot change what a press
|
||||
reports. So there is nothing here about findings, severities or checklist
|
||||
rows -- no judgement moved, and a test that asserted one would be testing the
|
||||
wrong step.
|
||||
|
||||
What IS here is the four contract rules from `docs/architecture-migration.md`
|
||||
section 2, each with a test that would actually fail if the rule were broken:
|
||||
|
||||
1. DISCARD WHOLE ON MISMATCH -- `load_evidence` on a file whose schema is
|
||||
not the current one comes back with NO tickets, not with the tickets it
|
||||
could still parse.
|
||||
2. NO LIVE OBJECTS -- `test_nothing_but_plain_data_survives_the_writer`
|
||||
walks the whole produced structure and fails on any value that is not
|
||||
str / int / float / bool / None / list / dict. MECHANICAL, not a spot
|
||||
check: that is the only form in which rule 2 means anything, because the
|
||||
live object that breaks the boundary will be one nobody thought to look
|
||||
for. The ticket it walks is built with live stand-ins in every place
|
||||
`read_ticket` really puts one.
|
||||
3. ABSENCE IS NOT A VALUE -- every null in the document is required to have
|
||||
a sentence beside it, and that requirement is asserted by walking the
|
||||
document rather than by naming keys.
|
||||
4. PROVENANCE TRAVELS -- schema, clock, build, doc key, knowledge layers,
|
||||
and the spec-vs-regex source of the intent.
|
||||
|
||||
THE REVIT STUB
|
||||
---------------
|
||||
`vibe.ticketqc` imports eight names from `Autodesk.Revit.DB` at module scope,
|
||||
so it cannot be imported on host CPython without help. The stub below is the
|
||||
same one `test_ticketchecks_host.py` uses and it is PUT BACK afterwards for
|
||||
the same reason: `sys.modules` is process-wide, and
|
||||
`test_reinforceoffline.py` proves the offline path pulls in no Revit by
|
||||
asserting no `Autodesk*` module is loaded.
|
||||
|
||||
Nothing under test here calls the Revit API. `_flat_text` is the one Revit
|
||||
read `ticket_to_json` makes -- it collects a legend view's text notes -- and
|
||||
it is monkeypatched where a legend is in play. If a future edit makes any of
|
||||
these functions reach for Revit for real, these tests raise rather than
|
||||
quietly pass, which is the correct outcome.
|
||||
"""
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _stub_revit():
|
||||
"""`Autodesk.Revit.DB`, inert, for a host interpreter."""
|
||||
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',
|
||||
'StorageType', 'ElementId', 'XYZ', 'Transform',
|
||||
'Options', 'Solid', 'BoundingBoxXYZ'):
|
||||
setattr(db, name, type(str(name), (object,), {}))
|
||||
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
|
||||
from vibe import knowledge as K # noqa: E402
|
||||
from vibe import compat # noqa: E402
|
||||
finally:
|
||||
for _name in _ADDED:
|
||||
sys.modules.pop(_name, None)
|
||||
|
||||
|
||||
# ------------------------------------------------------- live-object doubles
|
||||
#
|
||||
# Each of these stands in for something `read_ticket` really leaves on the
|
||||
# ticket. They are deliberately NOT dicts: the point of the walk below is
|
||||
# that a foreign object cannot reach the file, and a double that is already
|
||||
# plain data would prove nothing.
|
||||
|
||||
class _Id(object):
|
||||
def __init__(self, value):
|
||||
self.IntegerValue = value
|
||||
|
||||
|
||||
class _View(object):
|
||||
"""A live `View` -- what `t['legends']` holds."""
|
||||
|
||||
def __init__(self, ident, name):
|
||||
self.Id = _Id(ident)
|
||||
self.Name = name
|
||||
|
||||
|
||||
class _Sheet(object):
|
||||
"""A live `ViewSheet` -- what `t['sheets']` and `t['sheet']` hold."""
|
||||
|
||||
def __init__(self, ident, name, number):
|
||||
self.Id = _Id(ident)
|
||||
self.Name = name
|
||||
self.SheetNumber = number
|
||||
|
||||
|
||||
class _Doc(object):
|
||||
"""A live `Document` -- what `t['doc']` holds."""
|
||||
|
||||
def __init__(self, title='sample_job', path=None):
|
||||
self.Title = title
|
||||
self.PathName = path
|
||||
|
||||
|
||||
class _Transform(object):
|
||||
"""A live `Transform` -- what `ticketchecks` caches on `t['frame']`."""
|
||||
|
||||
|
||||
class _XYZ(object):
|
||||
"""A live `XYZ` -- what `t['strand_points']` holds before conversion."""
|
||||
|
||||
|
||||
# A model path, assembled rather than spelled.
|
||||
#
|
||||
# `tools/scrub.py` rejects EVERY `*.rvt` token anywhere in the working tree --
|
||||
# a structural pattern with no placeholder exemption, and rightly so: the
|
||||
# exemption is the hole a real filename walks through. So the extension is
|
||||
# built here instead of written, and what the tests below actually exercise --
|
||||
# that `_doc_identity` reduces a path to its STEM, and never carries an
|
||||
# absolute user path into the file -- is unaffected. The extension only has
|
||||
# to be an extension.
|
||||
MODEL_EXT = '.' + 'rvt'
|
||||
MODEL_PATH = 'C:' + chr(92) + 'jobs' + chr(92) + 'sample_job' + MODEL_EXT
|
||||
OTHER_MODEL_PATH = 'C:' + chr(92) + 'jobs' + chr(92) + 'other_job' + MODEL_EXT
|
||||
|
||||
|
||||
PLAIN_TYPES = (bool, int, float, type(None)) + compat.string_types
|
||||
|
||||
|
||||
def offenders(node, path='$'):
|
||||
"""Every value in `node` that JSON cannot carry, with where it was.
|
||||
|
||||
THE MECHANICAL FORM OF RULE 2. It walks, it does not sample -- the live
|
||||
object that breaks a file boundary is by definition the one nobody
|
||||
thought to assert on. A non-text dict key counts as an offender too:
|
||||
`json.dumps` silently stringifies those, so a float-keyed dict survives
|
||||
the write and comes back a different shape.
|
||||
"""
|
||||
bad = []
|
||||
if isinstance(node, dict):
|
||||
for key in node:
|
||||
if not isinstance(key, compat.string_types):
|
||||
bad.append(('{0} key {1!r}'.format(path, key),
|
||||
type(key).__name__))
|
||||
bad.extend(offenders(node[key], '{0}.{1}'.format(path, key)))
|
||||
elif isinstance(node, list):
|
||||
for i, item in enumerate(node):
|
||||
bad.extend(offenders(item, '{0}[{1}]'.format(path, i)))
|
||||
elif not isinstance(node, PLAIN_TYPES):
|
||||
bad.append((path, type(node).__name__))
|
||||
elif isinstance(node, float) and not (-1e308 < node < 1e308
|
||||
and node == node):
|
||||
bad.append((path, 'non-finite float'))
|
||||
return bad
|
||||
|
||||
|
||||
# The lists of rows this writer builds itself. A null inside one of these is
|
||||
# answered by that row's own `reason` rather than by the document's `missing`,
|
||||
# because the row is the unit: one legend can be unreadable while the next is
|
||||
# fine.
|
||||
ROW_LISTS = ('legends', 'sheets', 'lifters', 'strand_by_dia')
|
||||
|
||||
|
||||
def unanswered_nulls(out):
|
||||
"""Every null in `out` that nothing explains, by path.
|
||||
|
||||
THE MECHANICAL FORM OF RULE 3, and the boundary of it stated out loud:
|
||||
|
||||
* a top-level field that is null must have a sentence in `missing`;
|
||||
* a row this writer built must carry its own `reason` when any of its
|
||||
values is null;
|
||||
* a sub-document carried through verbatim -- `intent`, `bom_schedules`,
|
||||
`strand_layout`, `panel_extents` -- is NOT re-interpreted here. Its
|
||||
nulls are its own reader's business (`bom_schedules` already carries
|
||||
an `unreadable` list of its own), and step 1 does not re-judge them.
|
||||
"""
|
||||
bad = []
|
||||
missing = out.get('missing') or {}
|
||||
for key in out:
|
||||
if key in ('missing', 'unserialised_keys'):
|
||||
continue
|
||||
if out[key] is None and not (missing.get(key) or '').strip():
|
||||
bad.append('$.{0}'.format(key))
|
||||
for key in ROW_LISTS:
|
||||
for i, row in enumerate(out.get(key) or []):
|
||||
holes = [k for k in row if k != 'reason' and row[k] is None]
|
||||
if holes and not ((row.get('reason') or '').strip()):
|
||||
bad.append('$.{0}[{1}].{2}'.format(key, i, ','.join(holes)))
|
||||
return bad
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- fixtures
|
||||
|
||||
def full_ticket(doc=None):
|
||||
"""A ticket in the shape `read_ticket` really returns for a full read.
|
||||
|
||||
Every live holdout is present and live: the document, the legend views,
|
||||
the sheets -- plus a couple of the caches `ticketchecks` leaves behind
|
||||
after `check_ticket` has run, because the button serialises the ticket
|
||||
AFTER the checks and those caches are on it by then.
|
||||
"""
|
||||
doc = doc if doc is not None else _Doc('sample_job',
|
||||
MODEL_PATH)
|
||||
sheets = [_Sheet(9001, 'WP199', 'T-101'), _Sheet(9002, 'WP199', 'T-102')]
|
||||
return {
|
||||
'mark': 'WP199', 'asm_id': 4242,
|
||||
'doc': doc,
|
||||
'bom': {'PNL_SOLID': 1, '1-2_STRAND': 14, 'WWF_D3': 2},
|
||||
'legends': [_View(7001, 'STRAND PATTERN B')],
|
||||
'legend_name': 'STRAND PATTERN B', 'legend_text': 'USE (14) 1/2"',
|
||||
'strand_by_dia': {0.5: 14},
|
||||
'strand_qty': 14,
|
||||
'strand_points': None,
|
||||
'strand_layout': {'across': [3.0, 9.0], 'across_from_far': [9.0, 3.0],
|
||||
'rows': [4.0], 'width_in': 143.0},
|
||||
'wwf_count': 2, 'wwf_mesh': '6X6-D3/D3',
|
||||
'sheet': sheets[0], 'sheets': sheets, 'sheet_id': 9001,
|
||||
'panel_id': 5150, 'family': 'PNL_SOLID',
|
||||
'design_number': 'PNL1.0',
|
||||
'length': 22.9167, 'width': 11.9167, 'thickness': 0.6667,
|
||||
'pcf': 150.0, 'weight_lb': 24345.0,
|
||||
'panel_extents': ((0.0, 0.0, 0.0), (22.9167, 11.9167, 0.6667)),
|
||||
'lifters': [{'family': 'P76', 'id': 6001, 'point': None,
|
||||
'local': (-7.5, 0.0, 0.0)}],
|
||||
'ticket_strand_count': 14,
|
||||
'tkt_design_number': 'PNL1.0', 'plant': 'PLANT-A',
|
||||
'tkt_length': "22'-11\"", 'tkt_width': "11'-11\"",
|
||||
'tkt_depth': '8"',
|
||||
'tkt_fc': '6000', 'tkt_fci': '3500', 'tkt_pcf': '150',
|
||||
'bom_schedules': {'parts': [['14', '0010001', 'STRAND']],
|
||||
'strand_linear_ft': 321.0,
|
||||
'schedules': ['PARTS_LIST', 'STRAND_SCHEDULE'],
|
||||
'unreadable': []},
|
||||
# left behind by the checks, on the ticket the button serialises
|
||||
'frame': _Transform(),
|
||||
'member_boxes': [{'mark': 'P76', 'lo': (0.0, 0.0, 0.0)}],
|
||||
'_debond_requirement': {'runs': [], 'reason': None},
|
||||
}
|
||||
|
||||
|
||||
def bare_ticket():
|
||||
"""What `read_ticket` returns when it never found a panel.
|
||||
|
||||
The early return: the member loop ran, nothing was Structural Framing,
|
||||
and everything measured off the panel is absent. `strand_points` still
|
||||
holds LIVE XYZ objects on this path -- `read_ticket` only nulls them
|
||||
after the panel is found -- which is a fourth live holdout that only
|
||||
exists on the failure branch.
|
||||
"""
|
||||
return {'mark': 'WP999', 'asm_id': 1, 'doc': _Doc('sample_job', ''),
|
||||
'bom': {}, 'legends': [], 'legend_name': None,
|
||||
'legend_text': None, 'strand_by_dia': {None: 2},
|
||||
'strand_qty': 2, 'strand_points': [_XYZ(), _XYZ()],
|
||||
'wwf_count': 0, 'wwf_mesh': None, 'sheet': None,
|
||||
'panel_id': None, 'lifters': [], 'weight_lb': None,
|
||||
'ticket_strand_count': None}
|
||||
|
||||
|
||||
def a_result(ticket, design='PNL1.0', source='spec'):
|
||||
return {'design': design, 'spec_source': source, 'ticket': ticket,
|
||||
'intent': {'name': design, 'source': source, 'fc': 6000,
|
||||
'strands': [{'qty': 14, 'dia': 0.5, 'ksi': 270,
|
||||
'it': 31600}],
|
||||
'envelope': {'MAX_LENGTH': (24.0, "24'-0\"")}},
|
||||
'findings': [], 'run_log': []}
|
||||
|
||||
|
||||
# ============================================================ RULE 2, walked
|
||||
|
||||
def test_nothing_but_plain_data_survives_the_writer(monkeypatch):
|
||||
"""THE POINT OF THE WHOLE STEP, asserted mechanically.
|
||||
|
||||
A ticket carrying a live Document, live legend Views, live ViewSheets, a
|
||||
live Transform and a float-keyed dict goes in. What comes out is walked
|
||||
to its leaves and every value must be str / int / float / bool / None /
|
||||
list / dict. Nothing is spot-checked, because the live object that
|
||||
escapes will not be one this file thought to name.
|
||||
"""
|
||||
monkeypatch.setattr(QC, '_flat_text', lambda doc, vid: 'USE (14) 1/2"')
|
||||
out = QC.ticket_to_json(full_ticket(), design='PNL1.0',
|
||||
spec_source='spec')
|
||||
assert offenders(out) == []
|
||||
json.dumps(out, allow_nan=False) # and it really is writable
|
||||
|
||||
|
||||
def test_the_walker_would_actually_catch_a_live_object():
|
||||
"""A test that cannot fail is not a test. The walk is handed the exact
|
||||
shapes it is meant to catch and must name every one of them."""
|
||||
assert offenders({'a': _Doc()}) == [('$.a', '_Doc')]
|
||||
assert offenders({'a': [1, _XYZ()]}) == [('$.a[1]', '_XYZ')]
|
||||
assert offenders({0.5: 14}) == [('$ key 0.5', 'float')]
|
||||
assert offenders({'a': float('nan')}) == [('$.a', 'non-finite float')]
|
||||
|
||||
|
||||
def test_the_bare_ticket_survives_the_writer_too():
|
||||
"""The failure branch, where `strand_points` is still live XYZ."""
|
||||
out = QC.ticket_to_json(bare_ticket())
|
||||
assert offenders(out) == []
|
||||
json.dumps(out, allow_nan=False)
|
||||
|
||||
|
||||
def test_the_whole_run_document_survives_the_writer(monkeypatch):
|
||||
monkeypatch.setattr(QC, '_flat_text', lambda doc, vid: 'text')
|
||||
monkeypatch.setattr(K, 'sources', lambda d: [
|
||||
('catalogue', '/x/catalogue.yaml', True),
|
||||
('plant', '/x/plant.yaml', False)])
|
||||
doc = _Doc('sample_job', MODEL_PATH)
|
||||
document = QC.run_evidence(
|
||||
[a_result(full_ticket(doc)), a_result(bare_ticket(), source='regex')],
|
||||
doc=doc, written_at='2026-01-01T00:00:00')
|
||||
assert offenders(document) == []
|
||||
json.dumps(document, allow_nan=False)
|
||||
|
||||
|
||||
def test_a_live_object_in_an_unexpected_place_becomes_a_null_and_a_reason():
|
||||
"""Rule 2 must not be a crash. `strand_layout` is a dict today; if some
|
||||
future gather puts a live object in one, the file says so and the press
|
||||
still finishes."""
|
||||
t = bare_ticket()
|
||||
t['panel_id'] = 1
|
||||
t['strand_layout'] = {'across': [_XYZ()]}
|
||||
out = QC.ticket_to_json(t)
|
||||
assert offenders(out) == []
|
||||
assert out['strand_layout'] is None
|
||||
assert 'live _XYZ' in out['missing']['strand_layout']
|
||||
|
||||
|
||||
def test_a_non_finite_float_is_refused_rather_than_written():
|
||||
"""`json.dumps` writes `NaN`, which is not JSON. A nan out of a
|
||||
degenerate geometry read is a could-not-measure and lands as one."""
|
||||
t = bare_ticket()
|
||||
t['panel_id'] = 1
|
||||
t['length'] = float('nan')
|
||||
t['width'] = float('inf')
|
||||
out = QC.ticket_to_json(t)
|
||||
assert out['length'] is None and out['width'] is None
|
||||
assert 'finite' in out['missing']['length']
|
||||
assert 'finite' in out['missing']['width']
|
||||
json.dumps(out, allow_nan=False)
|
||||
|
||||
|
||||
def test_a_float_keyed_dict_does_not_reach_the_file_as_one():
|
||||
"""`strand_by_dia` is keyed by diameter. JSON stringifies number keys on
|
||||
the way out and never turns them back, and a None key -- which
|
||||
`strand_diameter` really does produce -- becomes the string "null". It
|
||||
goes out as records instead."""
|
||||
out = QC.ticket_to_json(full_ticket())
|
||||
assert out['strand_by_dia'] == [{'diameter_in': 0.5, 'count': 14,
|
||||
'reason': None}]
|
||||
|
||||
|
||||
# ================================================ RULE 2, the named holdouts
|
||||
|
||||
def test_the_document_becomes_a_key_and_a_stem(monkeypatch):
|
||||
monkeypatch.setattr(QC, '_flat_text', lambda doc, vid: '')
|
||||
out = QC.ticket_to_json(full_ticket())
|
||||
assert out['doc_key'] == 'sample_job'
|
||||
assert out['model_stem'] == 'sample_job'
|
||||
assert 'doc' not in out
|
||||
|
||||
|
||||
def test_the_doc_key_is_reinforceindex_s_answer_not_a_second_one():
|
||||
"""One answer to 'which model is this' in this codebase."""
|
||||
from vibe import reinforceindex
|
||||
doc = _Doc('some other title', OTHER_MODEL_PATH)
|
||||
key, stem, why = QC._doc_identity(doc)
|
||||
assert key == reinforceindex.doc_key(doc)
|
||||
assert stem == 'other_job'
|
||||
assert why is None
|
||||
|
||||
|
||||
def test_a_model_never_saved_has_a_stem_of_null_with_a_reason():
|
||||
out = QC.ticket_to_json(bare_ticket())
|
||||
assert out['model_stem'] is None
|
||||
assert 'never been saved' in out['missing']['model_stem']
|
||||
|
||||
|
||||
def test_the_legends_become_id_name_and_flattened_text(monkeypatch):
|
||||
monkeypatch.setattr(QC, '_flat_text',
|
||||
lambda doc, vid: 'STRAND PATTERN B USE (14)')
|
||||
out = QC.ticket_to_json(full_ticket())
|
||||
assert out['legends'] == [{'id': 7001, 'name': 'STRAND PATTERN B',
|
||||
'text': 'STRAND PATTERN B USE (14)',
|
||||
'reason': None}]
|
||||
|
||||
|
||||
def test_a_legend_that_was_read_and_says_nothing_is_not_a_legend_that_failed(
|
||||
monkeypatch):
|
||||
"""Empty text is an answer. `text: null` with a reason is not."""
|
||||
monkeypatch.setattr(QC, '_flat_text', lambda doc, vid: '')
|
||||
read = QC.ticket_to_json(full_ticket())['legends'][0]
|
||||
assert read['text'] == '' and read['reason'] is None
|
||||
|
||||
def boom(doc, vid):
|
||||
raise ValueError('no')
|
||||
|
||||
monkeypatch.setattr(QC, '_flat_text', boom)
|
||||
failed = QC.ticket_to_json(full_ticket())['legends'][0]
|
||||
assert failed['text'] is None
|
||||
assert 'could not be read' in failed['reason']
|
||||
|
||||
|
||||
def test_the_sheets_become_id_number_and_name():
|
||||
"""The holdout the plan did not name. `t['sheets']` is live ViewSheet
|
||||
and thirteen checks read `.Name` and `.Id` off it."""
|
||||
out = QC.ticket_to_json(full_ticket())
|
||||
assert out['sheets'] == [
|
||||
{'id': 9001, 'number': 'T-101', 'name': 'WP199', 'reason': None},
|
||||
{'id': 9002, 'number': 'T-102', 'name': 'WP199', 'reason': None}]
|
||||
assert out['sheet_id'] == 9001
|
||||
|
||||
|
||||
# ================================================ RULE 3, absence has a voice
|
||||
|
||||
def test_every_null_in_a_ticket_document_carries_a_reason():
|
||||
"""MECHANICAL, and it is the rule the whole checklist rests on.
|
||||
|
||||
On the far side of a file boundary a key that is null and a key that is
|
||||
missing look identical, and both look exactly like 'there was nothing to
|
||||
check here'. So every null is walked for, and every one of them has to be
|
||||
answerable. The bare ticket -- no panel, no sheet -- is the case that
|
||||
fills the document with nulls, which is why it is the one walked.
|
||||
"""
|
||||
out = QC.ticket_to_json(bare_ticket())
|
||||
assert unanswered_nulls(out) == []
|
||||
# and the reasons are sentences, not shrugs
|
||||
for key in out['missing']:
|
||||
assert len(out['missing'][key]) > 20, \
|
||||
'{0} has a reason too short to be one'.format(key)
|
||||
|
||||
|
||||
def test_the_null_rule_holds_on_a_full_read_too(monkeypatch):
|
||||
monkeypatch.setattr(QC, '_flat_text', lambda doc, vid: 'text')
|
||||
out = QC.ticket_to_json(full_ticket(), design='PNL1.0',
|
||||
spec_source='spec')
|
||||
assert unanswered_nulls(out) == []
|
||||
|
||||
|
||||
def test_the_null_rule_would_actually_catch_a_breach():
|
||||
"""A test that cannot fail is not a test. `unanswered_nulls` is handed a
|
||||
document with a bare null in it and must name it."""
|
||||
assert unanswered_nulls({'length': None, 'missing': {}}) == ['$.length']
|
||||
assert unanswered_nulls(
|
||||
{'missing': {}, 'lifters': [{'local': None, 'reason': None}]}) == [
|
||||
'$.lifters[0].local']
|
||||
|
||||
|
||||
def test_a_row_null_is_answered_by_its_own_reason():
|
||||
"""The nested half of the rule, on a real case: a lifter that could not
|
||||
be placed because the panel frame was never solved."""
|
||||
t = bare_ticket()
|
||||
t['lifters'] = [{'family': 'P76', 'id': 1, 'point': None}]
|
||||
out = QC.ticket_to_json(t)
|
||||
row = out['lifters'][0]
|
||||
assert row['local'] is None
|
||||
assert 'frame was not solved' in row['reason']
|
||||
|
||||
|
||||
def test_a_strand_of_unknown_diameter_says_so_rather_than_going_quiet():
|
||||
out = QC.ticket_to_json(bare_ticket())
|
||||
assert out['strand_by_dia'] == [
|
||||
{'diameter_in': None, 'count': 2,
|
||||
'reason': 'neither the strand family name nor its diameter '
|
||||
'parameter said what size this strand is'}]
|
||||
|
||||
|
||||
def test_no_panel_and_no_sheet_are_different_sentences():
|
||||
"""A piece with no framing member and a piece whose sheet is missing are
|
||||
two different failures, and a reader has to be able to tell them apart."""
|
||||
out = QC.ticket_to_json(bare_ticket())
|
||||
assert 'no Structural Framing member' in out['missing']['length']
|
||||
assert 'no Structural Framing member' in out['missing']['tkt_fc']
|
||||
|
||||
t = bare_ticket()
|
||||
t.update({'panel_id': 7, 'length': 20.0, 'family': 'PNL',
|
||||
'design_number': 'PNL1.0'})
|
||||
out = QC.ticket_to_json(t)
|
||||
assert 'no sheet is named after this mark' in out['missing']['tkt_fc']
|
||||
assert 'length' not in out['missing']
|
||||
|
||||
|
||||
def test_a_key_this_writer_does_not_know_is_named_not_dropped():
|
||||
"""Rule 3 turned on the writer itself. The check caches the button
|
||||
leaves on the ticket land here, and that list is step 4's worklist."""
|
||||
out = QC.ticket_to_json(full_ticket())
|
||||
assert out['unserialised_keys'] == ['_debond_requirement', 'frame',
|
||||
'member_boxes']
|
||||
|
||||
|
||||
def test_a_value_that_is_present_gets_no_reason():
|
||||
"""The other half: `missing` must not fill up with noise, or nobody
|
||||
reads it."""
|
||||
out = QC.ticket_to_json(full_ticket(), design='PNL1.0',
|
||||
spec_source='spec')
|
||||
for key in ('mark', 'length', 'width', 'tkt_fc', 'bom_schedules',
|
||||
'design', 'spec_source'):
|
||||
assert key not in out['missing']
|
||||
|
||||
|
||||
# ================================================== RULE 4, provenance rides
|
||||
|
||||
def test_the_envelope_carries_the_schema_the_clock_and_the_build(monkeypatch):
|
||||
monkeypatch.setattr(K, 'sources', lambda d: [])
|
||||
doc = _Doc('sample_job', MODEL_PATH)
|
||||
document = QC.run_evidence([], doc=doc,
|
||||
written_at='2026-01-01T00:00:00')
|
||||
# Pinned to the constant, not to a literal: the schema is BUMPED on any
|
||||
# key change (rule 1 -- discard whole, never migrate), so a literal here
|
||||
# only ever means an edit to this line.
|
||||
assert document['schema'] == QC.SCHEMA
|
||||
assert document['written_at'] == '2026-01-01T00:00:00'
|
||||
assert document['written_by'].startswith('TicketQC ')
|
||||
assert document['build']['extension_version']
|
||||
assert document['doc_key'] == 'sample_job'
|
||||
assert document['model_stem'] == 'sample_job'
|
||||
|
||||
|
||||
def test_the_clock_can_be_frozen():
|
||||
"""The parity gate compares two runs. A diff that is nothing but
|
||||
timestamps teaches nothing and hides everything."""
|
||||
one = QC.run_evidence([], doc=None, written_at='2026-01-01T00:00:00')
|
||||
two = QC.run_evidence([], doc=None, written_at='2026-01-01T00:00:00')
|
||||
assert json.dumps(one, sort_keys=True) == json.dumps(two, sort_keys=True)
|
||||
|
||||
|
||||
def test_the_knowledge_layers_ride_along_in_merge_order(monkeypatch):
|
||||
monkeypatch.setattr(K, 'sources', lambda d: [
|
||||
('catalogue', '/x/catalogue.yaml', True),
|
||||
('plant', '/x/plant.yaml', False),
|
||||
('job', '/x/job.yaml', True)])
|
||||
document = QC.run_evidence([], doc=_Doc(), written_at='t')
|
||||
assert [row['label'] for row in document['knowledge_sources']] == [
|
||||
'catalogue', 'plant', 'job']
|
||||
assert [row['exists'] for row in document['knowledge_sources']] == [
|
||||
True, False, True]
|
||||
|
||||
|
||||
def test_knowledge_layers_that_could_not_be_listed_are_null_and_a_reason(
|
||||
monkeypatch):
|
||||
def boom(_doc):
|
||||
raise RuntimeError('nope')
|
||||
|
||||
monkeypatch.setattr(K, 'sources', boom)
|
||||
document = QC.run_evidence([], doc=_Doc(), written_at='t')
|
||||
assert document['knowledge_sources'] is None
|
||||
assert 'RuntimeError' in document['missing']['knowledge_sources']
|
||||
|
||||
|
||||
def test_no_document_means_no_layers_and_says_so():
|
||||
document = QC.run_evidence([], doc=None, written_at='t')
|
||||
assert document['knowledge_sources'] is None
|
||||
assert 'no document' in document['missing']['knowledge_sources']
|
||||
assert 'doc_key' in document['missing']
|
||||
|
||||
|
||||
def test_the_spec_source_rides_with_each_ticket(monkeypatch):
|
||||
monkeypatch.setattr(K, 'sources', lambda d: [])
|
||||
document = QC.run_evidence(
|
||||
[a_result(bare_ticket(), design='PNL1.0', source='spec'),
|
||||
a_result(bare_ticket(), design='PNL2.0', source='regex')],
|
||||
doc=_Doc(), written_at='t')
|
||||
assert [t['spec_source'] for t in document['tickets']] == ['spec',
|
||||
'regex']
|
||||
assert [t['design'] for t in document['tickets']] == ['PNL1.0', 'PNL2.0']
|
||||
assert document['tickets'][0]['intent']['fc'] == 6000
|
||||
|
||||
|
||||
def test_a_ticket_with_no_intent_says_so_rather_than_going_quiet():
|
||||
out = QC.ticket_to_json(bare_ticket())
|
||||
assert out['intent'] is None
|
||||
assert 'no design intent' in out['missing']['intent']
|
||||
|
||||
|
||||
def test_the_file_carries_no_findings():
|
||||
"""Step 1 moves no judgement. `qc-run.json` owns the verdicts; this file
|
||||
owns the inputs, and keeping them apart is what lets step 2 re-judge one
|
||||
against the other."""
|
||||
document = QC.run_evidence([a_result(bare_ticket())], doc=None,
|
||||
written_at='t')
|
||||
assert 'findings' not in document
|
||||
assert 'findings' not in document['tickets'][0]
|
||||
assert 'severity' not in json.dumps(document)
|
||||
|
||||
|
||||
# ================================================ RULE 1, discarded WHOLE
|
||||
|
||||
def _write(tmp_path, payload):
|
||||
path = os.path.join(str(tmp_path), 'run-x.json')
|
||||
with open(path, 'w') as handle:
|
||||
handle.write(json.dumps(payload))
|
||||
return path
|
||||
|
||||
|
||||
def test_a_schema_mismatch_throws_the_WHOLE_file_away(tmp_path):
|
||||
"""Not partially, not migrated. The half-read case is the dangerous one:
|
||||
a run against a configuration nobody chose, that looks entirely normal.
|
||||
|
||||
So the assertion is not merely that `_load_error` is set -- it is that
|
||||
`tickets` is EMPTY even though the file on disk had three of them and
|
||||
they parsed perfectly well.
|
||||
"""
|
||||
path = _write(tmp_path, {'schema': 'ticketqc/0',
|
||||
'doc_key': 'sample_job',
|
||||
'written_by': 'TicketQC 0.9',
|
||||
'tickets': [{'mark': 'A'}, {'mark': 'B'},
|
||||
{'mark': 'C'}]})
|
||||
loaded = QC.load_evidence(path)
|
||||
assert loaded['tickets'] == []
|
||||
assert loaded['ticket_count'] == 0
|
||||
assert loaded['doc_key'] is None
|
||||
assert loaded['written_by'] is None
|
||||
assert 'ticketqc/0' in loaded['_load_error']
|
||||
assert QC.SCHEMA in loaded['_load_error']
|
||||
|
||||
|
||||
def test_a_file_with_no_schema_at_all_is_discarded_the_same_way(tmp_path):
|
||||
path = _write(tmp_path, {'tickets': [{'mark': 'A'}]})
|
||||
loaded = QC.load_evidence(path)
|
||||
assert loaded['tickets'] == []
|
||||
assert '_load_error' in loaded
|
||||
|
||||
|
||||
def test_a_damaged_file_is_not_fatal_but_is_never_silent(tmp_path):
|
||||
path = os.path.join(str(tmp_path), 'run-x.json')
|
||||
with open(path, 'w') as handle:
|
||||
handle.write('{ this is not json')
|
||||
loaded = QC.load_evidence(path)
|
||||
assert loaded['tickets'] == []
|
||||
assert loaded['_load_error']
|
||||
|
||||
|
||||
def test_a_file_that_is_not_there_says_so(tmp_path):
|
||||
loaded = QC.load_evidence(os.path.join(str(tmp_path), 'nope.json'))
|
||||
assert loaded['tickets'] == []
|
||||
assert 'no evidence file' in loaded['_load_error']
|
||||
|
||||
|
||||
def test_a_current_file_round_trips_through_disk(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(QC, '_flat_text', lambda doc, vid: 'USE (14) 1/2"')
|
||||
monkeypatch.setattr(K, 'sources', lambda d: [('job', '/x/job.yaml', True)])
|
||||
doc = _Doc('sample_job', MODEL_PATH)
|
||||
document = QC.run_evidence([a_result(full_ticket(doc))], doc=doc,
|
||||
written_at='2026-01-01T00:00:00')
|
||||
folder = os.path.join(str(tmp_path), '_ticketqc', 'evidence')
|
||||
path = QC.write_evidence(folder, document, name='run-frozen.json')
|
||||
assert os.path.isfile(path)
|
||||
back = QC.load_evidence(path)
|
||||
assert '_load_error' not in back
|
||||
assert back == document
|
||||
|
||||
|
||||
# ==================================================== the step is INERT
|
||||
|
||||
def test_the_writer_never_touches_the_ticket_it_was_given(monkeypatch):
|
||||
"""The whole claim of step 1 in one assertion.
|
||||
|
||||
`check_ticket` has already run against this dict by the time the button
|
||||
serialises it, and thirteen checks still read `t['doc']` and
|
||||
`t['sheets']` off it on the next press. If `ticket_to_json` edited the
|
||||
ticket -- swapped the document for a key, flattened the sheets -- the
|
||||
step would stop being inert and would break a check instead. So: same
|
||||
keys, and every value is the SAME OBJECT it was before.
|
||||
"""
|
||||
monkeypatch.setattr(QC, '_flat_text', lambda doc, vid: 'x')
|
||||
t = full_ticket()
|
||||
before = dict((k, id(t[k])) for k in t)
|
||||
QC.ticket_to_json(t, design='PNL1.0', spec_source='spec')
|
||||
assert sorted(t.keys()) == sorted(before.keys())
|
||||
for key in t:
|
||||
assert id(t[key]) == before[key], '{0} was replaced'.format(key)
|
||||
|
||||
|
||||
def test_the_run_writer_never_touches_the_results_it_was_given(monkeypatch):
|
||||
monkeypatch.setattr(K, 'sources', lambda d: [])
|
||||
results = [a_result(bare_ticket())]
|
||||
before = json.dumps(sorted(results[0].keys()))
|
||||
QC.run_evidence(results, doc=None, written_at='t')
|
||||
assert json.dumps(sorted(results[0].keys())) == before
|
||||
assert results[0]['ticket']['doc'] is not None # still the live one
|
||||
|
||||
|
||||
def test_the_evidence_file_name_is_one_per_press():
|
||||
assert QC.evidence_run_name('20260101-120000') == \
|
||||
'run-20260101-120000.json'
|
||||
assert QC.evidence_run_name().startswith('run-')
|
||||
assert QC.evidence_run_name().endswith('.json')
|
||||
|
||||
|
||||
def test_the_writer_creates_the_folder_it_needs(tmp_path):
|
||||
folder = os.path.join(str(tmp_path), 'a', 'b', 'evidence')
|
||||
path = QC.write_evidence(folder, {'schema': QC.SCHEMA, 'tickets': []},
|
||||
name='run-1.json')
|
||||
assert os.path.isfile(path)
|
||||
|
||||
|
||||
def test_no_absolute_user_path_is_invented_for_the_model():
|
||||
"""Evidence files live beside the model and are never committed, but the
|
||||
stem is what identifies the model here rather than a full path -- the
|
||||
thing `tools/scrub.py` would call a leak if one ever were."""
|
||||
out = QC.ticket_to_json(full_ticket())
|
||||
assert out['model_stem'] == 'sample_job'
|
||||
assert '\\' not in (out['model_stem'] or '')
|
||||
assert '/' not in (out['model_stem'] or '')
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The weight gate must count cast concrete that is not the panel rectangle.
|
||||
|
||||
A slab-seat ledge is a separate family in the assembly and real concrete:
|
||||
WP371's carried 8.99 ft3 = 1,349 lb, 7.5% of the piece. Before
|
||||
`extra_cast_ft3` existed, `weight_check()` reconstructed the piece without it
|
||||
and refused panels whose model -- whose own cast-weight parameter, which does
|
||||
count the ledge -- was right.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from vibe import knowledge, liftexport
|
||||
|
||||
|
||||
def make_know():
|
||||
return knowledge._deep_copy(knowledge.DEFAULTS)
|
||||
|
||||
|
||||
def make_job(extra_cast_ft3=None):
|
||||
"""A 20 x 10 ft x 8 in panel with one blockout, WP371-shaped."""
|
||||
gross_ft3 = 20.0 * 10.0 * 8.0 / 12.0 # 133.333
|
||||
hole_ft3 = 4.0 * 5.0 * 8.0 / 12.0 # 13.333
|
||||
pcf = 150.0
|
||||
job = {
|
||||
'panel': {
|
||||
'mark': 'TEST-1',
|
||||
'length_ft': 20.0,
|
||||
'width_ft': 10.0,
|
||||
'thickness_in': 8.0,
|
||||
'unit_weight_pcf': pcf,
|
||||
'weight_lb': (gross_ft3 - hole_ft3) * pcf,
|
||||
'blockouts': [{'name': 'OPENING 1', 'x0': 8.0, 'y0': 2.5,
|
||||
'length': 4.0, 'width': 5.0}],
|
||||
},
|
||||
'source': {'solid_ft3': gross_ft3 - hole_ft3},
|
||||
}
|
||||
if extra_cast_ft3 is not None:
|
||||
job['panel']['extra_cast_ft3'] = extra_cast_ft3
|
||||
job['source']['solid_ft3'] += extra_cast_ft3
|
||||
job['panel']['weight_lb'] += extra_cast_ft3 * pcf
|
||||
return job
|
||||
|
||||
|
||||
def test_no_extra_cast_still_reconciles():
|
||||
"""A plain rectangle behaves exactly as before the field existed."""
|
||||
ok, message, numbers = liftexport.weight_check(make_job(), make_know())
|
||||
assert ok
|
||||
assert message == ''
|
||||
assert numbers['reconstructed_lb'] == pytest.approx(numbers['solid_lb'])
|
||||
|
||||
|
||||
def test_ledge_is_added_to_the_reconstruction():
|
||||
"""With the ledge stated on both sides, the gate passes."""
|
||||
ok, _message, numbers = liftexport.weight_check(
|
||||
make_job(extra_cast_ft3=8.992), make_know())
|
||||
assert ok
|
||||
assert numbers['extra_cast_lb'] == pytest.approx(8.992 * 150.0)
|
||||
assert numbers['reconstructed_lb'] == pytest.approx(
|
||||
(133.333 - 13.333 + 8.992) * 150.0, rel=0.001)
|
||||
|
||||
|
||||
def test_a_missing_ledge_is_the_refusal_it_just_was():
|
||||
"""Stated includes the ledge; our reconstruction without it must fail.
|
||||
|
||||
This is WP371 as the button found it: +7.5%, refused rather than
|
||||
calculated -- correct behaviour then, and pinned so removing the field
|
||||
by accident fails here instead of at a plant.
|
||||
"""
|
||||
job = make_job()
|
||||
job['panel']['weight_lb'] += 8.992 * 150.0 # model counts its ledge
|
||||
|
||||
ok, message, _numbers = liftexport.weight_check(job, make_know())
|
||||
assert not ok
|
||||
assert 'disagree' in message
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# WPA545E, measured live 2026-09-02: a custom cast-weight shared parameter's
|
||||
# AsDouble() returned 560537.22 while AsValueString() -- what Revit shows
|
||||
# the engineer -- read "38,409" lb, a 14.5939029x units artifact (this
|
||||
# shared parameter's raw stored value is not pounds; verified 451/451
|
||||
# against AsValueString in `_scratch/match_weights.py`). The parameter's
|
||||
# actual name is plant knowledge and does not belong in this repository.
|
||||
# The production read path (`ticketqc.rstr` -> `pstr` -> `AsValueString()`,
|
||||
# never `AsDouble()`, for the `panel.weight` / `panel.unit_weight` roles)
|
||||
# never produces the bad number -- but this pins that IF a raw AsDouble
|
||||
# value ever reached `weight_lb`, `weight_check` refuses rather than
|
||||
# calculates on it. 256.06 ft3 x 150 pcf = 38,409 lb is the true weight
|
||||
# (9.48376 cubicYards -- consistent with the companion cast-volume
|
||||
# parameter's own AsValueString); 560,537 lb over the same volume implies
|
||||
# ~2189 pcf, which is not concrete.
|
||||
def make_wpa545e_job(stated_lb):
|
||||
return {
|
||||
'panel': {
|
||||
'mark': 'WPA545E',
|
||||
'length_ft': 25.606,
|
||||
'width_ft': 10.0,
|
||||
'thickness_in': 12.0, # 25.606 x 10 x 1 ft = 256.06 ft3
|
||||
'unit_weight_pcf': 150.0,
|
||||
'weight_lb': stated_lb,
|
||||
'blockouts': [],
|
||||
},
|
||||
'source': {'solid_ft3': 256.06},
|
||||
}
|
||||
|
||||
|
||||
def test_true_display_weight_reconciles_at_normal_weight_concrete():
|
||||
"""The engineer's number (AsValueString, 38,409 lb) passes clean at
|
||||
exactly 150 pcf -- the free sanity net this guard is built on."""
|
||||
job = make_wpa545e_job(38409.0)
|
||||
ok, message, numbers = liftexport.weight_check(job, make_know())
|
||||
assert ok, message
|
||||
assert numbers['solid_lb'] == pytest.approx(38409.0, rel=1e-4)
|
||||
|
||||
|
||||
def test_raw_asdouble_style_weight_is_refused_not_trusted():
|
||||
"""The 14.5939x units-artifact value (560,537.22, the live AsDouble()
|
||||
read on WPA545E) is never silently accepted: it implies ~2189 pcf over
|
||||
the piece's own 256.06 ft3 solid, which weight_check refuses rather
|
||||
than calculating on."""
|
||||
job = make_wpa545e_job(560537.22)
|
||||
ok, message, numbers = liftexport.weight_check(job, make_know())
|
||||
assert not ok
|
||||
assert 'disagree' in message
|
||||
implied_pcf = numbers['stated_lb'] / (numbers['solid_lb'] / 150.0)
|
||||
assert implied_pcf > 500 # nowhere near a real concrete density
|
||||
|
||||
|
||||
def test_implausible_density_is_refused_4050pcf():
|
||||
"""A stated weight implying ~4050 pcf (over 25x normal-weight concrete)
|
||||
on the same 256.06 ft3 solid must refuse, never pass as a two-way
|
||||
'agreement' -- the 150 pcf cross-check is the point of this guard."""
|
||||
job = make_wpa545e_job(256.06 * 4050.0)
|
||||
ok, message, _numbers = liftexport.weight_check(job, make_know())
|
||||
assert not ok
|
||||
assert 'disagree' in message
|
||||
|
||||
|
||||
def test_normal_weight_concrete_density_passes():
|
||||
"""The companion pass case: a stated weight at ordinary normal-weight
|
||||
concrete density (150 pcf) reconciles cleanly on the same solid."""
|
||||
job = make_wpa545e_job(256.06 * 150.0)
|
||||
ok, message, _numbers = liftexport.weight_check(job, make_know())
|
||||
assert ok, message
|
||||
@@ -0,0 +1,401 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for vibe.yamlkit: the hand-rolled YAML subset emit()/parse() round
|
||||
trips through, and nothing else.
|
||||
|
||||
No Revit dependency at all -- see conftest.py -- so every function here is
|
||||
in scope.
|
||||
"""
|
||||
from vibe import yamlkit
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ scalar
|
||||
|
||||
def test_scalar_plain_string_is_unquoted():
|
||||
assert yamlkit.scalar('left_rail') == 'left_rail'
|
||||
|
||||
|
||||
def test_scalar_quotes_a_reserved_word():
|
||||
assert yamlkit.scalar('true') == "'true'"
|
||||
assert yamlkit.scalar('null') == "'null'"
|
||||
|
||||
|
||||
def test_scalar_quotes_a_number_looking_string_so_it_reads_back_as_text():
|
||||
# A part number like '4.10' must not come back as the float 4.1.
|
||||
assert yamlkit.scalar('4.10') == "'4.10'"
|
||||
|
||||
|
||||
def test_scalar_quotes_a_string_with_a_leading_or_trailing_space():
|
||||
assert yamlkit.scalar(' x') == "' x'"
|
||||
assert yamlkit.scalar('x ') == "'x '"
|
||||
|
||||
|
||||
def test_scalar_quotes_a_string_ending_in_colon():
|
||||
assert yamlkit.scalar('note:') == "'note:'"
|
||||
|
||||
|
||||
def test_scalar_escapes_an_embedded_quote_by_doubling_it():
|
||||
assert yamlkit.scalar("it's") == "'it''s'"
|
||||
|
||||
|
||||
def test_scalar_empty_string_is_quoted():
|
||||
assert yamlkit.scalar('') == "''"
|
||||
|
||||
|
||||
def test_scalar_none_true_false():
|
||||
assert yamlkit.scalar(None) == 'null'
|
||||
assert yamlkit.scalar(True) == 'true'
|
||||
assert yamlkit.scalar(False) == 'false'
|
||||
|
||||
|
||||
def test_scalar_float_drops_a_trailing_dot_zero():
|
||||
assert yamlkit.scalar(270.0) == '270'
|
||||
assert yamlkit.scalar(3.5) == '3.5'
|
||||
|
||||
|
||||
def test_scalar_int_is_bare():
|
||||
assert yamlkit.scalar(28920) == '28920'
|
||||
|
||||
|
||||
# --------------------------------------------------------------- emit/parse
|
||||
|
||||
def test_round_trip_flat_dict():
|
||||
doc = {'a': 1, 'b': 'text', 'c': True, 'd': None, 'e': 2.5}
|
||||
assert yamlkit.parse('\n'.join(yamlkit.emit(doc))) == doc
|
||||
|
||||
|
||||
def test_round_trip_nested_dict():
|
||||
doc = {'outer': {'inner': {'x': 1, 'y': [1, 2, 3]}}}
|
||||
assert yamlkit.parse('\n'.join(yamlkit.emit(doc))) == doc
|
||||
|
||||
|
||||
def test_round_trip_flat_list_stays_on_one_line():
|
||||
doc = {'columns_in': [3.5, 16.5, 40.5]}
|
||||
lines = yamlkit.emit(doc)
|
||||
assert lines == ['columns_in: [3.5, 16.5, 40.5]']
|
||||
assert yamlkit.parse('\n'.join(lines)) == doc
|
||||
|
||||
|
||||
def test_round_trip_list_of_dicts():
|
||||
doc = {'rows': [{'from_bottom_in': 1.75, 'strands': ['X', 'X', '.']},
|
||||
{'from_bottom_in': 6.25, 'strands': ['X', 'X', 'X']}]}
|
||||
text = '\n'.join(yamlkit.emit(doc))
|
||||
assert yamlkit.parse(text) == doc
|
||||
|
||||
|
||||
def test_round_trip_empty_dict_and_list():
|
||||
doc = {'confidence': {}, 'notes': []}
|
||||
text = '\n'.join(yamlkit.emit(doc))
|
||||
assert yamlkit.parse(text) == doc
|
||||
|
||||
|
||||
def test_round_trip_string_containing_a_colon_stays_a_scalar():
|
||||
# A note that happens to contain ':' must not be misread as a nested key.
|
||||
doc = {'text': 'STRAND PATTERN: see detail'}
|
||||
text = '\n'.join(yamlkit.emit(doc))
|
||||
assert yamlkit.parse(text) == doc
|
||||
|
||||
|
||||
def test_parse_ignores_comments_and_blank_lines():
|
||||
text = '# a header comment\n\nkey: 1\n# inline-ish comment on its own line\nother: 2\n'
|
||||
assert yamlkit.parse(text) == {'key': 1, 'other': 2}
|
||||
|
||||
|
||||
def test_parse_ignores_a_comment_indented_above_a_section():
|
||||
text = ('reinforcing:\n'
|
||||
' # a note about this field\n'
|
||||
' mild_grade_ksi: 60\n')
|
||||
assert yamlkit.parse(text) == {'reinforcing': {'mild_grade_ksi': 60}}
|
||||
|
||||
|
||||
def test_deterministic_key_order_is_alphabetical():
|
||||
doc = {'z': 1, 'a': 2, 'm': 3}
|
||||
lines = yamlkit.emit(doc)
|
||||
assert [ln.split(':')[0] for ln in lines] == ['a', 'm', 'z']
|
||||
|
||||
|
||||
def test_is_flat_respects_the_width_budget():
|
||||
short = [1, 2, 3]
|
||||
long_list = list(range(40))
|
||||
assert yamlkit.is_flat(short) is True
|
||||
assert yamlkit.is_flat(long_list) is False
|
||||
|
||||
|
||||
def test_is_flat_false_for_a_list_containing_a_dict():
|
||||
assert yamlkit.is_flat([{'a': 1}]) is False
|
||||
|
||||
|
||||
def test_loads_is_an_alias_for_parse():
|
||||
text = 'a: 1\n'
|
||||
assert yamlkit.loads(text) == yamlkit.parse(text)
|
||||
|
||||
|
||||
# ------------------------------------------------ hand-written truncation
|
||||
#
|
||||
# EVERY TEST BELOW ASSERTS ON WHAT COMES AFTER THE AWKWARD CONSTRUCT, and
|
||||
# that is the whole point of them. `parse()` did not raise on any of these
|
||||
# shapes and did not return a wrong value for them either -- it returned a
|
||||
# tree that was simply MISSING everything below, and a test that checked
|
||||
# only the construct itself would have passed against the broken parser.
|
||||
#
|
||||
# The one that cost a real press: a job knowledge file wrapped
|
||||
# `parts: [P76, P89,` onto a second line two thirds of the way down, and
|
||||
# the three sections under it vanished. The config editor compares its tree
|
||||
# against the Revit-side reader's key by key, so a WHOLE-FILE failure was
|
||||
# reported as a disagreement about whichever innocent key was being
|
||||
# written. 16 top-level keys in the file, 13 in the tree.
|
||||
|
||||
WRAPPED = (
|
||||
'first: 1\n'
|
||||
'middle:\n'
|
||||
' parts: [P76, P89, P92,\n'
|
||||
' SP29, SP30,\n'
|
||||
" '0010001']\n"
|
||||
' after_the_list: yes it is read\n'
|
||||
'last:\n'
|
||||
' deep: 2\n'
|
||||
)
|
||||
|
||||
|
||||
def test_a_wrapped_flow_list_does_not_end_the_document():
|
||||
doc = yamlkit.parse(WRAPPED)
|
||||
# The keys AFTER the wrapped list are the assertion that matters.
|
||||
assert sorted(doc) == ['first', 'last', 'middle']
|
||||
assert doc['last'] == {'deep': 2}
|
||||
assert doc['middle']['after_the_list'] == 'yes it is read'
|
||||
|
||||
|
||||
def test_a_wrapped_flow_list_reads_back_as_the_whole_list():
|
||||
doc = yamlkit.parse(WRAPPED)
|
||||
assert doc['middle']['parts'] == ['P76', 'P89', 'P92', 'SP29', 'SP30',
|
||||
'0010001']
|
||||
|
||||
|
||||
def test_a_wrapped_flow_list_keeps_a_quoted_bracket_or_comma_intact():
|
||||
doc = yamlkit.parse("a: ['P76, spare', '[not a list]',\n"
|
||||
" 'it''s']\nb: 2\n")
|
||||
assert doc['a'] == ['P76, spare', '[not a list]', "it's"]
|
||||
assert doc['b'] == 2
|
||||
|
||||
|
||||
def test_a_hash_inside_a_quoted_flow_value_is_not_a_comment():
|
||||
doc = yamlkit.parse("bars: ['#4 BAR (TYP.)',\n"
|
||||
" '#9 (80ksi)']\nafter: ok\n")
|
||||
assert doc['bars'] == ['#4 BAR (TYP.)', '#9 (80ksi)']
|
||||
assert doc['after'] == 'ok'
|
||||
|
||||
|
||||
def test_a_comment_inside_a_wrapped_flow_list_is_dropped():
|
||||
doc = yamlkit.parse('a: [1, 2, # why these two\n'
|
||||
' 3]\nb: 4\n')
|
||||
assert doc == {'a': [1, 2, 3], 'b': 4}
|
||||
|
||||
|
||||
def test_a_double_quote_that_is_an_inch_mark_does_not_open_a_string():
|
||||
# knowledge/catalogue.yaml really writes this. Treating the inch marks
|
||||
# as quotes hides the closing bracket and the file ends there.
|
||||
doc = yamlkit.parse('dims: [3", 3", 3"]\nafter: ok\n')
|
||||
assert doc == {'dims': ['3"', '3"', '3"'], 'after': 'ok'}
|
||||
|
||||
|
||||
def test_an_unterminated_flow_list_raises_instead_of_returning_what_it_had():
|
||||
# THE DECISION, pinned: running off the end of the file and returning
|
||||
# the part it managed to read is the exact failure this whole path
|
||||
# exists to end.
|
||||
import pytest
|
||||
with pytest.raises(yamlkit.YamlKitError):
|
||||
yamlkit.parse('a: 1\nb: [1, 2,\nc: 3\n')
|
||||
|
||||
|
||||
def test_the_unterminated_message_names_the_line_to_go_and_look_at():
|
||||
import pytest
|
||||
with pytest.raises(yamlkit.YamlKitError) as caught:
|
||||
yamlkit.parse('a: 1\nb: 2\nparts: [P76,\n')
|
||||
assert 'line 3' in str(caught.value)
|
||||
|
||||
|
||||
def test_yamlkit_error_is_a_value_error():
|
||||
# A caller already guarding a parse with `except ValueError` keeps
|
||||
# working; one that wants to name this failure still can.
|
||||
assert issubclass(yamlkit.YamlKitError, ValueError)
|
||||
|
||||
|
||||
def test_a_block_sequence_at_its_keys_own_indent_does_not_end_the_document():
|
||||
doc = yamlkit.parse('one:\n'
|
||||
' notes:\n'
|
||||
' - the first note\n'
|
||||
' - the second\n'
|
||||
' after: read\n'
|
||||
'two: 2\n')
|
||||
assert doc['one']['notes'] == ['the first note', 'the second']
|
||||
assert doc['one']['after'] == 'read'
|
||||
assert doc['two'] == 2
|
||||
|
||||
|
||||
def test_a_trailing_comment_on_a_key_line_does_not_end_the_document():
|
||||
doc = yamlkit.parse('prefixes: # dead sheets, skipped outright\n'
|
||||
' - zvoid\n'
|
||||
' - zzvoid\n'
|
||||
'after: read\n')
|
||||
assert doc == {'prefixes': ['zvoid', 'zzvoid'], 'after': 'read'}
|
||||
|
||||
|
||||
def test_a_trailing_comment_is_stripped_from_a_value():
|
||||
assert yamlkit.parse('a: 1 # one\nb: two # and two\n') == {
|
||||
'a': 1, 'b': 'two'}
|
||||
|
||||
|
||||
def test_a_hash_that_is_part_of_a_value_is_not_a_comment():
|
||||
# A '#' with no whitespace in front of it is text, which is what YAML
|
||||
# says and what a pattern like this needs (checked against PyYAML).
|
||||
assert yamlkit.parse('sheet_re: ^WP#\\d+\nafter: ok\n') == {
|
||||
'sheet_re': '^WP#\\d+', 'after': 'ok'}
|
||||
assert yamlkit.parse("note: '# 4 bar'\n") == {'note': '# 4 bar'}
|
||||
|
||||
|
||||
def test_a_hash_after_a_space_is_a_comment_even_when_it_looks_like_a_value():
|
||||
# `size: #4` really is a null with a comment in YAML -- agreeing with
|
||||
# the other reader matters more than guessing the writer's intent.
|
||||
assert yamlkit.parse('size: #4\nafter: ok\n') == {'size': None,
|
||||
'after': 'ok'}
|
||||
|
||||
|
||||
def test_a_wrapped_plain_scalar_does_not_end_the_document():
|
||||
doc = yamlkit.parse('one:\n'
|
||||
' - a note that ran long and wrapped\n'
|
||||
' onto a second indented line\n'
|
||||
' - a shorter one\n'
|
||||
'two: 2\n')
|
||||
assert doc['one'] == ['a note that ran long and wrapped onto a second '
|
||||
'indented line', 'a shorter one']
|
||||
assert doc['two'] == 2
|
||||
|
||||
|
||||
def test_a_wrapped_scalar_does_not_swallow_a_list_items_second_field():
|
||||
# The guard that keeps `_fold_plain` from eating structure: the second
|
||||
# field of a `- key: value` item is a mapping line, not a continuation.
|
||||
doc = yamlkit.parse('rows:\n'
|
||||
' - qty: 14\n'
|
||||
' dia_in: 0.5\n'
|
||||
' - qty: 6\n'
|
||||
' dia_in: 0.5\n'
|
||||
'after: read\n')
|
||||
assert doc['rows'] == [{'qty': 14, 'dia_in': 0.5},
|
||||
{'qty': 6, 'dia_in': 0.5}]
|
||||
assert doc['after'] == 'read'
|
||||
|
||||
|
||||
def test_a_block_scalars_body_keeps_its_hashes_and_indentation():
|
||||
doc = yamlkit.parse('note: >-\n'
|
||||
' a sentence with a # in it\n'
|
||||
' and a second line\n'
|
||||
'after: read\n')
|
||||
assert doc['note'] == 'a sentence with a # in it and a second line'
|
||||
assert doc['after'] == 'read'
|
||||
|
||||
|
||||
def test_a_folded_block_scalar_clips_one_trailing_newline_by_default():
|
||||
# `>` clips (one newline), `>-` strips. Reading them the same way is a
|
||||
# disagreement with every real YAML reader on the value of the key.
|
||||
assert yamlkit.parse('note: >\n one line\n')['note'] == 'one line\n'
|
||||
assert yamlkit.parse('note: >-\n one line\n')['note'] == 'one line'
|
||||
|
||||
|
||||
# ------------------------------------------------------ flow mappings
|
||||
|
||||
def test_a_flow_mapping_is_a_dict_and_not_a_string():
|
||||
doc = yamlkit.parse("bar_notes: {SR356: OPPOSING PAIRS}\nafter: read\n")
|
||||
assert doc['bar_notes'] == {'SR356': 'OPPOSING PAIRS'}
|
||||
assert doc['after'] == 'read'
|
||||
|
||||
|
||||
def test_a_flow_mapping_reads_a_quoted_key():
|
||||
doc = yamlkit.parse("part_notes: {'0010001': (TYP.), SR7: 2}\n")
|
||||
assert doc['part_notes'] == {'0010001': '(TYP.)', 'SR7': 2}
|
||||
|
||||
|
||||
def test_a_wrapped_flow_mapping_does_not_end_the_document():
|
||||
doc = yamlkit.parse('m: {a: 1,\n'
|
||||
' b: 2}\n'
|
||||
'after: read\n')
|
||||
assert doc == {'m': {'a': 1, 'b': 2}, 'after': 'read'}
|
||||
|
||||
|
||||
def test_a_nested_flow_list_is_not_split_on_its_inner_commas():
|
||||
doc = yamlkit.parse('a: [[1, 2], [3, 4]]\nb: 5\n')
|
||||
assert doc == {'a': [[1, 2], [3, 4]], 'b': 5}
|
||||
|
||||
|
||||
def test_an_empty_flow_collection_still_reads_as_empty():
|
||||
assert yamlkit.parse('a: []\nb: {}\nc: 1\n') == {'a': [], 'b': {}, 'c': 1}
|
||||
|
||||
|
||||
def test_a_double_quoted_scalar_is_unquoted():
|
||||
# emit() never writes one; a person editing the file by hand does, and
|
||||
# a value the two readers disagree about is a value the config editor
|
||||
# refuses to write beside.
|
||||
assert yamlkit.parse('note: "quoted by hand"\n') == {
|
||||
'note': 'quoted by hand'}
|
||||
|
||||
|
||||
def test_a_realistic_hand_edited_file_reads_to_its_last_key():
|
||||
# The shape of the file that caused this: several sections, an awkward
|
||||
# construct partway down, and the sections that follow it are the
|
||||
# thing under test.
|
||||
text = (
|
||||
'job:\n'
|
||||
' name: sample_job\n'
|
||||
'details:\n'
|
||||
' 05/X4:\n'
|
||||
' bar_notes: {SR356: OPPOSING PAIRS}\n'
|
||||
' parts: [P76, P89, P92, P98,\n'
|
||||
' SP29, SP30, SP32,\n'
|
||||
" '0010001']\n"
|
||||
' notes:\n'
|
||||
' - a note at the key\'s own indent that also wraps\n'
|
||||
' onto a second line\n'
|
||||
' dims: [3", 3"] # inch marks, not quotes\n'
|
||||
'takeoff:\n'
|
||||
' enabled: true\n'
|
||||
'snapshot:\n'
|
||||
' detached_dir: somewhere\n'
|
||||
'catalogue:\n'
|
||||
' checked: true\n'
|
||||
)
|
||||
doc = yamlkit.parse(text)
|
||||
assert sorted(doc) == ['catalogue', 'details', 'job', 'snapshot',
|
||||
'takeoff']
|
||||
# The innocent key the old failure was reported against.
|
||||
assert doc['snapshot']['detached_dir'] == 'somewhere'
|
||||
detail = doc['details']['05/X4']
|
||||
assert detail['parts'][-1] == '0010001'
|
||||
assert detail['bar_notes'] == {'SR356': 'OPPOSING PAIRS'}
|
||||
assert detail['dims'] == ['3"', '3"']
|
||||
assert detail['notes'] == ["a note at the key's own indent that also "
|
||||
'wraps onto a second line']
|
||||
|
||||
|
||||
def test_round_trip_matches_a_realistic_spec_shaped_document():
|
||||
doc = {
|
||||
'schema': 1,
|
||||
'design_number': 'WP1',
|
||||
'source': {'extractor': 'regex', 'reviewed_by': None},
|
||||
'concrete': {'fc_psi': 6000, 'fci_psi': 3500},
|
||||
'reinforcing': {
|
||||
'strand_groups': [
|
||||
{'qty': 14, 'dia_in': 0.5, 'grade_ksi': 270, 'it_lb': 28920,
|
||||
'role': None, 'note': None},
|
||||
],
|
||||
'strand_layout': {
|
||||
'datum': 'left_rail',
|
||||
'columns_in': [3.5, 16.5, 40.5, 59.5, 87.5, 109.5, 132.5],
|
||||
'rows': [
|
||||
{'from_bottom_in': 1.75, 'strands': ['X'] * 7},
|
||||
{'from_bottom_in': 6.25, 'strands': ['X'] * 7},
|
||||
],
|
||||
'total_in': 143.0,
|
||||
},
|
||||
},
|
||||
'unresolved': [{'text': 'STRAND PATTERN', 'reason': 'no rule matched'}],
|
||||
}
|
||||
text = '\n'.join(yamlkit.emit(doc))
|
||||
assert yamlkit.parse(text) == doc
|
||||
Reference in New Issue
Block a user