# -*- coding: utf-8 -*- """Tests for the shop-ticket revision delta. Two modules under test, split so that neither needs a PDF: * `vibe.ticketdelta` is pure -- it takes two already-extracted tickets as plain dicts and returns findings. Everything about WHAT COUNTS AS A CHANGE is tested here on hand-written dicts. * `vibe.ticketextract` imports pymupdf only inside `read_spans`, so its parsing and region logic import and test fine on a machine with no PDF library at all. Most of these are regressions against false positives measured on the real old/new issue of one job, and each one names the ticket it came from. That is deliberate: for a diff tool the interesting failures are not "missed a change" but "invented forty", and a test that does not pin the invented ones lets them back in the next time the matcher is touched. """ import pytest from vibe import ticketdelta as td from vibe import ticketextract as tx # -------------------------------------------------------------------------- # dimension parsing # -------------------------------------------------------------------------- @pytest.mark.parametrize('text,inches', [ ('22\'-11"', 275.0), ('22\' - 11"', 275.0), ('2\' - 5 1/2"', 29.5), ('11 1/2"', 11.5), ('8"', 8.0), ('3/4"', 0.75), ('1\'-0"', 12.0), ]) def test_parse_dim_accepts_every_printed_form(text, inches): assert tx.parse_dim(text) == pytest.approx(inches) @pytest.mark.parametrize('text', [ '', None, 'BED PLAN', 'P76 W/ SR5602 (S.I.F.)', '(5) @ 1\'-6" = 7\'-6"', # a note that contains dimensions '#3 X 11\'-8"', # a BOM description '20132', ]) def test_parse_dim_rejects_things_that_merely_contain_a_dimension(text): assert tx.parse_dim(text) is None def test_the_two_printed_spellings_of_one_length_compare_equal(): """The bed plan prints 1' - 0"; the handling diagrams print 1'-0". Comparing the strings instead of the values would report a change on every ticket in the set. """ assert tx.parse_dim('1\' - 0"') == tx.parse_dim('1\'-0"') def test_format_inches_round_trips_through_parse(): for v in (0.75, 8.0, 29.5, 275.0, 221.5): assert tx.parse_dim(tx.format_inches(v)) == pytest.approx(v) # -------------------------------------------------------------------------- # pairing the two issues # -------------------------------------------------------------------------- def test_parse_name_reads_both_namings(): old = td.parse_name('WP199.pdf') new = td.parse_name('25881-20132_WP199.pdf') assert old['mark'] == 'WP199' and old['control'] is None assert new['mark'] == 'WP199' assert new['job'] == '25881' and new['control'] == '20132' def test_parse_name_reads_a_revision_suffix(): assert td.parse_name('25881-20107_WP179_R1.pdf')['revision'] == 'R1' def test_a_naming_change_between_issues_is_itself_reported(): plan = td.pair_sets(['WP199.pdf'], ['25881-20132_WP199.pdf']) assert plan['naming']['changed'] is True assert plan['naming']['old_has_control'] == 0 assert plan['naming']['new_has_control'] == 1 def test_one_old_file_pairs_with_every_new_control_number(): """One ticket per control number: three identical panels, three tickets. The old issue named files by mark and so had exactly one WP215. Reporting only the first of the three new ones would leave two issued tickets undiffed. """ plan = td.pair_sets( ['WP215.pdf'], ['25881-20161_WP215.pdf', '25881-20163_WP215.pdf', '25881-20165_WP215.pdf']) assert plan['naming']['fan_out'] == {'WP215': 3} assert len(plan['pairs']) == 1 assert len(plan['pairs'][0][1]) == 3 def test_a_mark_in_only_one_issue_is_named_not_dropped(): plan = td.pair_sets(['WP199.pdf', 'WP999.pdf'], ['25881-20132_WP199.pdf', '25881-20999_WP888.pdf']) assert plan['old_only'] == ['WP999.pdf'] assert plan['new_only'] == ['25881-20999_WP888.pdf'] # -------------------------------------------------------------------------- # helpers for building fake tickets # -------------------------------------------------------------------------- def span(text, page=0, x=100.0, y=100.0, vertical=False, size=9.5): return {'page': page, 'text': text, 'x': x, 'y': y, 'x1': x + 30, 'y1': y + 8, 'size': size, 'font': 'Arial', 'vertical': vertical, 'block': 0} def chain(texts, page=0, vertical=False, pos=100.0, along=50.0, step=40.0): items = [] for i, t in enumerate(texts): if vertical: items.append(span(t, page=page, x=pos, y=along + i * step, vertical=True)) else: items.append(span(t, page=page, x=along + i * step, y=pos)) return {'page': page, 'vertical': vertical, 'pos': pos, 'along': along, 'items': items} def ticket(**kw): base = {'pages': 2, 'hazards': [], 'title_block': {}, 'bom': [], 'handling': {}, 'notes': [], 'chains': [], 'view_titles': [], 'callouts': [], 'timestamps': [], 'bom_bbox': None} base.update(kw) return base def diff(a, b): return td.compare(a, b, tx.parse_dim, tx.format_inches, tx.locate) def of(findings, kind=None, severity=None): return [f for f in findings if (kind is None or f['kind'] == kind) and (severity is None or f['severity'] == severity)] # -------------------------------------------------------------------------- # title block # -------------------------------------------------------------------------- def _field(v, ok=True): return {'value': v, 'ok': ok, 'page': 0, 'x': 1000.0, 'y': 700.0} def test_a_changed_length_is_material(): a = ticket(title_block={'LENGTH:': _field('22\'-11"')}) b = ticket(title_block={'LENGTH:': _field('24\'-0"')}) f = of(diff(a, b), 'TITLE') assert len(f) == 1 assert f[0]['severity'] == td.MATERIAL assert f[0]['old'] == '22\'-11"' and f[0]['new'] == '24\'-0"' def test_a_changed_drafter_is_housekeeping_not_material(): a = ticket(title_block={'DRAWN BY:': _field('AB')}) b = ticket(title_block={'DRAWN BY:': _field('CD')}) assert of(diff(a, b), 'TITLE')[0]['severity'] == td.HOUSEKEEP def test_a_title_value_that_fails_its_own_shape_is_uncertain_not_a_change(): """A cell read from the wrong side of a boundary is plausible and wrong. Two plausible wrong strings diffed against each other is the worst output this tool could produce, so a value that does not look like a weight is reported as unreadable instead. """ a = ticket(title_block={'WEIGHT (lbs.):': _field('24345')}) b = ticket(title_block={'WEIGHT (lbs.):': _field('PTAC(PTAC)', ok=False)}) f = of(diff(a, b), 'TITLE') assert len(f) == 1 and f[0]['severity'] == td.UNCERTAIN # -------------------------------------------------------------------------- # bill of material # -------------------------------------------------------------------------- def _row(qty, mark, desc='', y=100.0): return {'qty': qty, 'mark': mark, 'desc': desc, 'page': 0, 'x': 960.0, 'y': y} def test_a_bom_quantity_change_is_material_and_names_the_part(): a = ticket(bom=[_row('4', 'P76', 'Erection Anchor - 8 ton')]) b = ticket(bom=[_row('6', 'P76', 'Erection Anchor - 8 ton')]) f = of(diff(a, b), 'BOM') assert len(f) == 1 assert f[0]['severity'] == td.MATERIAL assert 'P76' in f[0]['where'] assert (f[0]['old'], f[0]['new']) == ('4', '6') def test_a_bar_substitution_shows_as_one_out_one_in(): a = ticket(bom=[_row('2', 'SR4183')]) b = ticket(bom=[_row('2', 'SR4192')]) f = of(diff(a, b), 'BOM') assert {x['what'] for x in f} == {'item removed from the bill of material', 'item added to the bill of material'} def test_overlaid_text_in_the_bom_downgrades_every_bom_finding(): """Measured on a real reissue: the earlier file draws the strand row on top of the bar row above it, and the text layer hands back the wrong mark for the right quantity. Two phantom add/remove pairs and a 376 -> 4 quantity, all completely plausible. They are reported -- as uncertain.""" haz = [{'mode': 'overlaid-text', 'page': 0, 'x': 990.0, 'y': 384.0, 'texts': ['0010001', 'SR5602'], 'detail': 'x'}] box = {'page': 0, 'x0': 950.0, 'y0': 30.0, 'x1': 1200.0, 'y1': 400.0} a = ticket(bom=[_row('376', 'SR5602')], hazards=haz, bom_bbox=box) b = ticket(bom=[_row('4', 'SR5602')], bom_bbox=box) f = of(diff(a, b), 'BOM') assert f and all(x['severity'] == td.UNCERTAIN for x in f) assert all('artefact' in (x['note'] or '') for x in f) def test_a_clean_bom_is_not_downgraded(): box = {'page': 0, 'x0': 950.0, 'y0': 30.0, 'x1': 1200.0, 'y1': 400.0} a = ticket(bom=[_row('376', 'SR5602')], bom_bbox=box) b = ticket(bom=[_row('4', 'SR5602')], bom_bbox=box) assert of(diff(a, b), 'BOM')[0]['severity'] == td.MATERIAL # -------------------------------------------------------------------------- # handling diagrams # -------------------------------------------------------------------------- def test_the_dunnage_correction_is_reported_in_printed_order(): """WP199, the engineer's own fix: 4'-9"/4'-9" -> 5'-0"/3'-0". The order is the order they are PRINTED in, left to right. Reading the pair the other way round was measured to turn this corrected ticket back into a failure elsewhere in this codebase, and a diff that argues with a fix its reader has already made is a diff he stops opening. """ a = ticket(handling={'DUNNAGE': [span('4\'-9"', x=820), span('4\'-9"', x=915)]}) b = ticket(handling={'DUNNAGE': [span('5\'-0"', x=820), span('3\'-0"', x=915)]}) f = of(diff(a, b), 'HANDLING') assert len(f) == 2 assert all(x['severity'] == td.MATERIAL for x in f) assert (f[0]['old'], f[0]['new']) == ('4\'-9"', '5\'-0"') assert 'dimension 1 of 2' in f[0]['where'] assert (f[1]['old'], f[1]['new']) == ('4\'-9"', '3\'-0"') def test_an_unchanged_handling_diagram_reports_nothing(): d = {'DUNNAGE': [span('4\'-9"', x=820), span('4\'-9"', x=915)]} assert of(diff(ticket(handling=d), ticket(handling=d)), 'HANDLING') == [] def test_the_same_station_spelled_two_ways_is_not_a_change(): a = ticket(handling={'ERECTION': [span('2\' - 3"')]}) b = ticket(handling={'ERECTION': [span('2\'-3"')]}) assert of(diff(a, b), 'HANDLING') == [] def test_a_diagram_gaining_a_dimension_is_reported_whole(): a = ticket(handling={'PICKING/STRIPPING': [span('2\'-0"', x=820)]}) b = ticket(handling={'PICKING/STRIPPING': [span('2\'-0"', x=820), span('6\'-6"', x=860)]}) f = of(diff(a, b), 'HANDLING') assert len(f) == 1 and f[0]['severity'] == td.MATERIAL # -------------------------------------------------------------------------- # dimension chains -- the false-positive regressions # -------------------------------------------------------------------------- def test_a_replot_that_moves_every_chain_reports_nothing(): """WP194: adding one production note pushed the bed plan 89 points up the page. Every chain changed position; none changed value. The first version of this tool reported 40 material changes on that ticket.""" texts = [['1\' - 9"', '7\' - 5"', '4\' - 7"'], ['8"'], ['2\' - 10 1/2"']] a = ticket(chains=[chain(t, pos=200.0 + 20 * i) for i, t in enumerate(texts)]) b = ticket(chains=[chain(t, pos=111.0 + 20 * i) for i, t in enumerate(texts)]) assert of(diff(a, b), 'DIM') == [] def test_a_redistribution_says_the_total_did_not_change(): """WP219/WP220: a pair of P76 anchors pulled from 1'-0" to 1'-6" apart. Two segments moved and the run they sit on is the same length as before. Saying so is the difference between "two numbers changed" and "the anchors moved, the panel did not get longer". """ a = ticket(chains=[chain(['3\' - 3"', '1\' - 0"', '4\' - 0"'])]) b = ticket(chains=[chain(['2\' - 9"', '1\' - 6"', '4\' - 0"'])]) f = of(diff(a, b), 'DIM') assert len(f) == 2 assert all(x['severity'] == td.MATERIAL for x in f) assert (f[0]['old'], f[0]['new']) == ('3\' - 3"', '2\' - 9"') assert 'segment 1 of 3' in f[0]['where'] assert 'total unchanged' in f[0]['note'] def test_a_real_size_change_reports_the_new_total(): a = ticket(chains=[chain(['1\' - 0"', '4\' - 0"'])]) b = ticket(chains=[chain(['1\' - 6"', '4\' - 0"'])]) f = of(diff(a, b), 'DIM') assert len(f) == 1 assert '5\'-0"' in f[0]['note'] and '5\'-6"' in f[0]['note'] def test_regrouping_the_same_values_is_not_a_change(): """WP196 and WP412: page 2 carries byte-identical dimension text on both issues, and the section detail was still re-laid-out enough that the chain grouping came out differently. Reported as chains that was five confident wrong material findings on two unchanged tickets.""" a = ticket(chains=[chain(['4 1/2"', '5 3/4"', '2 1/4"'], vertical=True, pos=186.0), chain(['1 3/4"', '1 3/4"'], vertical=True, pos=197.0)]) b = ticket(chains=[chain(['4 1/2"'], vertical=True, pos=150.0), chain(['5 3/4"', '2 1/4"', '1 3/4"', '1 3/4"'], vertical=True, pos=162.0)]) assert of(diff(a, b), 'DIM') == [] def test_a_dimension_that_genuinely_appears_is_still_reported_after_regrouping(): a = ticket(chains=[chain(['4 1/2"', '5 3/4"'], vertical=True, pos=186.0)]) b = ticket(chains=[chain(['4 1/2"'], vertical=True, pos=150.0), chain(['5 3/4"', '6\' - 9"'], vertical=True, pos=162.0)]) f = of(diff(a, b), 'DIM') assert len(f) == 1 assert f[0]['new'] == '6\' - 9"' and f[0]['severity'] == td.MATERIAL def test_chains_on_different_pages_are_never_matched_to_each_other(): """8" on page 1 and 1'-0" on page 2 swapping places is two changes, one on each page -- never one chain on page 1 matched to one on page 2.""" a = ticket(chains=[chain(['8"'], page=0), chain(['1\'-0"'], page=1)]) b = ticket(chains=[chain(['1\'-0"'], page=0), chain(['8"'], page=1)]) f = of(diff(a, b), 'DIM') assert len(f) == 2 assert {x['loc_old']['page'] for x in f} == {0, 1} for x in f: assert x['loc_old']['page'] == x['loc_new']['page'] # -------------------------------------------------------------------------- # callouts and notes # -------------------------------------------------------------------------- def test_a_reworded_part_callout_is_one_material_finding_not_two_advisory(): """WP199: P106 (S.I.F.)(E.S.) became P106 (S.I.F.). As two multiset lines that is noise a reader skims. As one finding naming the part, it is an instruction about where an embed goes. """ a = ticket(callouts=[span('P106 (S.I.F.)(E.S.)')]) b = ticket(callouts=[span('P106 (S.I.F.)')]) f = of(diff(a, b), 'CALLOUT') assert len(f) == 1 assert f[0]['severity'] == td.MATERIAL assert 'P106' in f[0]['what'] def test_a_callout_naming_no_part_stays_advisory(): a = ticket(callouts=[span('BLOCKOUT')]) b = ticket(callouts=[span('POCKET')]) assert all(x['severity'] == td.ADVISORY for x in of(diff(a, b), 'CALLOUT')) def test_a_plot_stamp_is_housekeeping(): a = ticket(callouts=[span('8/26/2026 3:40:39 PM')]) b = ticket(callouts=[span('8/27/2026 9:10:48 AM')]) assert all(x['severity'] == td.HOUSEKEEP for x in of(diff(a, b), 'CALLOUT')) def test_a_callout_that_only_moved_is_not_a_change(): a = ticket(callouts=[span('SR333 (E.L.) TYP.', x=470.0, y=490.0)]) b = ticket(callouts=[span('SR333 (E.L.) TYP.', x=478.0, y=402.0)]) assert of(diff(a, b), 'CALLOUT') == [] def _note(text, y=300.0): return {'text': text, 'page': 0, 'x': 500.0, 'y': y} def test_an_added_note_arrives_as_one_paragraph(): a = ticket(notes=[]) b = ticket(notes=[_note('PRODUCTION NOTE: POST POUR CHECK MUST ENSURE ' 'THAT ALL THREADED EMBEDS ARE CLEAN')]) f = of(diff(a, b), 'NOTE') assert len(f) == 1 and f[0]['what'] == 'note added' def test_a_reworded_note_is_one_finding_with_both_versions(): a = ticket(notes=[_note('PRODUCTION NOTE: GROMMET STRAND & SR603')]) b = ticket(notes=[_note('PRODUCTION NOTE: GROMMET STRAND & SR625')]) f = of(diff(a, b), 'NOTE') assert len(f) == 1 assert f[0]['what'] == 'note reworded' assert 'SR603' in f[0]['old'] and 'SR625' in f[0]['new'] def test_a_note_starts_at_its_heading_not_at_its_pdf_block(): """Measured: on page 2 of one ticket the PDF block holding a PRODUCTION NOTE also holds a 6" dimension and a bar tag drawn nearby. Taking the block whole reported the note as removed on one issue and added on the other, because the bar tag next to it had moved.""" ss = [span('6"', y=10.0), span('SR567 (E.L.)', y=20.0), span('PRODUCTION NOTE:', y=30.0), span('3/4" CHAMFERS EA. FACE', y=40.0)] got = tx.notes(ss) assert len(got) == 1 assert got[0]['text'] == 'PRODUCTION NOTE: 3/4" CHAMFERS EA. FACE' def test_two_notes_in_one_block_come_out_as_two_notes(): ss = [span('PRODUCTION NOTE:', y=10.0), span('GROMMET STRAND', y=20.0), span('PRODUCTION NOTE:', y=30.0), span('3/4" CHAMFERS', y=40.0)] assert [n['text'] for n in tx.notes(ss)] == [ 'PRODUCTION NOTE: GROMMET STRAND', 'PRODUCTION NOTE: 3/4" CHAMFERS'] def test_the_page_number_never_lands_inside_a_note(): ss = [span('Notes:', y=10.0), span('1. 1/2" DIA. 270 ksi', y=20.0), span('PAGE 2 OF 2', y=30.0)] assert tx.notes(ss)[0]['text'] == 'Notes: 1. 1/2" DIA. 270 ksi' def test_a_changed_page_number_is_housekeeping(): a = ticket(callouts=[span('PAGE 1 OF 2')]) b = ticket(callouts=[span('PAGE 2 OF 2')]) assert all(x['severity'] == td.HOUSEKEEP for x in of(diff(a, b), 'CALLOUT')) # -------------------------------------------------------------------------- # hazards # -------------------------------------------------------------------------- def test_the_four_text_layer_failure_modes_are_each_detected(): spans = [ span('1316"'), # collapsed fraction span(u'\x04\x05\x06'), # cipher-shifted font span('SR5602', x=500.0, y=380.0), # overlaid pair ... span('0010001', x=500.4, y=380.2), # ... same point ] modes = {h['mode'] for h in tx.hazards(spans, npages=1)} assert 'collapsed-fraction' in modes assert 'cipher-font' in modes assert 'overlaid-text' in modes def test_a_page_with_almost_no_text_is_flagged_as_unread_not_unchanged(): haz = tx.hazards([span('BED PLAN')], npages=2) thin = [h for h in haz if h['mode'] == 'thin-text-layer'] assert len(thin) == 2 assert 'unread' in thin[0]['detail'] def test_a_clean_file_produces_no_hazards(): spans = [span('BED PLAN', x=10.0 * i, y=20.0 * i) for i in range(30)] assert tx.hazards(spans, npages=1) == [] def test_every_hazard_reaches_the_report_as_uncertain(): a = ticket(hazards=[{'mode': 'overlaid-text', 'page': 0, 'x': 10.0, 'y': 10.0, 'texts': ['a', 'b'], 'detail': 'd'}]) f = of(diff(a, ticket()), 'HAZARD') assert len(f) == 1 and f[0]['severity'] == td.UNCERTAIN # -------------------------------------------------------------------------- # report shape # -------------------------------------------------------------------------- def test_material_findings_sort_above_everything_else(): a = ticket(title_block={'LENGTH:': _field('22\'-11"'), 'DRAWN BY:': _field('AB')}, notes=[_note('PRODUCTION NOTE: A')]) b = ticket(title_block={'LENGTH:': _field('24\'-0"'), 'DRAWN BY:': _field('CD')}, notes=[_note('PRODUCTION NOTE: B')]) sev = [f['severity'] for f in diff(a, b)] assert sev[0] == td.MATERIAL assert sev == sorted(sev, key=lambda s: td._ORDER[s]) def test_a_page_count_change_is_material(): f = of(diff(ticket(pages=2), ticket(pages=3)), 'SHEET') assert len(f) == 1 and f[0]['severity'] == td.MATERIAL def test_two_identical_tickets_produce_nothing_at_all(): t = ticket(title_block={'LENGTH:': _field('22\'-11"')}, bom=[_row('4', 'P76')], handling={'DUNNAGE': [span('4\'-9"')]}, chains=[chain(['1\'-0"', '4\'-0"'])], notes=[_note('PRODUCTION NOTE: A')], callouts=[span('BLOCKOUT')]) assert diff(t, t) == [] def test_summarise_counts_every_severity(): a = ticket(title_block={'LENGTH:': _field('22\'-11"')}) b = ticket(title_block={'LENGTH:': _field('24\'-0"')}) counts = td.summarise(diff(a, b)) assert counts[td.MATERIAL] == 1 assert set(counts) == {td.MATERIAL, td.ADVISORY, td.HOUSEKEEP, td.UNCERTAIN} def test_a_finding_can_always_be_pointed_at_on_the_sheet(): """Every material finding carries the coordinates its crop is rendered from. "3 differences" sends someone hunting; a picture does not.""" a = ticket(chains=[chain(['3\' - 3"', '4\' - 0"'])]) b = ticket(chains=[chain(['2\' - 9"', '4\' - 0"'])]) f = of(diff(a, b), 'DIM')[0] assert f['loc_old']['page'] == 0 and f['loc_new']['page'] == 0 assert f['loc_old']['x'] is not None # -------------------------------------------------------------------------- # locators # -------------------------------------------------------------------------- def test_a_view_title_is_a_drawing_name_not_a_section_bubble(): spans = [span('BED PLAN', size=12.6), span('A', size=12.6), span('MK', size=12.6), span('1/4" = 1\' - 0"', size=9.9), span('SECTION', page=1, size=12.6)] titles = tx.view_titles(spans) assert {t['text'] for t in titles} == {'BED PLAN', 'SECTION'} def test_locate_names_the_view_and_the_corner(): titles = tx.view_titles([span('BED PLAN', size=12.6, x=350.0, y=620.0)]) where = tx.locate(titles, 0, 100.0, 100.0) assert 'BED PLAN' in where and 'page 1' in where and 'upper left' in where