52d94303bc
ci / test (push) Has been cancelled
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.
1069 lines
48 KiB
Python
1069 lines
48 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""The vision escalation, tested where testing is honest.
|
|
|
|
WHAT IS NOT TESTED HERE, AND WHY IT CANNOT BE
|
|
----------------------------------------------
|
|
**The verdict.** The same sheet, rendered the same way, asked the same
|
|
question, may come back `clear` on one run and `cannot_tell` on the next.
|
|
That is not a bug to be pinned down; it is what a model reading a picture is.
|
|
Nothing in this file asserts that a particular drawing gets a particular
|
|
answer, and nothing ever should -- a test that pinned one would be asserting
|
|
that a coin lands heads.
|
|
|
|
What IS testable is everything around it, and all of it matters more than the
|
|
verdict does:
|
|
|
|
* a refusal renders as NOT RUN, never as a pass
|
|
* failing to reach the model at all renders as NOT RUN, never as a pass
|
|
* every outcome, including the good one, carries its provenance
|
|
* a model-read finding cannot occupy or flip a computed checklist row
|
|
* the two kinds of finding never end up in one list, one table or one key
|
|
* the resolution arithmetic refuses a render nobody could read
|
|
|
|
Those are the properties the honesty of the report rests on, and they are
|
|
deterministic. Which is the whole argument for building it this way.
|
|
"""
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
|
|
import pytest
|
|
|
|
from vibe import ticketvision as TV
|
|
from vibe import ticketpage as TP
|
|
from vibe import configmodel
|
|
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
|
os.path.abspath(__file__))))
|
|
|
|
|
|
def _vision_read():
|
|
"""`tools/vision_read.py` is CPython 3.12 and lives outside the package,
|
|
so it is loaded by path -- the same boundary the extension crosses at
|
|
runtime, and the reason neither half imports the other."""
|
|
path = os.path.join(ROOT, 'tools', 'vision_read.py')
|
|
spec = importlib.util.spec_from_file_location('vision_read', path)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
def _result(**kw):
|
|
row = {'id': 'X|5.5|A VIEW', 'question_id': 'BARMARK-LEGIBLE',
|
|
'code': 'AI-BARMARK', 'status': 'OK', 'answer': 'clear',
|
|
'why': None, 'where': None, 'reason': None}
|
|
row.update(kw)
|
|
return row
|
|
|
|
|
|
# ------------------------------------------------------- the skill is a file
|
|
|
|
def test_the_skill_is_a_file_versioned_with_the_code():
|
|
"""Not a string in a function. A prompt buried in a function is a prompt
|
|
nobody reviews, and this one is the only thing between a small model and
|
|
a confident wrong answer about a piece of concrete."""
|
|
path = TV.skill_path()
|
|
assert os.path.isfile(path), path
|
|
with open(path, encoding='utf-8') as fh:
|
|
text = fh.read()
|
|
# The refusal has to be first-class in the text, not an afterthought.
|
|
assert 'cannot_tell' in text
|
|
assert 'is not a failure' in text
|
|
for forbidden in ('Never guess a character',
|
|
'Never paraphrase',
|
|
'Never score',
|
|
'Never infer from absence'):
|
|
assert forbidden in text, forbidden
|
|
|
|
|
|
def test_the_skill_forbids_repeating_note_content():
|
|
"""Checklist 8bis: the report may say what it could not judge and that
|
|
someone should look. It may not paraphrase the content."""
|
|
with open(TV.skill_path(), encoding='utf-8') as fh:
|
|
text = fh.read()
|
|
assert 'You may not say what it says.' in text
|
|
|
|
|
|
# ------------------------------------------------------------- resolution
|
|
|
|
def test_a_small_view_renders_well_above_the_floor():
|
|
plan = TV.render_plan(6.0, 4.0)
|
|
assert plan['ok'] is True
|
|
assert plan['pixels'] == 1500
|
|
assert plan['effective_dpi'] == pytest.approx(250.0)
|
|
|
|
|
|
def test_a_whole_sheet_is_refused_rather_than_rendered_illegibly():
|
|
"""A 34 x 22 in sheet at the largest size the model can receive is 44 dpi.
|
|
The QNA evidence says a stacked fraction is not separable there, so the
|
|
honest outcome is NOT RUN with the arithmetic in the reason -- not a
|
|
question asked of an image nobody could read."""
|
|
plan = TV.render_plan(34.0, 22.0)
|
|
assert plan['ok'] is False
|
|
assert '44 dpi' in plan['reason']
|
|
assert 'downscaled' in plan['reason']
|
|
|
|
|
|
def test_the_render_stays_under_the_ceiling_the_api_imposes():
|
|
"""Bigger than 1568 px on the long edge is discarded on arrival, so a
|
|
larger render is latency spent on pixels the model never sees."""
|
|
assert TV.LONG_EDGE_PX <= 1568
|
|
|
|
|
|
def test_an_unmeasurable_view_is_not_rendered_and_says_so():
|
|
plan = TV.render_plan(None, None)
|
|
assert plan['ok'] is False
|
|
assert 'could not be measured' in plan['reason']
|
|
|
|
|
|
# ------------------------------------------- a refusal is NOT RUN, not a pass
|
|
|
|
def test_a_model_refusal_is_never_a_pass():
|
|
row = TV.verdict_row(_result(answer='cannot_tell',
|
|
why='the callouts are four pixels tall'),
|
|
what='the bar marks', view='A VIEW')
|
|
assert row['severity'] == 'WARN'
|
|
assert 'could not tell' in row['message']
|
|
assert row['model_read']['answer'] == 'cannot_tell'
|
|
|
|
|
|
def test_failing_to_reach_the_model_is_not_run_with_the_reason_never_a_pass():
|
|
"""No key, no network, a timeout, no SDK. Every one of them is the same
|
|
outcome, and it is not silence and it is not OK."""
|
|
row = TV.verdict_row(_result(status='NOT_RUN', answer=None,
|
|
reason='the model could not be reached: '
|
|
'APIConnectionError'),
|
|
what='the bar marks')
|
|
assert row['severity'] == 'WARN'
|
|
assert row['model_read']['status'] == 'NOT_RUN'
|
|
assert 'could not' in row['message']
|
|
assert 'APIConnectionError' in row['message']
|
|
|
|
|
|
def test_a_missing_response_file_produces_nothing_that_looks_like_a_pass():
|
|
assert TV.rows_from_response(None) == []
|
|
assert TV.read_response(os.path.join(ROOT, 'no', 'such', 'file')) is None
|
|
|
|
|
|
def test_even_a_clean_read_is_a_warn_and_never_an_ok():
|
|
"""Section 8bis allows one shape: the row reads NOT RUN and a WARN says a
|
|
person still has to look. `clear` is a reason to look, not a reason to
|
|
skip looking, so there is no branch here that can emit OK or FAIL."""
|
|
for answer in ('clear', 'problem_seen', 'cannot_tell'):
|
|
row = TV.verdict_row(_result(answer=answer), what='the bar marks')
|
|
assert row['severity'] == 'WARN', answer
|
|
assert 'NOT a verdict' in row['message'], answer
|
|
|
|
|
|
def test_the_verdict_itself_is_not_pinned_only_the_plumbing_is():
|
|
"""Deliberate: the two opposite answers travel the same path and produce
|
|
the same severity and the same provenance. Nothing downstream branches on
|
|
which one came back, so nothing downstream can be broken by the model
|
|
changing its mind between runs."""
|
|
good = TV.verdict_row(_result(answer='clear'), what='x')
|
|
bad = TV.verdict_row(_result(answer='problem_seen', where='upper left'),
|
|
what='x')
|
|
assert good['severity'] == bad['severity'] == 'WARN'
|
|
assert good['source'] == bad['source'] == 'model-read'
|
|
|
|
|
|
# ------------------------------------------------------------- provenance
|
|
|
|
def test_every_model_read_row_carries_its_provenance_four_ways():
|
|
row = TV.verdict_row(_result(), what='the bar marks', view='A VIEW')
|
|
assert row['source'] == 'model-read' # 1: the field
|
|
assert row['code'].startswith('AI-') # 2: the code
|
|
assert row['message'].startswith('[read by a model') # 3: the message
|
|
assert 'model_read' in row # 4: the payload
|
|
|
|
|
|
def test_a_computed_finding_says_computed_by_default():
|
|
"""Everything that existed before this path was computed, so a finding
|
|
that does not say otherwise is computed."""
|
|
from vibe.ticketpage import weight_agreement # any Revit-free producer
|
|
assert weight_agreement(None, None, None) is None # sanity
|
|
# The Finding class itself is the thing under test, and it needs Revit
|
|
# to import; its default is asserted through the JSON shaping instead.
|
|
row = TV._json_row({'severity': 'WARN', 'code': 'WGT-TWO-WAY',
|
|
'message': 'x'}, 'computed')
|
|
assert row['source'] == 'computed'
|
|
assert row['computed'] is True
|
|
|
|
|
|
def test_the_finding_object_itself_carries_the_provenance(monkeypatch):
|
|
"""`to_findings` is the one place this module touches `vibe.ticketqc`,
|
|
lazily, because that module needs Revit to import. The stub here stands in
|
|
for the real `Finding`, whose signature it mirrors -- what is under test
|
|
is that `source` is set on the object and the payload rides along."""
|
|
import sys
|
|
import types
|
|
|
|
class _Finding(object):
|
|
def __init__(self, severity, code, message, source='computed'):
|
|
self.severity, self.code = severity, code
|
|
self.message, self.source = message, source
|
|
|
|
stub = types.ModuleType('vibe.ticketqc')
|
|
stub.Finding = _Finding
|
|
monkeypatch.setitem(sys.modules, 'vibe.ticketqc', stub)
|
|
|
|
out = TV.to_findings([TV.verdict_row(_result(), what='x', view='A VIEW')])
|
|
assert out[0].source == 'model-read'
|
|
assert out[0].severity == 'WARN'
|
|
assert out[0].code == 'AI-BARMARK'
|
|
assert out[0].model_read['view'] == 'A VIEW'
|
|
# And the JSON shaper reads a Finding as happily as it reads a row.
|
|
assert TV.qc_json([], out)['model_read'][0]['computed'] is False
|
|
|
|
|
|
def test_the_two_kinds_never_share_a_list_a_table_or_a_key():
|
|
computed = [{'severity': 'FAIL', 'code': 'DUN-OPENING', 'message': 'x'}]
|
|
read = [TV.verdict_row(_result(), what='the bar marks')]
|
|
doc = TV.qc_json(computed, read)
|
|
|
|
assert [r['code'] for r in doc['findings']] == ['DUN-OPENING']
|
|
assert [r['code'] for r in doc['model_read']] == ['AI-BARMARK']
|
|
assert all(r['computed'] is True for r in doc['findings'])
|
|
assert all(r['computed'] is False for r in doc['model_read'])
|
|
assert all(r['source'] == 'model-read' for r in doc['model_read'])
|
|
assert 'not a verdict' in doc['model_read_disclaimer']
|
|
# And the JSON survives a round trip, since that is how it leaves.
|
|
assert json.loads(json.dumps(doc)) == doc
|
|
|
|
|
|
def test_the_run_json_lands_beside_the_models_qc_data(tmp_path):
|
|
"""Next to `last-ticketqc.txt` in the QC data folder, under the name the
|
|
rest of the code promises -- not inside the vision working folder, which
|
|
a run with vision off has no business writing to."""
|
|
doc = TV.qc_json([], [TV.verdict_row(_result(), what='x')])
|
|
path = TV.write_run_json(str(tmp_path), doc)
|
|
assert os.path.dirname(path) == str(tmp_path)
|
|
assert os.path.basename(path) == 'qc-run.json'
|
|
with open(path, encoding='utf-8') as fh:
|
|
back = json.load(fh)
|
|
assert back['model_read'][0]['computed'] is False
|
|
assert back['findings'] == []
|
|
|
|
|
|
def test_the_run_json_is_written_when_no_model_was_consulted(tmp_path):
|
|
"""Vision defaults OFF, and the muted section promises every run's
|
|
findings reach this file. So it is written with an empty `model_read` --
|
|
which is itself the readable answer to "was a model asked?"."""
|
|
doc = TV.qc_json([{'severity': 'FAIL', 'code': '4.1',
|
|
'message': 'a computed fail'}], [])
|
|
path = TV.write_run_json(str(tmp_path), doc)
|
|
with open(path, encoding='utf-8') as fh:
|
|
back = json.load(fh)
|
|
assert back['model_read'] == []
|
|
assert [r['code'] for r in back['findings']] == ['4.1']
|
|
assert back['findings'][0]['computed'] is True
|
|
|
|
|
|
def test_the_vision_folder_does_not_double_the_qc_segment(tmp_path):
|
|
"""`designspec.root_dir` owns the layout and beside the model it already
|
|
ends in `_ticketqc`; joining that segment again put every request,
|
|
response and log in `_ticketqc/_ticketqc/vision`."""
|
|
root = os.path.join(str(tmp_path), '_ticketqc')
|
|
got = TV.vision_dir(root)
|
|
assert got == os.path.join(root, 'vision')
|
|
assert '_ticketqc' + os.sep + '_ticketqc' not in got
|
|
|
|
|
|
def test_the_report_puts_a_model_read_in_its_own_block_naming_what_read_it():
|
|
head, rows = TV.report_block([TV.verdict_row(_result(), what='x',
|
|
view='A VIEW')])
|
|
assert 'Read by a model, not computed' in head
|
|
assert 'not a verdict' in head
|
|
assert 'read by' in [c.lower() for c in TV.REPORT_COLUMNS]
|
|
assert rows[0][4] == 'a model looked at a picture'
|
|
assert len(rows[0]) == len(TV.REPORT_COLUMNS)
|
|
# And a ticket label rides along, so one table can cover a whole batch.
|
|
_, labelled = TV.report_block([('MARK1', TV.verdict_row(_result()))])
|
|
assert labelled[0][0] == 'MARK1'
|
|
|
|
|
|
def test_no_model_read_code_owns_a_checklist_row():
|
|
"""The structural guarantee: an `AI-` code appears in no CHECKLIST_ITEMS
|
|
owner tuple, so a model read cannot set, flip, or satisfy a computed row
|
|
however it is wired later."""
|
|
owned = set()
|
|
for item in TP.CHECKLIST_ITEMS:
|
|
for code in (item[3] or ()):
|
|
owned.add(code)
|
|
for row in TV.ESCALATIONS:
|
|
assert row['code'] not in owned, row['code']
|
|
assert not any(row['code'].startswith(o) for o in owned), row['code']
|
|
|
|
|
|
def test_annotating_a_row_marks_it_and_never_changes_its_status():
|
|
rows = [('5.5', 'Bar marks legible and unique on the drawing', 'D',
|
|
'FOR ENGINEER', 'person reads the drawing (grade D)'),
|
|
('8.2', 'No invisible characters', 'A', 'OK', None)]
|
|
out = TV.annotate_rows(rows, [TV.verdict_row(_result(), what='x')])
|
|
assert [r[3] for r in out] == ['FOR ENGINEER', 'OK']
|
|
assert 'a model also looked' in out[0][4]
|
|
assert 'not a verdict' in out[0][4]
|
|
assert out[1] == rows[1]
|
|
|
|
|
|
# ------------------------------------------------------ cost is opt-in, bounded
|
|
|
|
def test_the_escalation_is_off_by_default():
|
|
"""expensive_geometry is ON by default because it spends seconds on a
|
|
machine nobody bills. This spends money and leaves the building."""
|
|
assert configmodel.default_run_options()['vision_escalation'] is False
|
|
assert configmodel.default_run_options()['vision_escalation_max_calls'] == 25
|
|
|
|
|
|
def test_with_the_switch_off_nothing_is_sent_and_the_row_says_why():
|
|
items, blocked = TV.escalation_items({'mark': 'X'}, {}, None,
|
|
options={'vision_escalation': False})
|
|
assert items == []
|
|
assert len(blocked) == len(TV.ESCALATIONS)
|
|
assert 'vision_escalation is OFF' in blocked[0]['message']
|
|
assert blocked[0]['severity'] == 'WARN'
|
|
|
|
|
|
def test_an_unreadable_options_file_leaves_the_escalation_off():
|
|
"""Off is the safe default for a switch that spends money; a broken file
|
|
must never turn one on."""
|
|
on, cap = TV.run_settings(os.path.join(ROOT, 'no', 'such', 'folder'))
|
|
assert on is False and cap == 25
|
|
|
|
|
|
def test_no_plant_view_names_means_not_run_naming_the_key_never_a_pass():
|
|
"""Same discipline as notes.instruction_views: which views carry bar marks
|
|
is plant nomenclature, the default is empty, and an unconfigured plant
|
|
gets NOT RUN with the reason rather than a ticket nobody looked at."""
|
|
from vibe import knowledge as K
|
|
assert K.DEFAULTS['vision']['barmark_views'] == []
|
|
items, blocked = TV.escalation_items({'mark': 'X'}, {}, None,
|
|
options={'vision_escalation': True})
|
|
assert items == []
|
|
assert 'vision.barmark_views' in blocked[0]['message']
|
|
|
|
|
|
def test_a_view_that_prints_too_small_is_refused_before_the_model_is_called():
|
|
class _Pt(object):
|
|
def __init__(self, x, y):
|
|
self.X, self.Y = x, y
|
|
|
|
class _Box(object):
|
|
MinimumPoint = _Pt(0.0, 0.0)
|
|
MaximumPoint = _Pt(2.5, 1.5) # 30 in x 18 in on paper
|
|
|
|
class _VP(object):
|
|
@staticmethod
|
|
def GetBoxOutline():
|
|
return _Box()
|
|
|
|
t = {'mark': 'X', 'viewports': [{'name': 'A VIEW', 'viewport': _VP()}]}
|
|
items, blocked = TV.escalation_items(
|
|
t, {'vision': {'barmark_views': ['A VIEW']}}, None,
|
|
options={'vision_escalation': True})
|
|
assert items == []
|
|
assert 'dpi' in blocked[0]['message']
|
|
|
|
|
|
def test_over_budget_escalations_are_reported_never_dropped():
|
|
"""A dropped item reads exactly like an item that passed."""
|
|
items, blocked = TV.escalation_items(
|
|
{'mark': 'X', 'viewports': []},
|
|
{'vision': {'barmark_views': ['A VIEW']}}, None,
|
|
options={'vision_escalation': True, 'vision_escalation_max_calls': 0})
|
|
assert items == []
|
|
assert blocked # a reason, always
|
|
|
|
|
|
def test_a_ticket_with_no_such_view_says_so_rather_than_passing():
|
|
items, blocked = TV.escalation_items(
|
|
{'mark': 'X', 'viewports': [{'name': 'SOMETHING ELSE',
|
|
'viewport': None}]},
|
|
{'vision': {'barmark_views': ['A VIEW']}}, None,
|
|
options={'vision_escalation': True})
|
|
assert items == []
|
|
assert 'nothing to look at' in blocked[0]['message']
|
|
|
|
|
|
# ------------------------------------------------ the CPython half's plumbing
|
|
|
|
def test_prose_is_a_refusal_not_a_verdict():
|
|
"""A reply that is not one of the three answers is NOT coerced to the
|
|
nearest legal value. A coerced verdict is the exact hallucination this
|
|
path exists to keep out of the report."""
|
|
vr = _vision_read()
|
|
assert vr.parse_answer('Looks fine to me!', 'BARMARK-LEGIBLE') is None
|
|
assert vr.parse_answer('{"answer": "probably_fine"}',
|
|
'BARMARK-LEGIBLE') is None
|
|
assert vr.parse_answer('', 'BARMARK-LEGIBLE') is None
|
|
|
|
|
|
def test_an_answer_to_a_different_question_is_discarded():
|
|
vr = _vision_read()
|
|
assert vr.parse_answer('{"question_id": "NOTE-SENSE", "answer": "clear"}',
|
|
'BARMARK-LEGIBLE') is None
|
|
|
|
|
|
def test_a_fenced_json_answer_still_parses():
|
|
vr = _vision_read()
|
|
out = vr.parse_answer(
|
|
'```json\n{"question_id": "BARMARK-LEGIBLE", "answer": '
|
|
'"cannot_tell", "why": "too small"}\n```', 'BARMARK-LEGIBLE')
|
|
assert out['answer'] == 'cannot_tell'
|
|
assert out['why'] == 'too small'
|
|
|
|
|
|
def test_the_cpython_half_turns_every_failure_into_not_run(tmp_path):
|
|
"""No skill file, no SDK, no credential -- three different failures, one
|
|
outcome, and it is never a pass and never a dropped row."""
|
|
vr = _vision_read()
|
|
out = vr.run({'skill': str(tmp_path / 'nope.md'),
|
|
'items': [{'id': 'a', 'question_id': 'BARMARK-LEGIBLE'},
|
|
{'id': 'b', 'question_id': 'BARMARK-LEGIBLE'}]})
|
|
assert len(out['results']) == 2
|
|
for row in out['results']:
|
|
assert row['status'] == 'NOT_RUN'
|
|
assert row['reason']
|
|
assert row['answer'] is None
|
|
assert row['source'] == 'model-read'
|
|
|
|
|
|
def test_the_cpython_half_enforces_the_budget_and_reports_the_excess(tmp_path):
|
|
vr = _vision_read()
|
|
out = vr.run({'skill': str(tmp_path / 'nope.md'), 'max_calls': 1,
|
|
'items': [{'id': 'a'}, {'id': 'b'}, {'id': 'c'}]})
|
|
assert len(out['results']) == 3 # nothing silently dropped
|
|
over = [r for r in out['results'] if 'budget' in (r['reason'] or '')]
|
|
assert len(over) == 2
|
|
|
|
|
|
def test_the_two_halves_agree_on_the_image_ceiling():
|
|
"""They cannot import each other, so a drift between them would surface
|
|
as unexplained illegibility rather than an error."""
|
|
vr = _vision_read()
|
|
assert TV.LONG_EDGE_PX <= vr.IMAGE_LONG_EDGE_CEILING_PX
|
|
|
|
|
|
def test_no_credential_is_ever_written_into_a_file_this_keeps():
|
|
"""The request and the response are both written to disk beside the
|
|
model. Neither may carry a key, and the way that is guaranteed is that
|
|
nothing in either half ever reads one."""
|
|
with open(os.path.join(ROOT, 'tools', 'vision_read.py'),
|
|
encoding='utf-8') as fh:
|
|
source = fh.read()
|
|
assert 'api_key=' not in source
|
|
assert '192.168.99.151' in source # the LAN server, named in the docs
|
|
request = TV.build_request([{'id': 'a'}], max_calls=3)
|
|
assert 'key' not in json.dumps(request).lower()
|
|
|
|
|
|
# ============================================================================
|
|
# THE RUN'S DISPOSITION IN `qc-run.json` -- every row, its state, its reason
|
|
# ============================================================================
|
|
#
|
|
# The file used to hold `findings` + `model_read` and nothing else, so a
|
|
# consumer could not tell a row that PASSED from a row NOBODY ASKED: both were
|
|
# an absence of findings. Everything below holds the fix in place.
|
|
|
|
import copy # noqa: E402
|
|
|
|
|
|
class _StubFinding(object):
|
|
"""A stand-in for `ticketqc.Finding`, which needs Revit to import."""
|
|
|
|
def __init__(self, severity, code, message):
|
|
self.severity, self.code, self.message = severity, code, message
|
|
self.source = 'computed'
|
|
|
|
|
|
def _finding(severity, code, message):
|
|
return _StubFinding(severity, code, message)
|
|
|
|
|
|
def _run(findings=None, log=None, muted=None, mark='PNL101'):
|
|
"""One press's result entry for one piece, built the way the button
|
|
builds it: states, then the model-read annotation, then muting."""
|
|
rows = TP.checklist_states(list(findings or []), log or [])
|
|
kept, notes = TP.apply_muting(rows, muted or {})
|
|
return {'ticket': {'mark': mark}, 'design': 'PNL1.0',
|
|
'spec_source': 'spec', 'rows': kept, 'rows_unmuted': rows,
|
|
'run_log': list(log or []), 'muted_notes': notes}
|
|
|
|
|
|
def _rows_by_item(document, ticket=0):
|
|
return dict((r['item'], r) for r in document['tickets'][ticket]['rows'])
|
|
|
|
|
|
# ------------------------------------------------- no row can go missing
|
|
|
|
def test_every_checklist_row_reaches_the_json_walked_from_the_table_itself():
|
|
"""THE BUG, mechanically. Not a hand-written list of row numbers -- this
|
|
walks `CHECKLIST_ITEMS` and demands the file covers every one of them.
|
|
|
|
A hand-kept list is exactly how 5.3 spent a day printing the wrong state
|
|
(`ticketpage.CHECKLIST_FOR_ENGINEER` used to be one), so the assertion is
|
|
against the table and never against a literal."""
|
|
doc = TV.qc_json([], [], tickets=[_run(log=[
|
|
('check_sheet_name', 'RAN', None),
|
|
('check_jacking_force', 'SKIPPED', 'no jacking force printed'),
|
|
('check_note_views', 'FAILED', 'KeyError: notes'),
|
|
('check_wwf_designation', 'RAN-EMPTY', None),
|
|
])])
|
|
got = _rows_by_item(doc)
|
|
expected = [item[0] for item in TP.CHECKLIST_ITEMS]
|
|
assert sorted(got) == sorted(expected)
|
|
assert len(doc['tickets'][0]['rows']) == len(expected), 'no duplicates'
|
|
for item in TP.CHECKLIST_ITEMS:
|
|
row = got[item[0]]
|
|
assert row['name'] == item[1]
|
|
assert row['grade'] == item[2]
|
|
assert row['state'], '{0} reached the file with no state'.format(
|
|
item[0])
|
|
|
|
|
|
def test_the_row_list_is_derived_from_the_table_and_not_kept_by_hand():
|
|
"""The proof that the test above is mechanical rather than lucky: add a
|
|
row to the checklist table and the file grows one, with no edit here and
|
|
no edit in `ticketvision`. A hand-kept list could not do this."""
|
|
invented = ('9.9', 'An invented row', 'B', ('INVENTED',), None, None)
|
|
original = TP.CHECKLIST_ITEMS
|
|
try:
|
|
TP.CHECKLIST_ITEMS = original + (invented,)
|
|
doc = TV.qc_json([], [], tickets=[_run()])
|
|
finally:
|
|
TP.CHECKLIST_ITEMS = original
|
|
got = _rows_by_item(doc)
|
|
assert '9.9' in got
|
|
assert got['9.9']['name'] == 'An invented row'
|
|
assert got['9.9']['state'] == 'NOT RUN'
|
|
assert got['9.9']['judged'] is False
|
|
|
|
|
|
def test_a_row_the_run_dropped_lands_as_not_run_and_says_the_tool_dropped_it():
|
|
"""A reader must never have to infer a state from an absence. If the run
|
|
hands over rows with one missing -- for any reason, including one nobody
|
|
has thought of -- the file still carries it, loudly, never as a pass."""
|
|
entry = _run()
|
|
entry['rows'] = [r for r in entry['rows'] if r[0] != '6.3']
|
|
entry['rows_unmuted'] = list(entry['rows'])
|
|
doc = TV.qc_json([], [], tickets=[entry])
|
|
got = _rows_by_item(doc)
|
|
assert '6.3' in got
|
|
assert got['6.3']['state'] == 'NOT RUN'
|
|
assert got['6.3']['judged'] is False
|
|
assert got['6.3']['reported_by_run'] is False
|
|
assert 'never as a pass' in got['6.3']['reason']
|
|
# And every other row still says it did come from the run.
|
|
assert got['6.4']['reported_by_run'] is True
|
|
|
|
|
|
def test_an_empty_run_still_carries_no_claim_that_anything_passed():
|
|
"""No tickets at all: the findings list is empty and that is NOT the same
|
|
as a clean run. The file says so rather than looking like success."""
|
|
doc = TV.qc_json([], [])
|
|
assert doc['tickets'] == []
|
|
assert doc['ticket_count'] == 0
|
|
assert 'is not a row that passed' in doc['missing']['tickets']
|
|
|
|
|
|
# ------------------------------------------------- the reason survives
|
|
|
|
def test_a_not_run_row_carries_its_reason_verbatim_and_uncut():
|
|
"""The premise of the whole checklist. The reason existed only on the HTML
|
|
page and in `last-ticketqc.txt`; the JSON read as success."""
|
|
reason = ('the jacking force printed on the ticket is not a number, so '
|
|
'it could not be compared against the design -- ' + 'x' * 400)
|
|
doc = TV.qc_json([], [], tickets=[_run(log=[
|
|
('check_jacking_force', 'SKIPPED', reason)])])
|
|
row = _rows_by_item(doc)['4.3']
|
|
assert row['state'] == 'NOT RUN'
|
|
assert row['judged'] is False
|
|
assert row['reason'] == reason, 'the reason must be verbatim'
|
|
assert len(row['reason']) == len(reason), 'and uncut'
|
|
assert row['note'] == reason
|
|
|
|
|
|
def test_the_disposition_log_reaches_the_file_named_not_positional():
|
|
"""`run_all`'s (check, state, note) is the premise of every row's state.
|
|
A consumer reading a bare array has to know the tuple order to use it."""
|
|
doc = TV.qc_json([], [], tickets=[_run(log=[
|
|
('check_note_views', 'FAILED', 'KeyError: notes'),
|
|
('check_sheet_name', 'RAN', None)])])
|
|
log = doc['tickets'][0]['run_log']
|
|
assert {'check': 'check_note_views', 'disposition': 'FAILED',
|
|
'note': 'KeyError: notes'} in log
|
|
# And the row that names that check carries the disposition too.
|
|
assert _rows_by_item(doc)['6.5']['checks'] == [
|
|
{'check': 'check_note_views', 'disposition': 'FAILED',
|
|
'note': 'KeyError: notes'}]
|
|
|
|
|
|
def test_a_row_with_no_reason_at_all_says_so_rather_than_reading_as_settled():
|
|
"""Absence is not a value. A row that produced no verdict AND recorded no
|
|
reason is a hole in the tool, and it is named as one.
|
|
|
|
`missing.reason` is the LAST rung of that ladder and it is no longer the
|
|
one that catches this case: as of 2026-08-31 `checklist_states` refuses
|
|
to hand out an empty reason at all, so a code-answered row with nothing
|
|
to say arrives carrying a sentence that says exactly that (see
|
|
`ticketpage.NO_REASON_AT_ALL`). Both rungs assert the same thing -- the
|
|
row is not judged and does not read as settled -- and this pins the one
|
|
that now fires. `missing.reason` still guards a row that arrives with a
|
|
state but no note from anywhere else.
|
|
"""
|
|
doc = TV.qc_json([], [], tickets=[_run()])
|
|
row = _rows_by_item(doc)['4.1']
|
|
assert row['judged'] is False
|
|
assert row['reason'], 'a NOT RUN row may never carry an empty reason'
|
|
assert 'nothing in this run recorded why' in row['reason']
|
|
assert 'not a pass' in row['reason'] or 'NOT judged' in row['reason']
|
|
|
|
|
|
# ------------------------------------- the states are distinct in the DATA
|
|
|
|
def test_not_run_and_fail_are_distinguishable_in_data_not_just_in_colour():
|
|
"""Both are red on the page, deliberately. A file that carried only the
|
|
colour, or only a pass/fail boolean, would have merged them."""
|
|
doc = TV.qc_json([], [], tickets=[_run(
|
|
findings=[_finding('FAIL', 'DUN-OPENING',
|
|
'a block sits under an opening')],
|
|
log=[('check_dunnage_openings', 'RAN', None),
|
|
('check_jacking_force', 'SKIPPED', 'no printed force')])])
|
|
got = _rows_by_item(doc)
|
|
assert got['6.4']['state_key'] == 'fail'
|
|
assert got['4.3']['state_key'] == 'not_run'
|
|
assert got['6.4']['state_key'] != got['4.3']['state_key']
|
|
# ... even though the page paints them the same.
|
|
assert TP.state_color('FAIL') == TP.state_color('NOT RUN') == 'red'
|
|
# And only one of them is a judgement.
|
|
assert got['6.4']['judged'] is True
|
|
assert got['4.3']['judged'] is False
|
|
|
|
|
|
def test_every_state_the_table_can_print_has_its_own_key_in_the_file():
|
|
"""Seven states, seven keys, no two alike -- and the vocabulary travels in
|
|
the document so a consumer does not have to have this file to read it."""
|
|
words = [s for (s, _k, _j) in TP.STATE_VOCABULARY]
|
|
keys = [k for (_s, k, _j) in TP.STATE_VOCABULARY]
|
|
for word in ('OK', 'WARN', 'FAIL', 'NOT RUN', 'FOR ENGINEER',
|
|
TP.MUTED_STATE, TP.OUT_OF_SCOPE_STATE):
|
|
assert word in words, '{0} has no machine key'.format(word)
|
|
assert len(set(keys)) == len(keys), 'two states share a key'
|
|
assert TP.state_key('SOMETHING NOBODY DECLARED') == TP.UNKNOWN_STATE_KEY
|
|
assert TP.state_key('SOMETHING NOBODY DECLARED') != 'ok'
|
|
assert TP.state_is_judged('SOMETHING NOBODY DECLARED') is False
|
|
doc = TV.qc_json([], [], tickets=[_run()])
|
|
assert doc['checklist_states'] == [
|
|
{'state': s, 'state_key': k, 'judged': j}
|
|
for (s, k, j) in TP.STATE_VOCABULARY]
|
|
|
|
|
|
def test_every_state_word_the_page_paints_is_in_the_vocabulary():
|
|
"""The seam. A word `state_color` knows but `state_key` does not would be
|
|
a state the page can print and the file cannot spell."""
|
|
painted = (TP.GREEN_STATES + TP.YELLOW_STATES + TP.RED_STATES +
|
|
TP.GREY_STATES)
|
|
words = [s.upper() for (s, _k, _j) in TP.STATE_VOCABULARY]
|
|
for word in painted:
|
|
assert word.upper() in words, '{0} has no machine key'.format(word)
|
|
assert TP.state_key(word) != TP.UNKNOWN_STATE_KEY
|
|
|
|
|
|
# ------------------------------------------------------------- muting
|
|
|
|
def test_a_muted_row_is_distinguishable_from_an_ok_row():
|
|
"""Muting is a volume control, never an eraser. A grey row and a green row
|
|
must not read the same to a machine, and the grey one has to say what the
|
|
check REALLY reported -- as a state, not only inside an English note."""
|
|
doc = TV.qc_json([], [], tickets=[_run(
|
|
findings=[_finding('OK', 'FCI-RATIO', 'ratio in band')],
|
|
log=[('check_release_ratio', 'RAN', None),
|
|
('check_sheet_name', 'RAN', None)],
|
|
muted={'3.3': 'no piece on this job has one'})])
|
|
got = _rows_by_item(doc)
|
|
assert got['1.1']['state_key'] == 'ok'
|
|
assert got['3.3']['state_key'] == 'muted'
|
|
assert got['3.3']['state_key'] != got['1.1']['state_key']
|
|
assert got['3.3']['muted']['reported_state'] == 'OK'
|
|
assert got['3.3']['muted']['reported_state_key'] == 'ok'
|
|
assert got['3.3']['muted']['reason'] == 'no piece on this job has one'
|
|
assert got['3.3']['judged'] is True
|
|
assert 'muted' not in got['1.1']
|
|
# And the run's mute list travels beside the rows.
|
|
assert doc['tickets'][0]['muted'][0]['item'] == '3.3'
|
|
assert doc['tickets'][0]['muted'][0]['declined'] is False
|
|
|
|
|
|
def test_muting_a_row_that_never_ran_does_not_make_it_read_as_judged():
|
|
"""The one door this table left open. A job may mute a row that reported
|
|
NOT RUN, and the grey then sits over a question nobody could answer --
|
|
`judged` has to follow what the row really reported or the file hands back
|
|
the silence-is-a-pass reading through the mute."""
|
|
doc = TV.qc_json([], [], tickets=[_run(
|
|
log=[('check_release_ratio', 'SKIPPED', 'no fci in the intent')],
|
|
muted={'3.3': 'quiet on this job'})])
|
|
row = _rows_by_item(doc)['3.3']
|
|
assert row['state_key'] == 'muted'
|
|
assert row['muted']['reported_state'] == 'NOT RUN'
|
|
assert row['muted']['reported_state_key'] == 'not_run'
|
|
assert row['judged'] is False
|
|
assert 'no fci in the intent' in row['reason']
|
|
|
|
|
|
def test_a_mute_that_was_declined_says_so_in_the_file():
|
|
"""A FAIL is never softened. A consumer that read `checks.muted` off the
|
|
job file would believe the row was quiet; the run says it was not."""
|
|
doc = TV.qc_json([], [], tickets=[_run(
|
|
findings=[_finding('FAIL', 'FCI-RATIO', 'ratio out of band')],
|
|
log=[('check_release_ratio', 'RAN', None)],
|
|
muted={'3.3': 'quiet on this job'})])
|
|
row = _rows_by_item(doc)['3.3']
|
|
assert row['state_key'] == 'fail', 'muting must never soften a FAIL'
|
|
assert 'muted' not in row
|
|
entry = doc['tickets'][0]['muted'][0]
|
|
assert entry['item'] == '3.3'
|
|
assert entry['declined'] is True
|
|
assert 'FULL VOLUME' in entry['why']
|
|
|
|
|
|
def test_a_mute_with_no_reason_is_reported_and_not_quietly_accepted():
|
|
doc = TV.qc_json([], [], tickets=[_run(
|
|
log=[('check_release_ratio', 'RAN', None)], muted={'3.3': ''})])
|
|
entry = doc['tickets'][0]['muted'][0]
|
|
assert entry['reason'] is None
|
|
assert entry['reason_missing'] is True
|
|
|
|
|
|
# ------------------------------------------------------------ provenance
|
|
|
|
def test_the_envelope_is_the_one_this_codebase_already_uses():
|
|
"""Copied from `ticketqc.run_evidence`, not invented next door -- same
|
|
keys, same `missing` discipline, built by the same function."""
|
|
envelope = {'schema': 'ignored -- qc_json stamps its own',
|
|
'written_at': '2026-08-31T09:00:00',
|
|
'written_by': 'TicketQC 1.2.3',
|
|
'build': {'extension_version': '1.2.3', 'reason': None},
|
|
'doc_key': 'a-doc-key', 'model_stem': 'sample_job',
|
|
'knowledge_sources': [{'label': 'defaults', 'path': '-',
|
|
'exists': True}],
|
|
'missing': {}}
|
|
doc = TV.qc_json([], [], tickets=[_run()], envelope=envelope)
|
|
assert doc['schema'] == TV.RUN_SCHEMA
|
|
assert doc['written_at'] == '2026-08-31T09:00:00'
|
|
assert doc['written_by'] == 'TicketQC 1.2.3'
|
|
assert doc['doc_key'] == 'a-doc-key'
|
|
assert doc['model_stem'] == 'sample_job'
|
|
assert doc['knowledge_sources'][0]['label'] == 'defaults'
|
|
# Which spec source each piece was judged from travels per ticket.
|
|
assert doc['tickets'][0]['spec_source'] == 'spec'
|
|
assert doc['tickets'][0]['design'] == 'PNL1.0'
|
|
|
|
|
|
def test_a_run_with_no_provenance_says_so_rather_than_omitting_the_keys():
|
|
"""A null and an absent key look identical across a file boundary, and
|
|
both look like there was nothing to record."""
|
|
doc = TV.qc_json([], [], tickets=[_run()])
|
|
for key in ('written_at', 'written_by', 'doc_key', 'model_stem',
|
|
'knowledge_sources', 'build'):
|
|
assert key in doc
|
|
assert doc[key] is None
|
|
assert 'nothing here can be re-judged' in doc['missing']['envelope']
|
|
|
|
|
|
def test_a_piece_with_no_spec_source_names_that_rather_than_guessing():
|
|
entry = _run()
|
|
entry['spec_source'] = None
|
|
doc = TV.qc_json([], [], tickets=[entry])
|
|
assert doc['tickets'][0]['spec_source'] is None
|
|
assert 'weaker regex reading' in doc['tickets'][0]['missing']['spec_source']
|
|
|
|
|
|
# ----------------------------------------------- schema: discard whole
|
|
|
|
def test_a_schema_mismatch_discards_the_whole_file_not_part_of_it(tmp_path):
|
|
"""`reinforcecore.load()` is the reference implementation. The keys an old
|
|
`qc-run.json` still parses under are exactly `findings` and `model_read`
|
|
-- adopting those and skipping `tickets` would be reading the very file
|
|
this schema replaces, which reads as a clean run."""
|
|
path = os.path.join(str(tmp_path), 'qc-run.json')
|
|
stale = {'findings': [{'severity': 'FAIL', 'code': 'DUN-OPENING',
|
|
'message': 'a real failure'}],
|
|
'model_read': [], 'model_read_disclaimer': 'x',
|
|
'tickets': [{'mark': 'PNL101', 'rows': [{'item': '1.1'}]}]}
|
|
with open(path, 'w', encoding='utf-8') as fh:
|
|
json.dump(stale, fh)
|
|
back = TV.load_run_json(path)
|
|
assert back['tickets'] == []
|
|
assert back['findings'] == []
|
|
assert back['model_read'] == []
|
|
assert back['ticket_count'] == 0
|
|
assert 'schema' in back['_load_error']
|
|
assert TV.RUN_SCHEMA in back['_load_error']
|
|
|
|
|
|
def test_a_file_of_the_right_schema_round_trips_whole(tmp_path):
|
|
doc = TV.qc_json([_finding('FAIL', 'DUN-OPENING', 'x')], [],
|
|
tickets=[_run(log=[('check_jacking_force', 'SKIPPED',
|
|
'no printed force')])])
|
|
path = TV.write_run_json(str(tmp_path), doc)
|
|
back = TV.load_run_json(path)
|
|
assert '_load_error' not in back
|
|
assert back['schema'] == TV.RUN_SCHEMA
|
|
assert len(back['tickets'][0]['rows']) == len(TP.CHECKLIST_ITEMS)
|
|
got = dict((r['item'], r) for r in back['tickets'][0]['rows'])
|
|
assert got['4.3']['reason'] == 'no printed force'
|
|
|
|
|
|
def test_a_damaged_or_absent_file_judges_nothing_not_half_a_run(tmp_path):
|
|
missing = os.path.join(str(tmp_path), 'nope.json')
|
|
assert TV.load_run_json(missing)['tickets'] == []
|
|
assert 'no run file' in TV.load_run_json(missing)['_load_error']
|
|
broken = os.path.join(str(tmp_path), 'qc-run.json')
|
|
with open(broken, 'w', encoding='utf-8') as fh:
|
|
fh.write('{not json')
|
|
assert TV.load_run_json(broken)['tickets'] == []
|
|
assert TV.load_run_json(broken)['_load_error']
|
|
|
|
|
|
# ------------------------------- the model-read quarantine still holds
|
|
|
|
def test_the_disposition_never_lets_a_model_read_occupy_a_checklist_row():
|
|
"""`AI-*` codes own no row in `CHECKLIST_ITEMS`, and adding the rows to
|
|
the file must not have given them one. 5.5 stays FOR ENGINEER."""
|
|
read = [TV.verdict_row(_result(), what='the bar marks')]
|
|
entry = _run()
|
|
entry['rows'] = TV.annotate_rows(entry['rows'], read)
|
|
entry['rows_unmuted'] = list(entry['rows'])
|
|
doc = TV.qc_json([], read, tickets=[entry])
|
|
got = _rows_by_item(doc)
|
|
assert got['5.5']['state'] == 'FOR ENGINEER'
|
|
assert got['5.5']['state_key'] == 'for_engineer'
|
|
assert got['5.5']['judged'] is False
|
|
assert 'a model also looked' in got['5.5']['note']
|
|
# The read itself is still in its own key and still not computed.
|
|
assert [r['code'] for r in doc['model_read']] == ['AI-BARMARK']
|
|
assert doc['findings'] == []
|
|
assert all(r['computed'] is False for r in doc['model_read'])
|
|
# And no AI-* code ever appears as a row's owner.
|
|
for row in doc['tickets'][0]['rows']:
|
|
assert not any(str(c).startswith('AI-') for c in row['owner_codes'])
|
|
|
|
|
|
# --------------------------------------- and the human output did not move
|
|
|
|
def _page(entry):
|
|
"""Exactly what the button prints for one piece: the HTML table, the
|
|
plain-text table that goes into `last-ticketqc.txt`, and the headline.
|
|
|
|
`_TABLE_SEQ` is reset first. It is a process-wide counter that numbers the
|
|
DOM ids of the note-fold divs (`wt_ckn_t1_...`, `wt_ckn_t2_...`), so two
|
|
renders of the SAME rows differ in those ids alone -- by design, because
|
|
two tables on one page may not share an id. Resetting it is what makes a
|
|
byte comparison of the rendered page meaningful rather than a comparison
|
|
of a counter; nothing else about the output varies between calls.
|
|
"""
|
|
TP._TABLE_SEQ[0] = 0
|
|
details = TP.checklist_details([])
|
|
counts = TP.checklist_tally(entry['rows'])
|
|
return (TP.checklist_table_html(entry['rows'], details),
|
|
TP.checklist_table_text(entry['rows'], details),
|
|
TP.checklist_headline(counts))
|
|
|
|
|
|
def test_the_page_render_is_byte_stable_once_its_id_counter_is_reset():
|
|
"""The control for the test below: with the DOM-id counter reset, two
|
|
renders of the same rows are byte-identical. Without this, "the report did
|
|
not move" would be an assertion about a counter."""
|
|
entry = _run(log=[('check_sheet_name', 'RAN', None)])
|
|
assert _page(entry) == _page(entry)
|
|
|
|
|
|
def test_writing_the_json_moves_nothing_a_human_sees():
|
|
"""PROVEN, not asserted. The page, the text file and the findings are
|
|
rendered, the JSON is then built from the same objects, and everything is
|
|
rendered again and compared byte for byte -- plus a deep-equality check
|
|
that `qc_json` did not mutate a row, a note or a finding on the way."""
|
|
findings = [_finding('FAIL', 'DUN-OPENING',
|
|
'a block sits under an opening at 3 ft'),
|
|
_finding('OK', 'SHEET-NAME', 'sheet name matches')]
|
|
entry = _run(findings=findings,
|
|
log=[('check_dunnage_openings', 'RAN', None),
|
|
('check_sheet_name', 'RAN', None),
|
|
('check_jacking_force', 'SKIPPED', 'no printed force')],
|
|
muted={'3.3': 'no piece on this job has one'})
|
|
|
|
before_page = _page(entry)
|
|
before_entry = copy.deepcopy(entry)
|
|
before_findings = [(f.severity, f.code, f.message) for f in findings]
|
|
|
|
doc = TV.qc_json(findings, [], tickets=[entry])
|
|
assert doc['tickets'], 'the JSON really was built'
|
|
|
|
assert _page(entry) == before_page, 'the rendered report moved'
|
|
assert entry == before_entry, 'qc_json mutated the run result'
|
|
assert [(f.severity, f.code, f.message) for f in findings] == \
|
|
before_findings, 'qc_json mutated a finding'
|
|
|
|
|
|
def test_the_json_is_built_from_the_same_rows_the_page_is_rendered_from():
|
|
"""The other half of "the file and the page cannot say different things":
|
|
every state word in the file is the word the page printed for that row."""
|
|
entry = _run(findings=[_finding('WARN', 'CLASH', 'two members touch')],
|
|
log=[('check_member_interference', 'RAN', None),
|
|
('check_invisible', 'SKIPPED', 'no ticket text')],
|
|
muted={'3.3': 'quiet'})
|
|
doc = TV.qc_json([], [], tickets=[entry])
|
|
got = _rows_by_item(doc)
|
|
for row in entry['rows']:
|
|
assert got[row[0]]['state'] == row[3]
|
|
assert got[row[0]]['note'] == row[4]
|
|
text = TP.checklist_table_text(entry['rows'], TP.checklist_details([]))
|
|
for row in entry['rows']:
|
|
if row[3]:
|
|
assert row[3] in text
|
|
|
|
|
|
def test_the_findings_half_of_the_file_is_unchanged_by_the_disposition():
|
|
"""The addition is an addition. The two-keys-never-one-list quarantine and
|
|
every key a consumer already read are exactly as they were."""
|
|
computed = [{'severity': 'FAIL', 'code': 'DUN-OPENING', 'message': 'x'}]
|
|
read = [TV.verdict_row(_result(), what='the bar marks')]
|
|
doc = TV.qc_json(computed, read, tickets=[_run()])
|
|
assert [r['code'] for r in doc['findings']] == ['DUN-OPENING']
|
|
assert [r['code'] for r in doc['model_read']] == ['AI-BARMARK']
|
|
assert all(r['computed'] is True for r in doc['findings'])
|
|
assert all(r['computed'] is False for r in doc['model_read'])
|
|
assert 'not a verdict' in doc['model_read_disclaimer']
|
|
assert json.loads(json.dumps(doc)) == doc
|
|
|
|
|
|
# ============================================================================
|
|
# THE MACHINE-READABLE SIDE OF THE FIFTH STATE
|
|
# ============================================================================
|
|
#
|
|
# `qc-run/2`. A consumer must be able to tell a row that PASSED from a row
|
|
# NOBODY ASKED from a row the plant declares is not a question about this
|
|
# piece -- and, for the last of those, to see the classification the
|
|
# declaration rested on. The conclusion without the premise is not auditable.
|
|
|
|
|
|
def _classified(panel_type='insulated', verified=True):
|
|
return {'panel_type': panel_type, 'basis': 'signals',
|
|
'verified': verified, 'why': 'two signals agree',
|
|
'candidates': [panel_type] if panel_type else [],
|
|
'signals': {'insulated': {'thickness_in': {'agrees': True,
|
|
'why': '16 in'}}},
|
|
'facts': {'thickness_in': 16.0}}
|
|
|
|
|
|
def _na_run(mark='PNL101', classification=None):
|
|
"""One press's entry with 3.3 switched off by a declaration."""
|
|
rows = TP.checklist_states([], [])
|
|
decisions = {'3.3': {'not_applicable': True, 'condition': 'insulated',
|
|
'reason': 'these have no release requirement',
|
|
'since': '2026-08-31', 'declared_by': 'Will',
|
|
'layer': 'plant'}}
|
|
kept, notes = TP.apply_applicability(rows, decisions)
|
|
muted_kept, muted_notes = TP.apply_muting(kept, {})
|
|
ticket = {'mark': mark}
|
|
if classification is not None:
|
|
ticket['panel_type'] = classification
|
|
return {'ticket': ticket, 'design': 'PNL1.0', 'spec_source': 'spec',
|
|
'rows': muted_kept, 'rows_unmuted': rows, 'run_log': [],
|
|
'muted_notes': muted_notes, 'applicability_notes': notes,
|
|
'applicability_problems': []}
|
|
|
|
|
|
def test_the_schema_is_bumped_because_the_shape_changed():
|
|
"""Rule 1 of the file contract: discard WHOLE on mismatch, never
|
|
migrate. So a new key is a bump, and a `qc-run/1` file is refused rather
|
|
than read as a document that happens to be missing the key saying a row
|
|
was never asked."""
|
|
assert TV.RUN_SCHEMA == 'qc-run/3'
|
|
assert TV.qc_json([], [])['schema'] == 'qc-run/3'
|
|
|
|
|
|
def test_the_new_state_is_in_the_vocabulary_the_file_publishes():
|
|
states = dict((s['state'], s) for s in TV.qc_json([], [])
|
|
['checklist_states'])
|
|
entry = states[TP.NOT_APPLICABLE_STATE]
|
|
assert entry['state_key'] == 'not_applicable'
|
|
assert entry['judged'] is False
|
|
|
|
|
|
def test_a_switched_off_row_carries_its_declaration_and_says_it_never_ran():
|
|
doc = TV.qc_json([], [], tickets=[_na_run(classification=_classified())])
|
|
row = _rows_by_item(doc)['3.3']
|
|
assert row['state'] == TP.NOT_APPLICABLE_STATE
|
|
assert row['state_key'] == 'not_applicable'
|
|
assert row['judged'] is False
|
|
assert row['not_applicable']['condition'] == 'insulated'
|
|
assert row['not_applicable']['reason'] == 'these have no release ' \
|
|
'requirement'
|
|
assert row['not_applicable']['since'] == '2026-08-31'
|
|
assert row['not_applicable']['check_ran'] is False
|
|
assert row['reason']
|
|
|
|
|
|
def test_the_classification_travels_with_its_basis():
|
|
doc = TV.qc_json([], [], tickets=[_na_run(classification=_classified())])
|
|
got = doc['tickets'][0]['panel_type']
|
|
assert got['panel_type'] == 'insulated'
|
|
assert got['basis'] == 'signals'
|
|
assert got['verified'] is True
|
|
assert got['used_for_applicability'] is True
|
|
assert got['signals']['insulated']['thickness_in']['agrees'] is True
|
|
assert got['facts']['thickness_in'] == 16.0
|
|
|
|
|
|
def test_an_unverified_classification_says_it_switched_nothing_off():
|
|
doc = TV.qc_json([], [], tickets=[
|
|
_na_run(classification=_classified(verified=False))])
|
|
got = doc['tickets'][0]['panel_type']
|
|
assert got['panel_type'] == 'insulated'
|
|
assert got['used_for_applicability'] is False
|
|
|
|
|
|
def test_a_run_with_no_classification_says_so_rather_than_omitting_it():
|
|
"""Absence is not a value. A missing key reads downstream as "there was
|
|
nothing to classify", which is the silence-looks-like-a-pass failure one
|
|
layer out."""
|
|
doc = TV.qc_json([], [], tickets=[_na_run(classification=None)])
|
|
entry = doc['tickets'][0]
|
|
assert entry['panel_type']['panel_type'] is None
|
|
assert entry['panel_type']['used_for_applicability'] is False
|
|
assert 'panel_type' in entry['missing']
|
|
|
|
|
|
def test_the_piece_entry_lists_every_row_that_was_not_asked():
|
|
doc = TV.qc_json([], [], tickets=[_na_run(classification=_classified())])
|
|
off = doc['tickets'][0]['not_applicable']
|
|
assert [e['item'] for e in off] == ['3.3']
|
|
assert off[0]['layer'] == 'plant'
|
|
assert off[0]['layer_known'] is True
|
|
assert off[0]['declined'] is False
|
|
|
|
|
|
def test_a_refused_declaration_reaches_the_file_so_somebody_finds_out():
|
|
entry = _na_run(classification=_classified())
|
|
entry['applicability_problems'] = [('5.4', 'the entry has no date')]
|
|
doc = TV.qc_json([], [], tickets=[entry])
|
|
assert doc['tickets'][0]['applicability_refused'] == [
|
|
{'item': '5.4', 'problem': 'the entry has no date'}]
|
|
|
|
|
|
def test_muting_and_inapplicability_stay_different_things_in_the_file():
|
|
"""Both quieten a row and they are not the same claim. A muted row
|
|
records what the check REALLY reported; a not-applicable row records that
|
|
the check did not run at all."""
|
|
doc = TV.qc_json([], [], tickets=[
|
|
_na_run(classification=_classified()),
|
|
_run(muted={'5.4': 'no mesh on this job'})])
|
|
off = _rows_by_item(doc, 0)['3.3']
|
|
grey = _rows_by_item(doc, 1)['5.4']
|
|
assert 'muted' not in off
|
|
assert off['not_applicable']['check_ran'] is False
|
|
assert 'not_applicable' not in grey
|
|
assert 'reported_state' in grey['muted']
|