Files
willtools-demo/WillTools.extension/tests/test_ticketdelta.py
T
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

547 lines
22 KiB
Python

# -*- 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('SAMPLE_CLIENT(SAMPLE_CLIENT)', 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