WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
This commit is contained in:
@@ -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 '')
|
||||
Reference in New Issue
Block a user