8e6ee31596
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.
4129 lines
172 KiB
Python
4129 lines
172 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Phase 2: the arithmetic behind the repointed legend checks.
|
||
|
||
The ticket-side strand count is recovered from the BOM's linear-feet row by
|
||
division, never read as a quantity (249 on a 24'-11" piece is 9.99 strands,
|
||
and the section prints (10)). f'c/f'ci compare the title-block sheet params
|
||
against the design. The BOM / section / model three-way agreement is what
|
||
LGD's replacement checks against. All pure arithmetic: no Revit, no model.
|
||
"""
|
||
# pylint: disable=import-error,invalid-name,broad-except
|
||
import re
|
||
|
||
from vibe import ticketpage
|
||
|
||
|
||
# ------------------------------------------------ BOM linear-feet -> count
|
||
|
||
def test_linear_ft_divides_to_the_printed_count():
|
||
"""249 ft on 24.9167 ft is 9.99 strands; the section prints (10)."""
|
||
assert ticketpage.strand_count_from_linear_ft(249, 24.9167) == 10
|
||
assert ticketpage.strand_count_from_linear_ft(321, 22.9167) == 14
|
||
assert ticketpage.strand_count_from_linear_ft(349, 24.9167) == 14
|
||
assert ticketpage.strand_count_from_linear_ft(138, 22.9167) == 6
|
||
|
||
|
||
def test_linear_ft_handles_unusable_inputs():
|
||
assert ticketpage.strand_count_from_linear_ft(None, 24.9167) is None
|
||
assert ticketpage.strand_count_from_linear_ft(249, None) is None
|
||
assert ticketpage.strand_count_from_linear_ft(249, 0) is None
|
||
assert ticketpage.strand_count_from_linear_ft('x', 24.9167) is None
|
||
|
||
|
||
def test_linear_ft_measured_remainders_pass():
|
||
"""Twelve real tickets' ratios sit 0.0032 .. 0.0226 off a whole number;
|
||
all of them must still recover a count (measured 2026-08-27)."""
|
||
measured = [ # (linear_ft, length_ft, expected strands)
|
||
(321.0, 22.9167, 14), # WP199
|
||
(138.0, 22.9167, 6), # WP200
|
||
(361.0, 25.7917, 14), # WP194
|
||
(349.0, 24.9167, 14), # WP195
|
||
(349.0, 24.9167, 14), # WP196
|
||
(265.0, 18.9167, 14), # WP215
|
||
(249.0, 24.9167, 10), # WP210
|
||
(185.0, 18.4583, 10), # WP219
|
||
(185.0, 18.4583, 10), # WP220
|
||
(307.0, 21.9583, 14), # WP213
|
||
(132.0, 21.9583, 6), # WP214
|
||
(349.0, 24.9167, 14), # WP412
|
||
]
|
||
for linear, length, want in measured:
|
||
got = ticketpage.strand_count_from_linear_ft(linear, length)
|
||
assert got == want, (linear, length, got)
|
||
|
||
|
||
def test_linear_ft_rejects_a_ratio_far_from_whole():
|
||
"""A row that is NOT a length in feet must come back None, never a
|
||
confident wrong count. Review cases: a row that is actually a count
|
||
(10 / 24.9 ~ 0.40), and a misread unit (249 / 2.49 ~ 99.93)."""
|
||
assert ticketpage.strand_count_from_linear_ft(10, 24.9167) is None
|
||
assert ticketpage.strand_count_from_linear_ft(249, 2.49167) is None
|
||
# borderline: exactly at tolerance is still usable
|
||
assert ticketpage.strand_count_from_linear_ft(249.0 * 1.05, 24.9167) is None
|
||
|
||
|
||
# ------------------------------------------------------------- strengths
|
||
|
||
def test_strengths_agree_within_tolerance():
|
||
ok, what = ticketpage.strengths_agree(6000, 6000)
|
||
assert ok
|
||
assert '6,000' in what
|
||
ok, _ = ticketpage.strengths_agree(6000, 5999.6) # within 0.5 psi
|
||
assert ok
|
||
|
||
|
||
def test_strengths_disagree_and_missing_values():
|
||
ok, _ = ticketpage.strengths_agree(6000, 3500)
|
||
assert not ok
|
||
# summary absent -> nothing to check, agree trivially
|
||
ok, _ = ticketpage.strengths_agree(None, 3500)
|
||
assert ok
|
||
# ticket value absent -> caller decides what "no value" means
|
||
ok, what = ticketpage.strengths_agree(6000, None)
|
||
assert not ok
|
||
assert 'no value' in what
|
||
|
||
|
||
# ---------------------------------------------------- three-way agreement
|
||
|
||
def test_count_agrees_bom_model_and_section():
|
||
ok, _ = ticketpage.count_agrees(10, 10, 10)
|
||
assert ok
|
||
ok, _ = ticketpage.count_agrees(10, 10, None) # section not read
|
||
assert ok
|
||
ok, _ = ticketpage.count_agrees(0, 0, 0) # no strands on piece
|
||
assert ok
|
||
|
||
|
||
def test_count_agrees_surfaces_each_disagreement():
|
||
ok, what = ticketpage.count_agrees(10, 12, None)
|
||
assert not ok
|
||
assert 'BOM implies' in what
|
||
ok, what = ticketpage.count_agrees(None, 12, None)
|
||
assert not ok
|
||
assert 'unusable' in what
|
||
ok, what = ticketpage.count_agrees(10, 10, 8)
|
||
assert not ok
|
||
assert 'section prints' in what
|
||
ok, what = ticketpage.count_agrees(12, None, None)
|
||
assert not ok
|
||
assert 'model' in what
|
||
|
||
|
||
# ------------------------------------------------- handling stations
|
||
|
||
def test_stations_from_ends_wp412():
|
||
"""WP412 prints 2'-6\" 7'-6\" [blank] 7'-6\" 2'-6\" on 24'-11\"; the
|
||
recovered stations are the four modelled P136 to the inch (audit)."""
|
||
ft = [2.5, 7.5, 7.5, 2.5] # blank dropped from the middle
|
||
got = ticketpage.stations_from_ends(24.9167, ft)
|
||
assert got == [2.5, 10.0, 14.9167, 22.4167]
|
||
|
||
|
||
def test_stations_from_ends_wp213():
|
||
"""WP213 prints 2'-0\" 6'-0\" [blank] 7'-4\" 2'-0\" on 21'-11\"."""
|
||
ft = [2.0, 6.0, 7.3333, 2.0]
|
||
got = ticketpage.stations_from_ends(21.9583, ft)
|
||
assert got == [2.0, 8.0, 12.625, 19.9583]
|
||
|
||
|
||
def test_stations_from_ends_odd_count_is_symmetric():
|
||
"""The diagram [2,6,5,2] on 10: segments from the left give 2,8; from
|
||
the right end they give 8 (the right 2) and 3 (2+5 in from that end)."""
|
||
got = ticketpage.stations_from_ends(10.0, [2.0, 6.0, 5.0, 2.0])
|
||
assert sorted(got) == [2.0, 3.0, 8.0]
|
||
|
||
|
||
def test_dunnage_stations_each_end():
|
||
got = ticketpage.dunnage_stations([4.75], length_ft=22.9167)
|
||
assert got == [4.75, 18.1667]
|
||
|
||
|
||
def test_dunnage_stations_printing_order_wp199_new():
|
||
"""WP199 NEW prints 5'-0\" (left) and 3'-0\" (right). Printing order:
|
||
first inset from the left, second from the right. Mirrored = false."""
|
||
got = ticketpage.dunnage_stations([5.0, 3.0], length_ft=22.9167)
|
||
assert got == [5.0, 19.9167]
|
||
|
||
|
||
def test_dunnage_clearance_wp199_measured_cases():
|
||
"""Claude's six measured rows, straight from the model. WP199 really
|
||
has THREE openings -- the 0.0-0.667 end notch is what gives station
|
||
4.75 its 49.0\" clearance (nearest edge 0.667, not the far opening).
|
||
Openings are full (x0,x1,y0,y1) rectangles; piece_width is held at
|
||
the real values (WP199 11.92, WP213 11.92) so the 19% corner notches
|
||
stay OUT of the judgement (they do not take the support)."""
|
||
wp199_openings = [
|
||
(0.0, 0.667, 11.71, 11.917), # 4.3%-wide corner notch: no
|
||
(15.60, 18.96, 1.00, 9.77), # 73.6%-wide real opening
|
||
(22.25, 22.917, 11.71, 11.917), # corner notch: no
|
||
]
|
||
wp213_openings = [
|
||
(13.60, 16.96, 1.00, 9.77),
|
||
(18.63, 19.29, 1.00, 9.77),
|
||
]
|
||
# WP199: only OPENING 1 (index 1) is a through-thickness opening
|
||
# (measured by raycast: 0.0 in concrete through it); the corner pockets
|
||
# are blind recesses. WP213: both openings are through.
|
||
cases = [
|
||
# (label, station, openings, relevant, expect_sev, expect_dist)
|
||
('WP199 OLD 4.75', 4.75, wp199_openings, [1], 'OK', 130.2),
|
||
('WP199 OLD 18.17', 18.17, wp199_openings, [1], 'FAIL', 0.0),
|
||
('WP199 NEW 5.00', 5.00, wp199_openings, [1], 'OK', 127.2),
|
||
('WP199 NEW 19.92', 19.92, wp199_openings, [1], 'OK', 11.5),
|
||
('WP199 MIR 17.92', 17.92, wp199_openings, [1], 'FAIL', 0.0),
|
||
('WP213 OLD 17.38', 17.38, wp213_openings, [0, 1], 'WARN', 5.0),
|
||
('WP213 NEW 17.96', 17.96, wp213_openings, [0, 1], 'OK', 8.0),
|
||
('WP213 MIR 16.96', 16.96, wp213_openings, [0, 1], 'FAIL', 0.0),
|
||
]
|
||
for label, station, openings, relevant, want_sev, want_in in cases:
|
||
sev, name, dist = ticketpage.dunnage_clearance(
|
||
station, openings, relevant, fail_in=3.0, warn_in=6.0)
|
||
assert sev == want_sev, label
|
||
assert dist == want_in, label
|
||
if sev == 'FAIL':
|
||
assert name in ('INSIDE', 'OPENING 1', 'OPENING 2',
|
||
'OPENING 3'), label
|
||
|
||
|
||
def test_dunnage_clearance_spandrel_notch_is_not_a_hole():
|
||
"""WP200: a top-edge 11\" notch cutting only 19% of the width leaves
|
||
the block on 81% of the concrete -- it must NOT fail. The length-axis
|
||
filter reported 'sits INSIDE an opening' here; that was the false
|
||
positive Claude caught (measured: WP200 notch = 19.0% of width vs the
|
||
real 73.6% of WP199's opening)."""
|
||
wp200_notch = (2.79, 20.96, 3.92, 4.83) # 0.92 ft across on 4.83
|
||
openings = [wp200_notch, (0.0, 0.667, 4.62, 4.83),
|
||
(22.25, 22.917, 4.62, 4.83)]
|
||
# The notch is a blind recess (3.5 in of concrete through it, measured
|
||
# by raycast) -- NOT in relevant, so it is neither INSIDE nor a
|
||
# distance source. The corner pockets are irrelevant too; relevant is
|
||
# empty, so nothing to judge.
|
||
sev, name, dist = ticketpage.dunnage_clearance(
|
||
4.75, openings, [], fail_in=3.0, warn_in=6.0)
|
||
assert sev == 'OK', (sev, name, dist)
|
||
# same notch, station right at the length overlap: still clear
|
||
sev, name, dist = ticketpage.dunnage_clearance(
|
||
18.17, openings, [], fail_in=3.0, warn_in=6.0)
|
||
assert sev == 'OK', (sev, name, dist)
|
||
|
||
|
||
def test_dunnage_clearance_relevance_filters_distance_too():
|
||
"""Claude's latent bug: a 1.7%-wide corner pocket reported WARN at 5
|
||
inches and FAIL at 1 inch because the edge-distance branch skipped the
|
||
relevance filter. An irrelevant opening is neither INSIDE nor a
|
||
distance source."""
|
||
notch = (0.0, 0.667, 0.655, 0.667) # 1.7% of an 8 ft width
|
||
openings = [notch, (6.0, 8.0, 0.0, 8.0)] # + a real full-width hole
|
||
# station 1.0 is 4 inches from the notch's far edge (0.667) -- the old
|
||
# code would WARN; with relevance on the distance branch it is only
|
||
# judged against the real hole (far away) -> OK
|
||
sev, name, dist = ticketpage.dunnage_clearance(
|
||
1.0, openings, [1], fail_in=3.0, warn_in=6.0)
|
||
assert sev == 'OK', (sev, name, dist)
|
||
# real hole still triggers: station inside it
|
||
sev, name, dist = ticketpage.dunnage_clearance(
|
||
7.0, openings, [1], fail_in=3.0, warn_in=6.0)
|
||
assert sev == 'FAIL' and name == 'INSIDE', (sev, name)
|
||
|
||
|
||
def test_dunnage_clearance_edges():
|
||
"""Exactly at the fail/warn boundaries: fail < 3, warn 3..<6, ok >= 6.
|
||
The opening here is relevant (through), so it counts at every station."""
|
||
openings = [(10.0, 12.0, 0.0, 8.0)]
|
||
sev, _, d = ticketpage.dunnage_clearance(13.0, openings, [0],
|
||
fail_in=3.0, warn_in=6.0)
|
||
assert sev == 'OK' # 12" away
|
||
sev, _, d = ticketpage.dunnage_clearance(12.5, openings, [0],
|
||
fail_in=3.0, warn_in=6.0)
|
||
assert sev == 'OK' # 6" away -> OK (>= 6)
|
||
sev, _, d = ticketpage.dunnage_clearance(12.4, openings, [0],
|
||
fail_in=3.0, warn_in=6.0)
|
||
assert sev == 'WARN' # 4.8" away
|
||
sev, _, d = ticketpage.dunnage_clearance(12.2, openings, [0],
|
||
fail_in=3.0, warn_in=6.0)
|
||
assert sev == 'FAIL' # 2.4" away
|
||
sev, _, d = ticketpage.dunnage_clearance(11.0, openings, [0],
|
||
fail_in=3.0, warn_in=6.0)
|
||
assert sev == 'FAIL' # inside
|
||
assert d == 0.0
|
||
|
||
|
||
# --------------------------------------------------------- strand debond
|
||
|
||
def test_strand_debond_runs_short_run_flags_the_crossing_strand():
|
||
"""A 22 ft piece with two openings 3.0 ft apart leaves a run shorter
|
||
than the 4.0 ft threshold. Only the strand at y=2.0 (inside both
|
||
openings' y0..y1 band) crosses that run; the strand at y=8.0 does
|
||
not and is left out."""
|
||
length_ft = 22.0
|
||
openings = [(8.0, 9.0, 0.0, 3.0), (12.0, 13.0, 0.0, 3.0)]
|
||
strands_in = [2.0 * 12.0, 8.0 * 12.0] # y=2.0 ft and y=8.0 ft, inches
|
||
runs = ticketpage.strand_debond_runs(length_ft, openings, strands_in, 4.0)
|
||
assert runs == [{'x0': 9.0, 'x1': 12.0, 'length_ft': 3.0,
|
||
'strands_in': [24.0]}]
|
||
|
||
|
||
def test_strand_debond_runs_long_run_is_clean():
|
||
"""Same shape, openings 5.0 ft apart -- the run clears the 4.0 ft
|
||
threshold, so nothing is reported even though the strand still
|
||
crosses both openings."""
|
||
length_ft = 22.0
|
||
openings = [(8.0, 9.0, 0.0, 3.0), (14.0, 15.0, 0.0, 3.0)]
|
||
strands_in = [2.0 * 12.0]
|
||
runs = ticketpage.strand_debond_runs(length_ft, openings, strands_in, 4.0)
|
||
assert runs == []
|
||
|
||
|
||
def test_strand_debond_runs_no_openings_is_empty_not_a_finding():
|
||
"""A piece with no through openings has nothing for 4.7 to say --
|
||
empty list, not a finding, and distinct from the missing-input None."""
|
||
runs = ticketpage.strand_debond_runs(22.0, [], [24.0], 4.0)
|
||
assert runs == []
|
||
|
||
|
||
def test_strand_debond_runs_missing_inputs_is_not_run_not_ok():
|
||
"""No length, no strand positions, or no threshold -- these are 'could
|
||
not judge', which this function marks with None. A caller that reads
|
||
None as an empty list (== OK) would repeat the exact bug this project
|
||
keeps finding: a check that returns fine because it never looked."""
|
||
openings = [(8.0, 9.0, 0.0, 3.0), (12.0, 13.0, 0.0, 3.0)]
|
||
assert ticketpage.strand_debond_runs(None, openings, [24.0], 4.0) is None
|
||
assert ticketpage.strand_debond_runs(0.0, openings, [24.0], 4.0) is None
|
||
assert ticketpage.strand_debond_runs(22.0, openings, [], 4.0) is None
|
||
assert ticketpage.strand_debond_runs(22.0, openings, [24.0], None) is None
|
||
assert ticketpage.strand_debond_runs(22.0, openings, [24.0], 0.0) is None
|
||
# and none of these collapse to the same shape as the clean [] result
|
||
assert ticketpage.strand_debond_runs(None, openings, [24.0], 4.0) != []
|
||
|
||
|
||
def test_strand_debond_runs_notch_only_catches_the_strand_it_crosses():
|
||
"""A notch spanning only a fifth of an 8 ft width does not need its own
|
||
hole-vs-notch threshold here: the strand at y=0.5 ft (inside the
|
||
notch's 0..1.6 ft band) is caught by every short run its path
|
||
crosses; the strand at y=6.0 ft (outside the band entirely) never
|
||
even gets a cut built at its position, no matter how short the notch
|
||
runs along the length."""
|
||
length_ft = 20.0
|
||
notch = (9.0, 9.5, 0.0, 1.6) # 1.6 of 8.0 ft width = 20%
|
||
other_gap = (13.0, 13.5, 0.0, 1.6)
|
||
openings = [notch, other_gap]
|
||
strands_in = [0.5 * 12.0, 6.0 * 12.0] # 6.0 in, 72.0 in
|
||
runs = ticketpage.strand_debond_runs(length_ft, openings, strands_in, 4.0)
|
||
assert runs # at least one short run found
|
||
all_strands = set()
|
||
for r in runs:
|
||
all_strands.update(r['strands_in'])
|
||
assert all_strands == set([6.0]) # only the in-band strand
|
||
assert 72.0 not in all_strands # the outside strand never appears
|
||
|
||
|
||
def test_strand_debond_runs_piece_end_counts_as_a_boundary():
|
||
"""One opening pinches BOTH stubs to under the threshold: the 2.0 ft
|
||
run to the start and the 3.0 ft run to the end are each measured
|
||
exactly like a run bounded by two openings would be -- the piece's
|
||
own end is a real boundary, not a reason to skip the stub."""
|
||
length_ft = 6.0
|
||
openings = [(2.0, 3.0, 0.0, 3.0)] # 2.0 ft stub, then a 3.0 ft stub
|
||
strands_in = [12.0] # y=1.0 ft, inside the opening band
|
||
runs = ticketpage.strand_debond_runs(length_ft, openings, strands_in, 4.0)
|
||
bounds = set([(r['x0'], r['x1']) for r in runs])
|
||
assert (0.0, 2.0) in bounds
|
||
assert (3.0, 6.0) in bounds
|
||
|
||
|
||
def test_openings_hit_wp199():
|
||
"""WP199: ticket dunnage lands 4.75 / 18.17; OPENING 1 runs 15.60..18.96,
|
||
so the far block sits over it (the real defect of this whole refactor)."""
|
||
stations = ticketpage.dunnage_stations([4.75], length_ft=22.9167)
|
||
hits = ticketpage.openings_hit(stations, [(15.60, 18.96)])
|
||
assert [s for s, _ in hits] == [18.1667]
|
||
# the near block at 4.75 is clear
|
||
assert all(s != 4.75 for s, _ in hits)
|
||
|
||
|
||
def test_openings_edge_is_a_graze_not_a_hit():
|
||
hits = ticketpage.openings_hit([18.96, 19.0], [(15.60, 18.96)])
|
||
assert hits == [] # exactly on the edge is not over the hole
|
||
|
||
|
||
def test_pick_stations_match_wp412():
|
||
"""WP412's four picking stations match the four modelled P136."""
|
||
printed = ticketpage.stations_from_ends(24.9167, [2.5, 7.5, 7.5, 2.5])
|
||
model = [2.5, 10.0, 14.917, 22.417]
|
||
matched, unmatched = ticketpage.pick_stations_match(printed, model)
|
||
assert len(matched) == 4
|
||
assert unmatched == []
|
||
|
||
|
||
# ------------------------------------------------------------- weight
|
||
|
||
def test_weight_three_way_clean():
|
||
"""WP219 15,525 / WP220 15,371 / WP199 24,345-vs-24,348 all clean."""
|
||
for p, m, r in ((15525, 15525, 15525),
|
||
(15371, 15371, 15371),
|
||
(24345, 24348, 24348)):
|
||
sev, code, _ = ticketpage.weight_three_way(p, m, r)
|
||
assert sev == 'OK', (p, m, r)
|
||
assert code == 'WGT-OK'
|
||
|
||
|
||
def test_weight_three_way_diagnoses_each_side():
|
||
"""Which pair agrees says which side is wrong."""
|
||
# printed stale: print differs, geometry reads clean
|
||
sev, code, msg = ticketpage.weight_three_way(20000, 15525, 15525)
|
||
assert (sev, code) == ('WARN', 'WGT-PRINTED')
|
||
assert 'stale' in msg
|
||
# model wrong: geometry differs from a solid that matches the ticket
|
||
sev, code, msg = ticketpage.weight_three_way(15525, 17500, 15525)
|
||
assert (sev, code) == ('WARN', 'WGT-MODEL')
|
||
# our read wrong: printed == measured but reconstructed differs
|
||
sev, code, msg = ticketpage.weight_three_way(15525, 15525, 14000)
|
||
assert (sev, code) == ('WARN', 'WGT-READ')
|
||
assert 'tool' in msg
|
||
|
||
|
||
def test_weight_three_way_incomplete():
|
||
sev, code, _ = ticketpage.weight_three_way(None, 15525, 15525)
|
||
assert (sev, code) == ('WARN', 'WGT-INCOMPLETE')
|
||
|
||
|
||
def test_weight_three_way_tolerance():
|
||
"""WP199 printed 24,345 vs model 24,348: 0.012% -- inside 2%."""
|
||
sev, code, _ = ticketpage.weight_three_way(24345, 24348, 24348)
|
||
assert (sev, code) == ('OK', 'WGT-OK')
|
||
|
||
|
||
def test_weight_agreement_runs_the_three_way():
|
||
"""All three present: the checklist's own diagnosis, by name."""
|
||
sev, code, msg = ticketpage.weight_agreement(15525, 15525, 15525)
|
||
assert (sev, code) == ('OK', 'WGT-OK')
|
||
# printed stale -- the model is consistent with itself
|
||
sev, code, msg = ticketpage.weight_agreement(20000, 15525, 15525)
|
||
assert (sev, code) == ('WARN', 'WGT-PRINTED')
|
||
# the solid does not match the dimensions: a model-side problem
|
||
sev, code, msg = ticketpage.weight_agreement(15525, 17500, 15525)
|
||
assert (sev, code) == ('WARN', 'WGT-MODEL')
|
||
# printed and solid agree: the model is right, our read is not
|
||
sev, code, msg = ticketpage.weight_agreement(15525, 15525, 14000)
|
||
assert (sev, code) == ('WARN', 'WGT-READ')
|
||
assert 'tool' in msg
|
||
|
||
|
||
def test_weight_agreement_two_way_says_so_and_why():
|
||
"""Two of three: still compared, never presented as a clean 2.2."""
|
||
why = {'measured': 'the panel has no unit-weight parameter'}
|
||
sev, code, msg = ticketpage.weight_agreement(
|
||
15525, None, 15525, unavailable=why)
|
||
assert (sev, code) == ('WARN', 'WGT-TWO-WAY') # agreeing is not OK
|
||
assert 'two-way' in msg
|
||
assert 'no unit-weight parameter' in msg # why the third is gone
|
||
assert 'odd one out cannot be said' in msg
|
||
assert "modelled solid's own weight" in msg # WHICH one is missing
|
||
|
||
|
||
def test_weight_agreement_two_way_disagreement_is_a_fail():
|
||
sev, code, msg = ticketpage.weight_agreement(
|
||
20000, None, 15525, unavailable={'measured': 'no solid was readable'})
|
||
assert (sev, code) == ('WARN', 'WGT-TWO-WAY')
|
||
assert 'disagree by' in msg
|
||
# and it must NOT claim to know which of the two is the wrong one
|
||
assert 'stale' not in msg
|
||
|
||
|
||
def test_weight_agreement_two_way_default_reason():
|
||
"""A caller that offers no reason still gets a sentence, not a blank."""
|
||
sev, code, msg = ticketpage.weight_agreement(15525, 15525, None)
|
||
assert (sev, code) == ('WARN', 'WGT-TWO-WAY')
|
||
assert 'could not be read' in msg
|
||
|
||
|
||
def test_weight_agreement_could_not_judge_is_none():
|
||
"""Fewer than two weights is 'could not judge', not a pass."""
|
||
assert ticketpage.weight_agreement(15525, None, None) is None
|
||
assert ticketpage.weight_agreement(None, None, None) is None
|
||
|
||
|
||
def test_weight_agreement_parses_printed_text():
|
||
"""The printed weight arrives as title-block text, commas and all."""
|
||
sev, code, _ = ticketpage.weight_agreement('15,525', 15525, 15525)
|
||
assert (sev, code) == ('OK', 'WGT-OK')
|
||
# text that is not a number is a missing source, not a crash
|
||
assert ticketpage.weight_agreement('SEE PLAN', None, 15525) is None
|
||
|
||
|
||
# --------------------- reconstruction NOT APPLICABLE (WP100, 2026-09-08) ---
|
||
#
|
||
# A stepped/tapered wythe (`liftexport._reconstructed_ft3`) is a known tool
|
||
# LIMITATION, not a missing input -- so this is a SEPARATE function from
|
||
# `weight_agreement`, and unlike its generic two-way path (deliberately
|
||
# never OK, `test_weight_agreement_two_way_says_so_and_why` above) this one
|
||
# CAN say OK, because with the reconstruction's own absence already
|
||
# explained, printed-vs-measured really is the whole comparison left.
|
||
|
||
def test_weight_agreement_no_reconstruction_ok_when_the_two_agree():
|
||
sev, code, msg = ticketpage.weight_agreement_no_reconstruction(
|
||
9045, 9115, 0.02, 'stepped or tapered wythe')
|
||
assert (sev, code) == ('OK', 'WGT-OK')
|
||
assert '9,115' in msg and '9,045' in msg
|
||
assert 'not applicable' in msg
|
||
assert 'stepped or tapered wythe' in msg
|
||
|
||
|
||
def test_weight_agreement_no_reconstruction_warns_on_real_disagreement():
|
||
sev, code, msg = ticketpage.weight_agreement_no_reconstruction(
|
||
8000, 9115, 0.02, 'stepped or tapered wythe')
|
||
assert (sev, code) == ('WARN', 'WGT-TWO-WAY-NA')
|
||
assert 'not applicable' in msg
|
||
# never claims to know which of printed/measured is the wrong one --
|
||
# that diagnosis needs the third (reconstructed) number, which is
|
||
# exactly what is missing here.
|
||
assert 'stale' not in msg
|
||
|
||
|
||
def test_weight_agreement_no_reconstruction_could_not_judge_is_none():
|
||
assert ticketpage.weight_agreement_no_reconstruction(
|
||
None, 9115, 0.02, 'reason') is None
|
||
assert ticketpage.weight_agreement_no_reconstruction(
|
||
9045, None, 0.02, 'reason') is None
|
||
|
||
|
||
def test_weight_lb_parses_what_asvaluestring_actually_returns():
|
||
"""`weight_lb` is the parser standing between a Revit AsValueString()
|
||
read (or OCR'd title-block text) and every weight comparison in this
|
||
module. Pinned hard because it is the only host-testable half of the
|
||
fix for the live 2026-09-02 finding: a custom cast-weight shared
|
||
parameter's AsDouble() returned 560537.22 while AsValueString() read
|
||
"38,409" -- a 14.6x units artifact (the parameter name is plant
|
||
knowledge and does not belong in this repository). That units artifact
|
||
never reaches here because the production read (ticketqc.rstr -> pstr
|
||
-> AsValueString) is a string, and everything downstream goes through
|
||
this function.
|
||
"""
|
||
# commas, exactly what a formatted title-block weight looks like
|
||
assert ticketpage.weight_lb('38,409') == 38409.0
|
||
assert ticketpage.weight_lb('560,537.22') == 560537.22
|
||
# plain numbers, ints, and already-float values pass through
|
||
assert ticketpage.weight_lb(15525) == 15525.0
|
||
assert ticketpage.weight_lb(15525.5) == 15525.5
|
||
# incidental whitespace
|
||
assert ticketpage.weight_lb(' 15,525 ') == 15525.0
|
||
# not a number at all -- "could not judge", never a crash and never 0
|
||
assert ticketpage.weight_lb('SEE PLAN') is None
|
||
assert ticketpage.weight_lb('') is None
|
||
assert ticketpage.weight_lb(None) is None
|
||
|
||
|
||
def test_weight_param_note_only_speaks_when_it_disagrees():
|
||
"""The stored weight parameter is not one of the three; it is reported
|
||
only when it contradicts a derived weight (it is what the handling
|
||
gate reads)."""
|
||
assert ticketpage.weight_param_note(15525, 15525, 15525) is None
|
||
assert ticketpage.weight_param_note(None, 15525, 15525) is None
|
||
sev, code, msg = ticketpage.weight_param_note(13100, 15525, 15525)
|
||
assert (sev, code) == ('WARN', 'WGT-PARAM')
|
||
assert 'modelled solid' in msg
|
||
# with no solid read, it falls back to the ticket and says so
|
||
sev, code, msg = ticketpage.weight_param_note(13100, None, 15525)
|
||
assert (sev, code) == ('WARN', 'WGT-PARAM')
|
||
assert 'the ticket' in msg
|
||
|
||
|
||
def test_checklist_two_way_weight_is_visible_as_warn():
|
||
"""A two-way result must reach the checklist table as WARN with the
|
||
reason attached -- an OK row with no note is the silent degradation."""
|
||
from vibe import ticketpage as TP
|
||
sev, code, msg = TP.weight_agreement(
|
||
15525, None, 15525, unavailable={'measured': 'no solid was readable'})
|
||
rows = TP.checklist_states(
|
||
[_F(sev, code, msg)],
|
||
log=[('check_weight_three_way', 'RAN', None)])
|
||
num, name, grade, status, note = _row(rows, '2.2')
|
||
assert status == 'WARN'
|
||
assert note and 'two-way' in note
|
||
|
||
|
||
# ------------------------------------------------------------- invisible
|
||
|
||
def test_clean_text_strips_zero_width():
|
||
"""U+200E rode in a model parameter's ValueString all the way into
|
||
evidence json and crashed a tool that printed it (measured)."""
|
||
from vibe import ticketpage as TP
|
||
assert TP.clean_text(u'x\u200Ey') == 'xy'
|
||
assert TP.clean_text(u'\u200e') == '' # nothing but the mark
|
||
assert TP.clean_text(u'a\u200fb\u00adc\ufeff') == 'abc'
|
||
assert TP.clean_text('plain') == 'plain'
|
||
assert TP.clean_text(None) is None
|
||
|
||
|
||
# -------------------------------------------- Phase 5 checklist table
|
||
|
||
class _F(object):
|
||
"""A finding stub: only what checklist_states reads."""
|
||
def __init__(self, sev, code, msg=''):
|
||
self.severity = sev
|
||
self.code = code
|
||
self.message = msg
|
||
|
||
|
||
def _row(table, num):
|
||
for row in table:
|
||
if row[0] == num:
|
||
return row
|
||
raise AssertionError('no row {0} in table'.format(num))
|
||
|
||
|
||
def test_checklist_ran_empty_is_ok_not_notrun():
|
||
"""Claude's self-test, automated: an owner check that RAN but emitted
|
||
no finding must show OK -- and stay OK when its OK finding is removed
|
||
(the 'silence looks like fine' trap is exactly what this table kills)."""
|
||
from vibe import ticketpage as TP
|
||
# 6.1 owned by check_lifter_capacity: RAN, no finding at all
|
||
rows = TP.checklist_states([], log=[('check_lifter_capacity',
|
||
'RAN', None)])
|
||
assert _row(rows, '6.1')[3] == 'OK'
|
||
# with an OK finding present, still OK
|
||
rows2 = TP.checklist_states([_F('OK', 'LIFT-CAPACITY')],
|
||
log=[('check_lifter_capacity', 'RAN', None)])
|
||
assert _row(rows2, '6.1')[3] == 'OK'
|
||
|
||
|
||
def test_checklist_skipped_must_carry_reason():
|
||
"""A SKIPPED owner check yields NOT RUN with a non-empty reason -- the
|
||
check's own words, never a guessed one."""
|
||
from vibe import ticketpage as TP
|
||
rows = TP.checklist_states(
|
||
[],
|
||
log=[('check_weight_three_way', 'SKIPPED',
|
||
'plant has no sheet.weight mapping')])
|
||
num, name, grade, status, note = _row(rows, '2.2')
|
||
assert status == 'NOT RUN'
|
||
assert note and 'sheet.weight' in note
|
||
|
||
|
||
def test_checklist_no_code_not_confused_with_skipped():
|
||
"""An item with no owner check at all must NOT pick up some other
|
||
check's skip reason.
|
||
|
||
Written against 6.5, then 5.3, and now 2.1 -- both of the earlier ones
|
||
acquired a disposition of their own (6.5 a check on 2026-08-30, 5.3 the
|
||
FOR ENGINEER state it always should have had). The property is about ANY
|
||
unowned item, so it keeps moving to one that still is.
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
rows = TP.checklist_states(
|
||
[],
|
||
log=[('check_weight_three_way', 'SKIPPED',
|
||
'plant has no sheet.weight mapping')])
|
||
_2_1 = _row(rows, '2.1')
|
||
assert _2_1[3] == 'NOT RUN'
|
||
assert 'sheet.weight' not in (_2_1[4] or '')
|
||
_2_2 = _row(rows, '2.2')
|
||
assert _2_2[3] == 'NOT RUN'
|
||
assert 'sheet.weight' in _2_2[4]
|
||
|
||
|
||
# ------------------------------- 5.3: a person's row, not an unrun one
|
||
#
|
||
# The defect the owner reported as "5.3 没跑" (2026-08-30), pinned from both
|
||
# ends: the row must read FOR ENGINEER, and the rows that genuinely have not
|
||
# run must NOT be swept into FOR ENGINEER along with it.
|
||
|
||
|
||
def test_53_is_for_engineer_and_never_the_red_not_run_it_used_to_print():
|
||
"""Grade C, no check function, and a reason recorded: a person's row.
|
||
|
||
It printed NOT RUN -- red, the same colour as FAIL -- on every ticket
|
||
because `CHECKLIST_FOR_ENGINEER` was a hand-kept tuple holding only 5.5.
|
||
"""
|
||
from vibe import ticketpage as TP
|
||
row = _row(TP.checklist_states([], log=[]), '5.3')
|
||
assert row[2] == 'C'
|
||
assert row[3] == 'FOR ENGINEER'
|
||
assert TP.state_color(row[3]) == 'yellow'
|
||
assert TP.state_color(row[3]) != TP.state_color('FAIL')
|
||
|
||
|
||
def test_53_says_in_its_own_words_why_nothing_was_computed():
|
||
"""A FOR ENGINEER row carries the item's OWN reason, not a stock
|
||
sentence about grade D -- 5.3 is grade C and would have been lied to."""
|
||
from vibe import ticketpage as TP
|
||
rows = dict((r[0], r) for r in TP.checklist_states([], log=[]))
|
||
assert 'grade D' not in rows['5.3'][4]
|
||
assert 'grade C' in rows['5.3'][4]
|
||
assert 'person' in rows['5.3'][4]
|
||
assert 'grade D' in rows['5.5'][4]
|
||
# each row's note is the reason recorded in the table, one source
|
||
by_num = dict((i[0], i) for i in TP.CHECKLIST_ITEMS)
|
||
for num in ('5.3', '5.5'):
|
||
assert rows[num][4] == by_num[num][4]
|
||
|
||
|
||
def test_for_engineer_is_derived_from_the_table_not_kept_by_hand():
|
||
"""No check, no owner code, and a reason written down: those three are
|
||
what "a person owns this ON PURPOSE" looks like. A row missing any of
|
||
them is an unbuilt row and stays NOT RUN."""
|
||
from vibe import ticketpage as TP
|
||
assert TP.CHECKLIST_FOR_ENGINEER == ('5.3', '5.5')
|
||
for item in TP.CHECKLIST_ITEMS:
|
||
num, _name, _grade, owners, reason, check_name = item
|
||
if num in TP.CHECKLIST_FOR_ENGINEER:
|
||
assert check_name is None, num
|
||
assert not owners, num
|
||
assert reason, num
|
||
|
||
|
||
def test_an_unbuilt_row_is_not_swept_into_for_engineer_by_the_derivation():
|
||
"""1.2 and 2.1 have no check function either. They have owner codes, so
|
||
something else claims them, and with nothing claimed they are NOT RUN --
|
||
red -- exactly as before. Turning a red row yellow is the same failure
|
||
as turning it green, one shade quieter."""
|
||
from vibe import ticketpage as TP
|
||
rows = dict((r[0], r) for r in TP.checklist_states([], log=[]))
|
||
for num in ('1.2', '2.1', '2.3', '4.1', '4.2'):
|
||
assert rows[num][3] == 'NOT RUN', num
|
||
assert TP.state_color(rows[num][3]) == 'red', num
|
||
|
||
|
||
# ------------------------------------------------ ticket-set uniqueness (1.3)
|
||
|
||
def test_ticket_set_ok_pair():
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.ticket_set_status(
|
||
['WP199 1 OF 2', 'WP199 2 OF 2'])
|
||
assert sev == 'OK'
|
||
assert '2-sheet' in msg
|
||
|
||
|
||
def test_ticket_set_ok_single():
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.ticket_set_status(['WP210 1 OF 1'])
|
||
assert sev == 'OK'
|
||
|
||
|
||
def test_ticket_set_orphan_fails():
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.ticket_set_status([])
|
||
assert sev == 'FAIL'
|
||
assert 'nobody drew' in msg
|
||
|
||
|
||
def test_ticket_set_missing_page_warns():
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.ticket_set_status(['WP199 1 OF 2']) # 2 OF 2 absent
|
||
assert sev == 'WARN'
|
||
assert '2' in msg
|
||
|
||
|
||
def test_ticket_set_duplicate_warns():
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.ticket_set_status(
|
||
['WP199 1 OF 2', 'WP199 2 OF 2', 'WP199 1 OF 2'])
|
||
assert sev == 'WARN'
|
||
assert 'duplicate' in msg
|
||
|
||
|
||
def test_ticket_set_refuses_names_only():
|
||
"""Claude's negative test: names without page markers contain no
|
||
criterion -- the check must NOT say OK. The old bug (SheetNumber read
|
||
as a parameter) returned OK on exactly this input while seeing
|
||
nothing; this is the test that would have caught it."""
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.ticket_set_status(['WP133', 'WP133'])
|
||
assert sev != 'OK'
|
||
assert 'page marker' in msg
|
||
|
||
|
||
# -------------------------------------------------- sheet name vs mark (1.1)
|
||
|
||
def test_sheet_name_matches_ok():
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.sheet_name_matches('WP133', ['WP133', 'WP133'])
|
||
assert sev == 'OK'
|
||
assert '2 sheet(s)' in msg
|
||
|
||
|
||
def test_sheet_name_matches_normalized():
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.sheet_name_matches('wp133', ['WP133 ', 'WP133'])
|
||
assert sev == 'OK'
|
||
|
||
|
||
def test_sheet_name_matches_mismatch():
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.sheet_name_matches('WP133', ['WP134', 'WP133'])
|
||
assert sev == 'WARN'
|
||
assert 'WP134' in msg
|
||
|
||
|
||
def test_sheet_name_matches_no_sheets():
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.sheet_name_matches('WP133', [])
|
||
assert sev == 'FAIL'
|
||
|
||
|
||
# -------------------------------------------------- invisible chars (8.2)
|
||
|
||
def test_invisible_chars_ok():
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.invisible_chars(['WP133 1 OF 2', 'plain'])
|
||
assert sev == 'OK'
|
||
|
||
|
||
def test_invisible_chars_detects():
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.invisible_chars([u'x\u200ey', 'plain'])
|
||
assert sev == 'WARN'
|
||
assert '0x200e' in msg
|
||
|
||
|
||
def test_invisible_chars_none_skipped():
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.invisible_chars([None, ''])
|
||
assert sev == 'OK'
|
||
|
||
|
||
# --------------------------------------------------- wwf designation (5.4)
|
||
|
||
def test_wwf_designation_matches():
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.wwf_designation_status('6X6-W1.4XW1.4',
|
||
['MESH 6X6-W1.4XW1.4 (SCHEDULED)'])
|
||
assert sev == 'OK'
|
||
assert 'modelled' in msg
|
||
|
||
|
||
def test_wwf_designation_mismatch():
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.wwf_designation_status('6X6-W1.4XW1.4', ['MESH 4X4'])
|
||
assert sev == 'WARN'
|
||
assert '4X4' in msg
|
||
|
||
|
||
def test_wwf_designation_no_design_side():
|
||
"""The branch this check actually walks on this model: spec has no
|
||
wwf -> NOT RUN with the pointer to Extract Design (probe side, but
|
||
the pure core must not invent a verdict either)."""
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.wwf_designation_status('', ['MESH 6X6'])
|
||
assert sev == 'NOT RUN'
|
||
|
||
|
||
# ------------------------------------------- 1.4 view name vs design
|
||
|
||
TOKEN_RE = r'([A-Za-z]+)\s*(\d+)'
|
||
|
||
|
||
def test_view_name_design_catches_the_measured_case():
|
||
"""The finding this check was written for: a view named for WP2 placed
|
||
on a WP1 ticket (measured on 100 sheets, 2026-08-27)."""
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.view_name_design_status(
|
||
'WP1', ['Elevation Top', 'Elevation Front',
|
||
'TICKET NOTE - WP2- STRAND JACKING FORCE'], TOKEN_RE)
|
||
assert sev == 'WARN'
|
||
assert 'WP2' in msg
|
||
# never FAIL, and it must say the name is the finding -- the audit
|
||
# proved the note's CONTENT was right.
|
||
assert 'do not infer' in msg
|
||
|
||
|
||
def test_view_name_design_agreeing_label_is_ok():
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.view_name_design_status(
|
||
'WP1', ['TICKET NOTE - WP1 - STRAND JACKING FORCE'], TOKEN_RE)
|
||
assert sev == 'OK'
|
||
|
||
|
||
def test_view_name_design_ignores_unlabelled_names():
|
||
"""'Elevation Top 2' reduces to TOP2 under the same pattern. A check
|
||
that reported it would fire on ordinary view names on every ticket;
|
||
only a token with THIS design's letter prefix is compared."""
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.view_name_design_status(
|
||
'WP1', ['Elevation Top 2', 'Section A-A', 'Detail 3'], TOKEN_RE)
|
||
assert sev == 'OK'
|
||
assert 'no placed view labels itself' in msg
|
||
|
||
|
||
def test_view_name_design_reduces_page_suffixes():
|
||
"""PNL1.0 and PNL1 are one design; a view labelled PNL1.0 on a PNL1
|
||
ticket is not a finding."""
|
||
from vibe import ticketpage as TP
|
||
sev, _ = TP.view_name_design_status(
|
||
'PNL1', ['NOTE - PNL1.0 - DUNNAGE'], TOKEN_RE)
|
||
assert sev == 'OK'
|
||
|
||
|
||
def test_view_name_design_could_not_judge_is_none_not_ok():
|
||
"""No design, no pattern, or no views -> None (the caller reports NOT
|
||
RUN). Returning OK here is the silent-pass failure the tool exists to
|
||
prevent."""
|
||
from vibe import ticketpage as TP
|
||
assert TP.view_name_design_status('', ['WP2 NOTE'], TOKEN_RE) is None
|
||
assert TP.view_name_design_status('WP1', ['WP2 NOTE'], None) is None
|
||
assert TP.view_name_design_status('WP1', [], TOKEN_RE) is None
|
||
assert TP.view_name_design_status('WP1', ['WP2 NOTE'], '([') is None
|
||
|
||
|
||
def test_view_name_design_reports_every_offender():
|
||
"""A number alone is not information -- the message must name each one."""
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.view_name_design_status(
|
||
'WP1', ['NOTE - WP2 - A', 'NOTE - WP3 - B', 'Elevation Top'],
|
||
TOKEN_RE)
|
||
assert sev == 'WARN'
|
||
assert 'WP2' in msg and 'WP3' in msg
|
||
|
||
|
||
# ------------------------------------------- 6.5 note views say something
|
||
|
||
def _view(name, placed=True, texts=(), other=0):
|
||
return {'name': name, 'placed': placed, 'texts': list(texts),
|
||
'other_elements': other}
|
||
|
||
|
||
def test_note_view_heading_over_nothing_is_the_finding():
|
||
"""The measured shape: the view contains its own title and nothing
|
||
else, which prints as a heading over blank paper."""
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.note_view_content_status([
|
||
_view('DUNNAGE', texts=['DUNNAGE']),
|
||
_view('ERECTION', texts=['ERECTION']),
|
||
_view('PICKING-STRIPPING', texts=['PICKING/STRIPPING']),
|
||
])
|
||
assert sev == 'WARN'
|
||
for n in ('DUNNAGE', 'ERECTION', 'PICKING-STRIPPING'):
|
||
assert n in msg
|
||
|
||
|
||
def test_note_view_with_an_instruction_is_ok():
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.note_view_content_status([
|
||
_view('DUNNAGE', texts=['DUNNAGE', 'STACK ON 4X4 AT 1/5 POINTS']),
|
||
])
|
||
assert sev == 'OK'
|
||
|
||
|
||
def test_note_view_drawn_instruction_counts_as_content():
|
||
"""If the instruction is DRAWN rather than written, text notes alone
|
||
report the view as empty and the check fails every ticket at that
|
||
plant. View-owned graphics count."""
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.note_view_content_status([
|
||
_view('ERECTION', texts=['ERECTION'], other=7),
|
||
])
|
||
assert sev == 'OK'
|
||
assert 'graphics' in msg
|
||
|
||
|
||
def test_note_view_not_placed_is_reported_not_passed():
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.note_view_content_status([
|
||
_view('DUNNAGE', placed=False),
|
||
_view('ERECTION', texts=['ERECTION', 'SEE ERECTION DRAWINGS']),
|
||
])
|
||
assert sev == 'WARN'
|
||
assert 'not placed' in msg and 'DUNNAGE' in msg
|
||
|
||
|
||
def test_note_view_heading_punctuation_does_not_count_as_content():
|
||
"""'PICKING/STRIPPING' in a view named 'PICKING-STRIPPING' is the same
|
||
heading; punctuation must not make an empty view look full."""
|
||
from vibe import ticketpage as TP
|
||
sev, _ = TP.note_view_content_status([
|
||
_view('PICKING-STRIPPING', texts=['PICKING / STRIPPING:']),
|
||
])
|
||
assert sev == 'WARN'
|
||
|
||
|
||
def test_note_view_blank_text_is_not_content():
|
||
from vibe import ticketpage as TP
|
||
sev, _ = TP.note_view_content_status([
|
||
_view('DUNNAGE', texts=['DUNNAGE', ' ']),
|
||
])
|
||
assert sev == 'WARN'
|
||
|
||
|
||
def test_note_view_nothing_declared_is_none_not_ok():
|
||
"""No declared note views -> could not judge, never a pass."""
|
||
from vibe import ticketpage as TP
|
||
assert TP.note_view_content_status([]) is None
|
||
assert TP.note_view_content_status(None) is None
|
||
|
||
|
||
# --------------------------------- both items now occupy a checklist row
|
||
|
||
def test_checklist_carries_rows_for_14_and_65():
|
||
"""Before this, 1.4 had no row at all -- it could not even report NOT
|
||
RUN, and an item nobody checked looked exactly like one that passed."""
|
||
from vibe import ticketpage as TP
|
||
nums = [row[0] for row in TP.CHECKLIST_ITEMS]
|
||
assert '1.4' in nums and '6.5' in nums
|
||
by_num = dict((r[0], r) for r in TP.CHECKLIST_ITEMS)
|
||
assert by_num['1.4'][5] == 'check_view_name_design'
|
||
assert by_num['6.5'][5] == 'check_note_views'
|
||
# re-graded 2026-08-30: both need a plant fact written down first
|
||
assert by_num['1.4'][2] == 'B'
|
||
assert by_num['6.5'][2] == 'B'
|
||
|
||
|
||
def test_checklist_14_and_65_report_not_run_when_the_check_skipped():
|
||
from vibe import ticketpage as TP
|
||
log = [('check_view_name_design', 'SKIPPED', 'no design token pattern'),
|
||
('check_note_views', 'SKIPPED', 'no notes.instruction_views')]
|
||
rows = dict((r[0], r) for r in TP.checklist_states([], log))
|
||
assert rows['1.4'][3] == 'NOT RUN'
|
||
assert 'design token' in rows['1.4'][4]
|
||
assert rows['6.5'][3] == 'NOT RUN'
|
||
assert 'instruction_views' in rows['6.5'][4]
|
||
|
||
|
||
# ------------- 6.5 groups of alternatives, and product families (08-30)
|
||
#
|
||
# Placeholder names throughout: the real ones are plant knowledge and live
|
||
# in the plant file, which is not in this repository.
|
||
|
||
PANEL_SETS = {
|
||
'PANEL': {
|
||
'DUNNAGE': ['NOTE - PANEL - DUNNAGE'],
|
||
'ERECTION': ['NOTE - PANEL - ERECTION',
|
||
'NOTE - PANEL - ERECTION (V2)',
|
||
'NOTE - PANEL - PICKING/ERECTION'],
|
||
'PICKING': ['NOTE - PANEL - PICKING',
|
||
'NOTE - PANEL - PICKING (V3)',
|
||
'NOTE - PANEL - PICKING/ERECTION'],
|
||
},
|
||
'SLAB': {
|
||
'DUNNAGE': ['NOTE - SLAB - DUNNAGE'],
|
||
'PICKING': ['NOTE - SLAB - PICKING'],
|
||
},
|
||
}
|
||
|
||
|
||
def _labels(groups):
|
||
return sorted(g['label'] for g in groups)
|
||
|
||
|
||
def test_a_flat_list_of_names_still_means_one_group_each():
|
||
"""Backward compatibility is not academic -- PLANT_STARTER and the
|
||
existing tests use this shape."""
|
||
from vibe import ticketpage as TP
|
||
groups, reason = TP.instruction_view_groups(
|
||
{'instruction_views': ['DUNNAGE', 'ERECTION']}, ['DUNNAGE'])
|
||
assert reason is None
|
||
assert _labels(groups) == ['DUNNAGE', 'ERECTION']
|
||
assert [g['placed'] for g in groups] == [['DUNNAGE'], []]
|
||
|
||
|
||
def test_one_variant_satisfies_its_whole_group():
|
||
"""The measured case: 15 sheets carry the V3 picking note and the rest
|
||
carry the plain one. A flat list would have called all 15 defective."""
|
||
from vibe import ticketpage as TP
|
||
groups, reason = TP.instruction_view_groups(
|
||
{'instruction_view_sets': PANEL_SETS},
|
||
['NOTE - PANEL - DUNNAGE', 'NOTE - PANEL - ERECTION',
|
||
'NOTE - PANEL - PICKING (V3)'])
|
||
assert reason is None
|
||
assert _labels(groups) == ['DUNNAGE', 'ERECTION', 'PICKING']
|
||
assert all(g['placed'] for g in groups)
|
||
|
||
|
||
def test_a_group_with_none_of_its_variants_names_the_group_once():
|
||
"""Listing four picking variants as four missing views would be four
|
||
times the noise for one fact."""
|
||
from vibe import ticketpage as TP
|
||
groups, _ = TP.instruction_view_groups(
|
||
{'instruction_view_sets': PANEL_SETS},
|
||
['NOTE - PANEL - DUNNAGE', 'NOTE - PANEL - ERECTION'])
|
||
missing = [g['label'] for g in groups if not g['placed']]
|
||
assert missing == ['PICKING']
|
||
sev, msg = TP.note_view_content_status(
|
||
[{'name': 'PICKING', 'placed': False, 'texts': [],
|
||
'other_elements': 0}])
|
||
assert sev == 'WARN'
|
||
assert msg.count('PICKING') == 1
|
||
assert 'V3' not in msg
|
||
|
||
|
||
def test_a_panel_ticket_is_never_asked_for_the_slab_views():
|
||
"""A slab ticket legitimately has no erection note and a panel ticket
|
||
has no slab views at all. Only the family the ticket carries counts."""
|
||
from vibe import ticketpage as TP
|
||
groups, reason = TP.instruction_view_groups(
|
||
{'instruction_view_sets': PANEL_SETS},
|
||
['NOTE - PANEL - DUNNAGE', 'NOTE - PANEL - ERECTION',
|
||
'NOTE - PANEL - PICKING'])
|
||
assert reason is None
|
||
assert set(g['set'] for g in groups) == set(['PANEL'])
|
||
slab, _ = TP.instruction_view_groups(
|
||
{'instruction_view_sets': PANEL_SETS},
|
||
['NOTE - SLAB - DUNNAGE', 'NOTE - SLAB - PICKING'])
|
||
assert set(g['set'] for g in slab) == set(['SLAB'])
|
||
assert _labels(slab) == ['DUNNAGE', 'PICKING']
|
||
|
||
|
||
def test_an_undeclared_product_type_is_not_run_not_a_finding():
|
||
"""A beam ticket, with no beam family written down yet. Reporting its
|
||
three panel groups 'not placed' would be three inventions."""
|
||
from vibe import ticketpage as TP
|
||
groups, reason = TP.instruction_view_groups(
|
||
{'instruction_view_sets': PANEL_SETS},
|
||
['NOTE - BEAM - DUNNAGE', 'NOTE - BEAM - PICKING 2PT'])
|
||
assert groups == []
|
||
assert reason and 'not declared' in reason
|
||
assert 'PANEL' in reason and 'SLAB' in reason
|
||
|
||
|
||
def test_nothing_declared_at_all_says_so():
|
||
from vibe import ticketpage as TP
|
||
groups, reason = TP.instruction_view_groups({}, ['ANYTHING'])
|
||
assert groups == []
|
||
assert 'instruction_views' in reason
|
||
|
||
|
||
def test_a_combined_view_answers_both_groups():
|
||
"""One drawing that gives both instructions satisfies both."""
|
||
from vibe import ticketpage as TP
|
||
groups, _ = TP.instruction_view_groups(
|
||
{'instruction_view_sets': PANEL_SETS},
|
||
['NOTE - PANEL - DUNNAGE', 'NOTE - PANEL - PICKING/ERECTION'])
|
||
assert all(g['placed'] for g in groups)
|
||
|
||
|
||
def test_group_shapes_all_normalise_the_same_way():
|
||
from vibe import ticketpage as TP
|
||
placed = ['A', 'B2']
|
||
for decl in (
|
||
[{'ONE': 'A'}, {'TWO': ['B1', 'B2']}],
|
||
{'ONE': 'A', 'TWO': ['B1', 'B2']},
|
||
[['A'], {'TWO': ['B1', 'B2']}],
|
||
):
|
||
groups, reason = TP.instruction_view_groups(
|
||
{'instruction_views': decl}, placed)
|
||
assert reason is None
|
||
assert len(groups) == 2
|
||
assert all(g['placed'] for g in groups)
|
||
# a bare list of alternatives is named by its first name
|
||
groups, _ = TP.instruction_view_groups(
|
||
{'instruction_views': [['B1', 'B2']]}, placed)
|
||
assert groups[0]['label'] == 'B1'
|
||
|
||
|
||
def test_view_names_match_on_case_and_spacing_only():
|
||
from vibe import ticketpage as TP
|
||
groups, _ = TP.instruction_view_groups(
|
||
{'instruction_views': ['note - panel - dunnage']},
|
||
['NOTE - PANEL - DUNNAGE'])
|
||
assert groups[0]['placed']
|
||
|
||
|
||
def test_an_empty_variant_is_reported_by_the_name_a_reader_must_open():
|
||
"""Told 'PICKING is empty' with four variants declared, a reader has no
|
||
way to know which view to open."""
|
||
from vibe import ticketpage as TP
|
||
sev, msg = TP.note_view_content_status([
|
||
{'name': 'PICKING', 'matched': 'NOTE - PANEL - PICKING (V3)',
|
||
'placed': True, 'texts': ['PICKING'], 'other_elements': 0}])
|
||
assert sev == 'WARN'
|
||
assert 'NOTE - PANEL - PICKING (V3)' in msg
|
||
|
||
|
||
def test_the_matched_variant_is_what_a_heading_is_compared_against():
|
||
"""The variant's own name is the heading it prints, punctuation and
|
||
all -- so an empty variant is still caught as empty."""
|
||
from vibe import ticketpage as TP
|
||
sev, _ = TP.note_view_content_status([
|
||
{'name': 'PICKING', 'matched': 'NOTE - PANEL - PICKING/STRIPPING (V3)',
|
||
'placed': True, 'texts': ['PICKING / STRIPPING:'],
|
||
'other_elements': 0}])
|
||
assert sev == 'WARN'
|
||
sev, _ = TP.note_view_content_status([
|
||
{'name': 'PICKING', 'matched': 'NOTE - PANEL - PICKING/STRIPPING (V3)',
|
||
'placed': True,
|
||
'texts': ['PICKING/STRIPPING', 'PICK AT 1/5 POINTS'],
|
||
'other_elements': 0}])
|
||
assert sev == 'OK'
|
||
|
||
|
||
def test_an_always_required_flat_list_survives_alongside_sets():
|
||
from vibe import ticketpage as TP
|
||
groups, reason = TP.instruction_view_groups(
|
||
{'instruction_views': ['NOTE - ALWAYS'],
|
||
'instruction_view_sets': PANEL_SETS},
|
||
['NOTE - PANEL - DUNNAGE'])
|
||
assert reason is None
|
||
assert 'NOTE - ALWAYS' in _labels(groups)
|
||
assert [g for g in groups if g['label'] == 'NOTE - ALWAYS'][0]['set'] is None
|
||
|
||
|
||
def test_a_view_answering_two_groups_is_named_once():
|
||
from vibe import ticketpage as TP
|
||
row = lambda label: {'name': label, 'matched': 'NOTE - PICK/ERECT',
|
||
'placed': True, 'texts': ['NOTE', 'DO THIS'],
|
||
'other_elements': 0}
|
||
sev, msg = TP.note_view_content_status([row('ERECTION'), row('PICKING')])
|
||
assert sev == 'OK'
|
||
assert msg.count('NOTE - PICK/ERECT') == 1
|
||
|
||
|
||
# ----------------------------- 2.4 and 4.5 now occupy a checklist row (08-30)
|
||
|
||
def test_checklist_carries_rows_for_24_and_45():
|
||
"""2.4 and 4.5 had no row at all until 2026-08-30 -- the report did not
|
||
mention them, so a reader saw a clean table and concluded all items were
|
||
checked. Both are now BUILT and own a check, so the row must come from
|
||
that check's own disposition: silence with no log is still NOT RUN, and
|
||
a check that ran and found nothing is OK."""
|
||
from vibe import ticketpage as TP
|
||
by_num = dict((r[0], r) for r in TP.CHECKLIST_ITEMS)
|
||
for num in ('2.4', '4.5', '5.3'):
|
||
assert num in by_num, num
|
||
assert by_num['2.4'][5] == 'check_cast_extras'
|
||
assert by_num['2.4'][3] == ('CAST-EXTRA',)
|
||
assert by_num['4.5'][5] == 'check_debond_callout'
|
||
assert by_num['4.5'][3] == ('STRAND-CALLOUT',)
|
||
|
||
# No log at all: neither may claim a pass. (Same as every other built
|
||
# row -- 1.1 included -- now that these two carry a check instead of a
|
||
# standing "not implemented" sentence.)
|
||
rows = dict((r[0], r) for r in TP.checklist_states([], []))
|
||
for num in ('1.1', '2.4', '4.5'):
|
||
assert rows[num][3] == 'NOT RUN', num
|
||
|
||
# The check said why it could not run: that reason is the row's.
|
||
log = [('check_cast_extras', 'SKIPPED', 'no families.cast_include'),
|
||
('check_debond_callout', 'SKIPPED', '4.7 could not establish it')]
|
||
rows = dict((r[0], r) for r in TP.checklist_states([], log))
|
||
assert rows['2.4'][3] == 'NOT RUN'
|
||
assert 'cast_include' in rows['2.4'][4]
|
||
assert rows['4.5'][3] == 'NOT RUN'
|
||
assert '4.7' in rows['4.5'][4]
|
||
|
||
|
||
def test_checklist_numbers_are_unique():
|
||
from vibe import ticketpage as TP
|
||
nums = [r[0] for r in TP.CHECKLIST_ITEMS]
|
||
assert len(nums) == len(set(nums))
|
||
|
||
|
||
# ------------------------------------- BED-OFFSET reads left_rail_is_fixed
|
||
|
||
def test_bed_offset_false_rail_spends_the_slack():
|
||
from vibe import ticketpage as TP
|
||
# 144" bed, 143" piece -> 1" of slide is allowed.
|
||
assert TP.bed_offset_status(0.5, 143.0, 144.0, False)[0] == 'OK'
|
||
sev, code, msg = TP.bed_offset_status(2.0, 143.0, 144.0, False)
|
||
assert sev == 'FAIL' and code == 'BED-OFFSET'
|
||
assert 'can only move 1"' in msg
|
||
|
||
|
||
def test_bed_offset_true_rail_is_never_silently_the_false_behaviour():
|
||
"""A fixed left rail: the pattern cannot slide, and nobody has recorded
|
||
what it may do instead. Report the offset, do not judge it, and never
|
||
return the OK that the false path would have produced."""
|
||
from vibe import ticketpage as TP
|
||
sev, code, msg = TP.bed_offset_status(0.5, 143.0, 144.0, True)
|
||
assert sev == 'WARN' and code == 'BED-OFFSET'
|
||
assert 'not judged' in msg and 'FIXED' in msg
|
||
# even a zero offset is not an OK on a true plant
|
||
assert TP.bed_offset_status(0.0, 143.0, 144.0, True)[0] == 'WARN'
|
||
|
||
|
||
def test_bed_offset_unrecorded_flag_says_so_in_the_message():
|
||
from vibe import ticketpage as TP
|
||
sev, _c, msg = TP.bed_offset_status(0.5, 143.0, 144.0, None)
|
||
assert sev == 'OK' and 'unrecorded' in msg
|
||
|
||
|
||
def test_bed_offset_piece_wider_than_bed_beats_every_flag():
|
||
from vibe import ticketpage as TP
|
||
for flag in (True, False, None):
|
||
sev, code, _m = TP.bed_offset_status(0.0, 150.0, 144.0, flag)
|
||
assert (sev, code) == ('FAIL', 'BED-WIDTH')
|
||
|
||
|
||
def test_bed_offset_nothing_to_judge_returns_none():
|
||
from vibe import ticketpage as TP
|
||
assert TP.bed_offset_status(0.5, None, 144.0, False) is None
|
||
assert TP.bed_offset_status(0.5, 143.0, None, False) is None
|
||
|
||
|
||
# --------------------------------------------- optional note-view groups
|
||
#
|
||
# A note the manufacturer's standard itself calls CONDITIONAL. Declared as
|
||
# an ordinary group it would report 'not placed' on every ticket that does
|
||
# not need it -- a permanent false alarm, which is the exact failure the
|
||
# group mechanism was built to avoid.
|
||
|
||
OPTIONAL_SETS = {
|
||
'SLAB': {
|
||
'DUNNAGE': ['NOTE - SLAB - DUNNAGE'],
|
||
'PICKING': ['NOTE - SLAB - PICKING'],
|
||
'ERECTION': {'optional': True, 'views': ['NOTE - SLAB - ERECTION']},
|
||
},
|
||
}
|
||
|
||
|
||
def test_an_absent_optional_group_is_silence_not_a_finding():
|
||
from vibe import ticketpage as TP
|
||
groups, reason = TP.instruction_view_groups(
|
||
{'instruction_view_sets': OPTIONAL_SETS},
|
||
['NOTE - SLAB - DUNNAGE', 'NOTE - SLAB - PICKING'])
|
||
assert reason is None
|
||
assert _labels(groups) == ['DUNNAGE', 'PICKING']
|
||
assert all(g['placed'] for g in groups)
|
||
|
||
|
||
def test_a_placed_optional_group_is_judged_like_any_other():
|
||
from vibe import ticketpage as TP
|
||
groups, reason = TP.instruction_view_groups(
|
||
{'instruction_view_sets': OPTIONAL_SETS},
|
||
['NOTE - SLAB - DUNNAGE', 'NOTE - SLAB - PICKING',
|
||
'NOTE - SLAB - ERECTION'])
|
||
assert reason is None
|
||
assert _labels(groups) == ['DUNNAGE', 'ERECTION', 'PICKING']
|
||
erection = [g for g in groups if g['label'] == 'ERECTION'][0]
|
||
assert erection['placed'] == ['NOTE - SLAB - ERECTION']
|
||
# placed but printing only its own heading is still the finding
|
||
sev, msg = TP.note_view_content_status([
|
||
{'name': 'ERECTION', 'matched': 'NOTE - SLAB - ERECTION',
|
||
'placed': True, 'texts': ['ERECTION'], 'other_elements': 0}])
|
||
assert sev == 'WARN'
|
||
assert 'heading' in msg
|
||
|
||
|
||
def test_an_optional_group_still_identifies_the_product_family():
|
||
"""Carrying a family's conditional note is as good a sign of the
|
||
family as any other, so it counts when picking the set."""
|
||
from vibe import ticketpage as TP
|
||
sets = {'PANEL': PANEL_SETS['PANEL'], 'SLAB': OPTIONAL_SETS['SLAB']}
|
||
groups, reason = TP.instruction_view_groups(
|
||
{'instruction_view_sets': sets}, ['NOTE - SLAB - ERECTION'])
|
||
assert reason is None
|
||
assert set(g['set'] for g in groups) == set(['SLAB'])
|
||
|
||
|
||
def test_optional_defaults_to_false_for_every_older_shape():
|
||
"""Nothing that was required stops being required."""
|
||
from vibe import ticketpage as TP
|
||
for decl in (['A'], [['A', 'B']], {'ONE': ['A']}, [{'ONE': 'A'}]):
|
||
groups = TP._normalise_groups(decl)
|
||
assert groups and all(g['optional'] is False for g in groups)
|
||
groups = TP._normalise_groups({'ONE': {'optional': True, 'views': ['A']}})
|
||
assert groups[0]['optional'] is True
|
||
assert groups[0]['alternatives'] == ['A']
|
||
|
||
|
||
def test_a_map_of_groups_is_not_mistaken_for_an_option_map():
|
||
"""A map without a views key is a map OF groups, as it always was."""
|
||
from vibe import ticketpage as TP
|
||
groups = TP._normalise_groups({'ONE': ['A'], 'TWO': ['B']})
|
||
assert _labels(groups) == ['ONE', 'TWO']
|
||
|
||
|
||
def test_the_views_key_accepts_its_aliases_and_a_bare_string():
|
||
from vibe import ticketpage as TP
|
||
for key in ('views', 'alternatives', 'names', 'any_of'):
|
||
groups = TP._normalise_groups({'ONE': {'optional': True, key: 'A'}})
|
||
assert groups[0]['alternatives'] == ['A']
|
||
assert groups[0]['optional'] is True
|
||
|
||
|
||
# ------------------------- 5.2: the enumeration, not the residual (A-23)
|
||
#
|
||
# The standard places reinforcing by class of bar, positively. These tests
|
||
# pin the two cases the old residual got wrong (a full-length bar and a
|
||
# full-width transverse bar are drawn in a SECTION, never on the plan) and
|
||
# the cases where the tool must say it could not classify rather than fall
|
||
# back to flagging.
|
||
|
||
def test_full_length_longitudinal_bar_belongs_in_section_a_not_the_plan():
|
||
from vibe import ticketpage as TP
|
||
home, why = TP.bar_drawing_home('length', 330.0, 331.5, 0.9)
|
||
assert home == TP.BAR_HOME_SECTION_A
|
||
assert 'note 25' in why
|
||
|
||
|
||
def test_partial_length_bar_belongs_on_the_plan():
|
||
from vibe import ticketpage as TP
|
||
home, why = TP.bar_drawing_home('length', 48.0, 331.5, 0.9)
|
||
assert home == TP.BAR_HOME_PLAN
|
||
assert 'plan' in why
|
||
|
||
|
||
def test_full_width_transverse_bar_belongs_in_section_b_not_the_plan():
|
||
from vibe import ticketpage as TP
|
||
home, why = TP.bar_drawing_home('width', 137.0, 138.0, 0.9)
|
||
assert home == TP.BAR_HOME_SECTION_B
|
||
assert 'note 40' in why
|
||
|
||
|
||
def test_part_width_transverse_bar_is_unplaced_by_the_standard():
|
||
"""Note 26 only keeps transverse bars out of A-1; note 40 covers the
|
||
full-width one. Nothing says where a part-width one goes, so the tool
|
||
says it could not classify it -- it does not fall back to the plan."""
|
||
from vibe import ticketpage as TP
|
||
home, why = TP.bar_drawing_home('width', 24.0, 138.0, 0.9)
|
||
assert home is None
|
||
assert 'does not say' in why
|
||
|
||
|
||
def test_no_full_length_fraction_stands_the_classification_down():
|
||
"""The threshold is plant knowledge with a null default, exactly like
|
||
rails.max_bed_width_in. Unset, nothing is classified and nothing is
|
||
guessed."""
|
||
from vibe import ticketpage as TP
|
||
for axis, reach in (('length', 330.0), ('width', 137.0)):
|
||
home, why = TP.bar_drawing_home(axis, reach, 331.5, None)
|
||
assert home is None
|
||
assert 'full_length_bar_fraction' in why
|
||
|
||
|
||
def test_a_nonsense_fraction_is_refused_rather_than_used():
|
||
from vibe import ticketpage as TP
|
||
for bad in (0, -0.5, 1.5, 'x'):
|
||
assert TP.bar_drawing_home('length', 330.0, 331.5, bad)[0] is None
|
||
|
||
|
||
def test_unusable_extents_are_could_not_classify_never_a_home():
|
||
from vibe import ticketpage as TP
|
||
assert TP.bar_drawing_home('length', None, 331.5, 0.9)[0] is None
|
||
assert TP.bar_drawing_home('length', 330.0, 0, 0.9)[0] is None
|
||
assert TP.bar_drawing_home(None, 330.0, 331.5, 0.9)[0] is None
|
||
# every non-classification carries a reason, never a bare None
|
||
assert TP.bar_drawing_home(None, 330.0, 331.5, 0.9)[1]
|
||
|
||
|
||
def test_bar_run_axis_reads_the_dominant_in_plane_extent():
|
||
from vibe import ticketpage as TP
|
||
# axes: length=0, width=2, thickness=1 (a panel lying in the form)
|
||
assert TP.bar_run_axis([300.0, 0.5, 0.5], 0, 2, 1) == 'length'
|
||
assert TP.bar_run_axis([0.5, 0.5, 130.0], 0, 2, 1) == 'width'
|
||
|
||
|
||
def test_bar_run_axis_refuses_the_cases_it_cannot_read():
|
||
from vibe import ticketpage as TP
|
||
# stands through the thickness: neither longitudinal nor transverse
|
||
assert TP.bar_run_axis([0.5, 8.0, 0.5], 0, 2, 1) is None
|
||
# square in plan (a hoop's box): no run direction to read
|
||
assert TP.bar_run_axis([12.0, 0.5, 12.0], 0, 2, 1) is None
|
||
# unusable input
|
||
assert TP.bar_run_axis([300.0, 0.5], 0, 2, 1) is None
|
||
assert TP.bar_run_axis([300.0, 0.5, 0.5], 0, 0, 1) is None
|
||
assert TP.bar_run_axis(None, 0, 2, 1) is None
|
||
|
||
|
||
# ------------------------------------ BOM disposition (LGD-MISSING retired)
|
||
#
|
||
# `LGD-MISSING` used to be the only thing that said anything about a ticket
|
||
# with no bill of materials. It was an orphan (no checklist item owns LGD-*),
|
||
# it called an unreadable schedule a FAIL, and its premise -- that an empty
|
||
# BOM description is a defect -- is contradicted by section 8000 of the
|
||
# manufacturer's manual. These tests pin its replacement: every check that
|
||
# wants the BOM asks bom_state and says its own honest NOT RUN, and "absent"
|
||
# never reads the same as "unreadable".
|
||
|
||
def test_a_bom_with_content_is_ok_and_needs_no_excuse():
|
||
state, reason = ticketpage.bom_state(
|
||
{'parts': [['2', 'P106', '']], 'strand_linear_ft': 249,
|
||
'schedules': ['PARTS_LIST'], 'unreadable': []})
|
||
assert state == ticketpage.BOM_OK
|
||
assert reason is None
|
||
assert ticketpage.bom_not_run_reason(
|
||
{'parts': [['2', 'P106', '']], 'schedules': ['PARTS_LIST']},
|
||
'anything') is None
|
||
|
||
|
||
def test_a_strand_row_alone_still_counts_as_a_readable_bom():
|
||
"""strand_linear_ft of 0 is a value, not an absence."""
|
||
state, _ = ticketpage.bom_state(
|
||
{'parts': [], 'strand_linear_ft': 0, 'schedules': ['STRAND_SCHEDULE'],
|
||
'unreadable': []})
|
||
assert state == ticketpage.BOM_OK
|
||
|
||
|
||
def test_no_schedule_at_all_is_absent_not_unreadable():
|
||
state, reason = ticketpage.bom_state(
|
||
{'parts': [], 'strand_linear_ft': None, 'schedules': [],
|
||
'unreadable': []})
|
||
assert state == ticketpage.BOM_ABSENT
|
||
assert 'no schedule at all' in reason
|
||
assert 'could not be read' not in reason
|
||
|
||
|
||
def test_schedules_read_but_none_is_a_bom_is_still_absent():
|
||
state, reason = ticketpage.bom_state(
|
||
{'parts': [], 'strand_linear_ft': None,
|
||
'schedules': ['SHEET INDEX'], 'unreadable': []})
|
||
assert state == ticketpage.BOM_ABSENT
|
||
assert 'SHEET INDEX' in reason
|
||
assert 'no BOM' in reason
|
||
|
||
|
||
def test_an_unreadable_schedule_never_reads_as_an_absent_bom():
|
||
"""The two states must not collapse into one -- this is the whole point."""
|
||
state, reason = ticketpage.bom_state(
|
||
{'parts': [], 'strand_linear_ft': None, 'schedules': [],
|
||
'unreadable': [('PARTS_LIST', 'InvalidOperationException: boom')]})
|
||
assert state == ticketpage.BOM_UNREADABLE
|
||
assert 'PARTS_LIST' in reason and 'boom' in reason
|
||
assert 'could not be read' in reason
|
||
# and it says out loud that this is OUR failure, not the ticket's
|
||
assert 'may carry one' in reason
|
||
|
||
absent, absent_reason = ticketpage.bom_state(
|
||
{'parts': [], 'strand_linear_ft': None, 'schedules': [],
|
||
'unreadable': []})
|
||
assert absent != state
|
||
assert absent_reason != reason
|
||
|
||
|
||
def test_nothing_gathered_at_all_is_its_own_state():
|
||
state, reason = ticketpage.bom_state(None)
|
||
assert state == ticketpage.BOM_UNKNOWN
|
||
assert 'no BOM was gathered' in reason
|
||
|
||
|
||
def test_content_plus_an_unreadable_schedule_runs_but_says_so():
|
||
state, reason = ticketpage.bom_state(
|
||
{'parts': [['2', 'P106', '']], 'strand_linear_ft': None,
|
||
'schedules': ['PARTS_LIST'],
|
||
'unreadable': [('STRAND_SCHEDULE', 'boom')]})
|
||
assert state == ticketpage.BOM_PARTIAL
|
||
assert 'STRAND_SCHEDULE' in reason
|
||
# partial still RUNS -- a partial read is reported, never withheld
|
||
assert ticketpage.bom_not_run_reason(
|
||
{'parts': [['2', 'P106', '']], 'schedules': ['PARTS_LIST'],
|
||
'unreadable': [('STRAND_SCHEDULE', 'boom')]}, 'x') is None
|
||
|
||
|
||
def test_the_not_run_reason_names_the_comparison_that_wanted_the_bom():
|
||
"""A BOM-less ticket is named by EVERYTHING that wanted one."""
|
||
bom = {'parts': [], 'strand_linear_ft': None, 'schedules': [],
|
||
'unreadable': []}
|
||
strand = ticketpage.bom_not_run_reason(bom, "the BOM's strand row")
|
||
members = ticketpage.bom_not_run_reason(bom, 'the BOM rows')
|
||
assert strand.startswith("the BOM's strand row could not be judged")
|
||
assert members.startswith('the BOM rows could not be judged')
|
||
assert strand != members
|
||
|
||
|
||
def test_more_than_four_unreadable_schedules_are_counted_not_dumped():
|
||
reason = ticketpage.bom_unreadable_sentence(
|
||
[('S{0}'.format(i), 'boom') for i in range(7)])
|
||
assert reason.startswith('7 schedule(s)')
|
||
assert '+3 more' in reason
|
||
|
||
|
||
# --------------------------------- the expensive_geometry run-time switch
|
||
|
||
def test_the_geometry_switch_has_one_reason_said_one_way():
|
||
"""OFF must be attributable to the switch, not to a missing input."""
|
||
reason = ticketpage.expensive_geometry_off_reason()
|
||
assert reason is ticketpage.EXPENSIVE_GEOMETRY_OFF_REASON
|
||
assert 'expensive_geometry' in reason
|
||
assert 'OFF' in reason
|
||
assert 'run-options.yaml' in reason
|
||
# it must not read like a missing input
|
||
assert 'could not' not in reason
|
||
|
||
|
||
def test_bed_plan_disposition_keeps_only_plan_bound_bars_in_the_difference():
|
||
from vibe import ticketpage as TP
|
||
homes = {
|
||
'A_FULL': set([TP.BAR_HOME_SECTION_A]),
|
||
'B_FULL': set([TP.BAR_HOME_SECTION_B]),
|
||
'TRIM': set([TP.BAR_HOME_PLAN]),
|
||
'MIXED': set([TP.BAR_HOME_SECTION_A, TP.BAR_HOME_PLAN]),
|
||
}
|
||
plan, section, unknown = TP.bed_plan_bar_disposition(homes)
|
||
assert plan == ['MIXED', 'TRIM']
|
||
assert section == ['A_FULL', 'B_FULL']
|
||
assert unknown == []
|
||
|
||
|
||
def test_one_unreadable_instance_takes_the_whole_family_out_of_the_flag():
|
||
"""Half a family measured is not a licence to flag the family."""
|
||
from vibe import ticketpage as TP
|
||
homes = {'X': set([TP.BAR_HOME_PLAN, None]), 'Y': set()}
|
||
plan, section, unknown = TP.bed_plan_bar_disposition(homes)
|
||
assert plan == [] and section == []
|
||
assert unknown == ['X', 'Y']
|
||
|
||
|
||
def test_bed_plan_disposition_handles_no_bars_at_all():
|
||
from vibe import ticketpage as TP
|
||
assert TP.bed_plan_bar_disposition({}) == ([], [], [])
|
||
assert TP.bed_plan_bar_disposition(None) == ([], [], [])
|
||
|
||
|
||
# ------------------------------------- 4.5: debond CALLED OUT on the ticket
|
||
#
|
||
# 4.7 derives where debonding is REQUIRED. 4.5 asks whether the ticket says
|
||
# so. These test the second question only: `required_runs` is 4.7's own
|
||
# return value, passed straight in, and the geometry is never re-derived.
|
||
|
||
def _run(x0, x1, strands):
|
||
return {'x0': x0, 'x1': x1, 'length_ft': round(x1 - x0, 4),
|
||
'strands_in': strands}
|
||
|
||
|
||
def test_45_warns_when_debonding_is_required_and_the_ticket_is_silent():
|
||
"""The owner's ruling: required, not called out -> WARN."""
|
||
from vibe import ticketpage as TP
|
||
sev, code, msg = TP.debond_callout_status(
|
||
[_run(0.0, 2.5, [6.0, 12.0])], [], 4.0)
|
||
assert sev == 'WARN' and code == 'STRAND-CALLOUT'
|
||
# names WHAT and WHERE, never a count
|
||
assert '2.5 ft' in msg and '0 to 2.5 ft' in msg
|
||
assert '6"' in msg and '12"' in msg
|
||
assert 'no debond call-out' in msg
|
||
|
||
|
||
def test_45_cannot_judge_when_47_could_not():
|
||
"""4.7 stood down -> 4.5 has no requirement to compare anything to, and
|
||
must not warn about a requirement nobody established."""
|
||
from vibe import ticketpage as TP
|
||
assert TP.debond_callout_status(None, [], 4.0) is None
|
||
assert TP.debond_callout_status(None, [{'where': 'BED PLAN',
|
||
'text': 'DEBOND STRAND'}],
|
||
4.0) is None
|
||
|
||
|
||
def test_45_ok_when_called_out_but_says_what_it_did_not_check():
|
||
from vibe import ticketpage as TP
|
||
sev, code, msg = TP.debond_callout_status(
|
||
[_run(0.0, 2.5, [6.0])],
|
||
[{'where': 'BED PLAN', 'text': 'DEBOND STRAND'}], 4.0)
|
||
assert sev == 'OK' and code == 'STRAND-CALLOUT'
|
||
assert 'BED PLAN' in msg and 'DEBOND STRAND' in msg
|
||
# the call-out carries no station: the OK must not claim it was verified
|
||
assert 'NOT VERIFIED' in msg
|
||
|
||
|
||
def test_45_nothing_required_is_judged_and_found_nothing_not_silence():
|
||
from vibe import ticketpage as TP
|
||
sev, code, msg = TP.debond_callout_status([], [], 4.0)
|
||
assert sev == 'OK' and code == 'STRAND-CALLOUT'
|
||
assert '4 ft' in msg
|
||
|
||
|
||
def test_45_does_not_warn_about_a_callout_nobody_required():
|
||
"""Only one direction warns (owner's ruling). A ticket that debonds
|
||
where the 4 ft trigger does not fire is NOT flagged -- the plant's own
|
||
fixed each-end debond standard is a different rule and is not merged."""
|
||
from vibe import ticketpage as TP
|
||
sev, code, msg = TP.debond_callout_status(
|
||
[], [{'where': 'BED PLAN', 'text': 'DEBOND STRAND'}], 4.0)
|
||
assert sev == 'OK'
|
||
assert 'not judged' in msg
|
||
|
||
|
||
def test_45_names_at_most_a_readable_number_of_runs():
|
||
from vibe import ticketpage as TP
|
||
runs = [_run(float(i), i + 0.5, [6.0]) for i in range(9)]
|
||
sev, code, msg = TP.debond_callout_status(runs, [], 4.0)
|
||
assert sev == 'WARN'
|
||
assert '+5 more' in msg
|
||
|
||
|
||
# ------------------------------ 2.4: the extras drawn vs the extras counted
|
||
|
||
def _extra(family='LEDGE', volume=8.99, mark=None, eid='1234'):
|
||
return {'family': family, 'mark': mark, 'id': eid, 'volume_ft3': volume}
|
||
|
||
|
||
OKBOM = ('ok', None)
|
||
PARTIALBOM = ('partial', '1 schedule(s) could not be read -- BOM: boom')
|
||
ABSENTBOM = ('absent', 'this ticket carries no schedule at all')
|
||
UNREADBOM = ('unreadable', '1 schedule(s) on this ticket could not be read')
|
||
UNKNOWNBOM = ('unknown', 'no BOM was gathered for this ticket at all')
|
||
|
||
|
||
def test_24_flags_an_extra_the_ticket_names_that_the_model_does_not_carry():
|
||
"""THE case 2.2 cannot see: the ledge is off the model, so it is missing
|
||
from all three weights, which then agree with each other."""
|
||
from vibe import ticketpage as TP
|
||
out = TP.cast_extra_agreement(
|
||
[], [{'where': 'Elevation Front', 'text': 'SLAB SEAT LEDGE',
|
||
'token': 'LEDGE'}], OKBOM, ['LEDGE'])
|
||
assert len(out) == 1
|
||
sev, code, msg = out[0]
|
||
assert sev == 'WARN' and code == 'CAST-EXTRA'
|
||
assert 'Elevation Front' in msg and 'SLAB SEAT LEDGE' in msg
|
||
assert 'agree with each other' in msg
|
||
|
||
|
||
def test_24_flags_an_extra_in_the_weight_that_the_ticket_never_names():
|
||
from vibe import ticketpage as TP
|
||
out = TP.cast_extra_agreement([_extra()], [], OKBOM, ['LEDGE'])
|
||
assert len(out) == 1
|
||
sev, _, msg = out[0]
|
||
assert sev == 'WARN'
|
||
assert '1 x LEDGE' in msg and '8.99 ft3' in msg
|
||
assert 'nothing on this ticket names it' in msg
|
||
|
||
|
||
def test_24_flags_an_extra_whose_volume_could_not_be_read():
|
||
"""Drawn and NOT counted: it contributed nothing to 2.2's reconstruction
|
||
and 2.2 has no way to say so."""
|
||
from vibe import ticketpage as TP
|
||
out = TP.cast_extra_agreement(
|
||
[_extra(volume=None, mark='L1')],
|
||
[{'where': 'BOM row', 'text': '1 LEDGE', 'token': 'LEDGE'}],
|
||
OKBOM, ['LEDGE'])
|
||
assert len(out) == 1
|
||
sev, _, msg = out[0]
|
||
assert sev == 'WARN'
|
||
assert 'LEDGE (L1)' in msg
|
||
assert 'drawn and not counted' in msg
|
||
|
||
|
||
def test_24_agrees_when_both_sides_name_the_same_extra():
|
||
from vibe import ticketpage as TP
|
||
out = TP.cast_extra_agreement(
|
||
[_extra()],
|
||
[{'where': 'BOM row', 'text': '1 LEDGE', 'token': 'LEDGE'}],
|
||
OKBOM, ['LEDGE'])
|
||
assert [o[0] for o in out] == ['OK']
|
||
assert '1 x LEDGE' in out[0][2] and 'BOM row' in out[0][2]
|
||
|
||
|
||
def test_24_says_out_loud_what_it_cannot_detect():
|
||
"""Nothing on either side is a real OK, but the blind spot the checklist
|
||
named -- an extra missing from BOTH -- must not be claimed as checked."""
|
||
from vibe import ticketpage as TP
|
||
out = TP.cast_extra_agreement([], [], OKBOM, ['LEDGE'])
|
||
assert out[0][0] == 'OK'
|
||
assert 'cannot be detected here' in out[0][2]
|
||
|
||
|
||
def test_24_stands_down_when_the_bom_could_not_be_read():
|
||
"""An unreadable BOM may carry the very row that names the ledge, so
|
||
'the ticket does not name it' is not assertable. NOT the same as an
|
||
ABSENT BOM, which is a fact about the ticket."""
|
||
from vibe import ticketpage as TP
|
||
assert TP.cast_extra_agreement([_extra()], [], UNREADBOM,
|
||
['LEDGE']) is None
|
||
assert TP.cast_extra_agreement([_extra()], [], UNKNOWNBOM,
|
||
['LEDGE']) is None
|
||
|
||
|
||
def test_24_still_reports_the_unreadable_volume_when_the_bom_is_unreadable():
|
||
"""That finding needs no ticket at all -- but it must say the ticket
|
||
comparison did not happen, rather than let the row read as judged."""
|
||
from vibe import ticketpage as TP
|
||
out = TP.cast_extra_agreement([_extra(volume=None)], [], UNREADBOM,
|
||
['LEDGE'])
|
||
assert [o[0] for o in out] == ['WARN', 'WARN']
|
||
assert 'NOT compared' in out[1][2]
|
||
|
||
|
||
def test_24_judges_an_absent_bom_from_view_text_and_says_so():
|
||
from vibe import ticketpage as TP
|
||
out = TP.cast_extra_agreement([_extra()], [], ABSENTBOM, ['LEDGE'])
|
||
assert out[0][0] == 'WARN'
|
||
assert 'no BOM' in out[0][2] and 'text drawn on its views' in out[0][2]
|
||
|
||
|
||
def test_24_carries_the_partial_bom_caveat_into_its_message():
|
||
from vibe import ticketpage as TP
|
||
out = TP.cast_extra_agreement([_extra()], [], PARTIALBOM, ['LEDGE'])
|
||
assert out[0][0] == 'WARN'
|
||
assert 'could not be read' in out[0][2]
|
||
|
||
|
||
def test_24_cannot_judge_without_the_plants_cast_family_words():
|
||
from vibe import ticketpage as TP
|
||
assert TP.cast_extra_agreement([], [], OKBOM, []) is None
|
||
|
||
|
||
# ==================================================================
|
||
# THE RESTYLE: order, colour, muting, and the lines behind a count
|
||
# ==================================================================
|
||
#
|
||
# All of this is presentation, and the point of every test here is that
|
||
# presentation NEVER changes the data. The five status words
|
||
# `checklist_states` produces are what the plain-text and JSON consumers
|
||
# read; colour, folding and muting sit on top of them and are not allowed
|
||
# to reach down.
|
||
|
||
|
||
def test_checklist_is_in_numerical_order():
|
||
"""5.4 was listed before 5.3 until 2026-08-30.
|
||
|
||
The owner found it by reading a WP133 run. A checklist is read as a
|
||
checklist -- a reader who cannot find 5.3 where 5.3 belongs starts
|
||
doubting the row is there at all -- so the whole table is held to
|
||
numerical order, not just the pair that was wrong.
|
||
"""
|
||
def key(num):
|
||
major, minor = num.split('.')
|
||
return (int(major), int(minor))
|
||
|
||
nums = [item[0] for item in ticketpage.CHECKLIST_ITEMS]
|
||
assert nums == sorted(nums, key=key)
|
||
assert nums.index('5.3') < nums.index('5.4')
|
||
|
||
|
||
def test_every_checklist_number_is_unique():
|
||
nums = [item[0] for item in ticketpage.CHECKLIST_ITEMS]
|
||
assert len(nums) == len(set(nums))
|
||
|
||
|
||
# ----------------------------------------------------------- the colours
|
||
|
||
def test_ok_is_green():
|
||
assert ticketpage.state_color('OK') == 'green'
|
||
|
||
|
||
def test_warn_and_for_engineer_are_yellow():
|
||
assert ticketpage.state_color('WARN') == 'yellow'
|
||
assert ticketpage.state_color('FOR ENGINEER') == 'yellow'
|
||
|
||
|
||
def test_not_run_is_red_exactly_like_fail():
|
||
"""The owner's grouping, and the founding principle as colour.
|
||
|
||
An unrun check tells you nothing about the piece. If NOT RUN were grey
|
||
or blue -- calmer than FAIL -- the page would be back to letting silence
|
||
read as a pass, which is the one failure this whole tool exists to
|
||
prevent. Anyone softening this has to delete this test first, and the
|
||
name is here so they know what they are deleting.
|
||
"""
|
||
assert ticketpage.state_color('FAIL') == 'red'
|
||
assert ticketpage.state_color('NOT RUN') == 'red'
|
||
assert (ticketpage.state_color('NOT RUN') ==
|
||
ticketpage.state_color('FAIL'))
|
||
|
||
|
||
def test_an_unrecognised_status_is_red_not_grey():
|
||
assert ticketpage.state_color('WHATEVER') == 'red'
|
||
assert ticketpage.state_color('') == 'red'
|
||
assert ticketpage.state_color(None) == 'red'
|
||
|
||
|
||
def test_colour_never_touches_the_status_word():
|
||
"""Colour is presentation; the word is the data.
|
||
|
||
The plain-text and JSON consumers read the word. Rendering a row must
|
||
leave it spelled exactly as `checklist_states` produced it.
|
||
"""
|
||
rows = [('1.1', 'a', 'A', 'NOT RUN', 'why'),
|
||
('1.2', 'b', 'A', 'OK', None)]
|
||
html = ticketpage.checklist_table_html(rows)
|
||
text = ticketpage.checklist_table_text(rows)
|
||
for blob in (html, text):
|
||
assert 'NOT RUN' in blob
|
||
assert 'OK' in blob
|
||
assert [r[3] for r in rows] == ['NOT RUN', 'OK']
|
||
|
||
|
||
# ------------------------------------------------------------- the muting
|
||
|
||
def _know(muted):
|
||
return {'checks': {'muted': muted}}
|
||
|
||
|
||
def _get(tree, dotted, default=None):
|
||
node = tree
|
||
for part in dotted.split('.'):
|
||
if not isinstance(node, dict) or part not in node:
|
||
return default
|
||
node = node[part]
|
||
return default if node is None else node
|
||
|
||
|
||
def test_muted_checks_reads_the_map():
|
||
out = ticketpage.muted_checks(_know({'3.3': 'no piece has one'}),
|
||
getter=_get)
|
||
assert out == {'3.3': 'no piece has one'}
|
||
|
||
|
||
def test_no_muting_configured_returns_every_row():
|
||
rows = [('3.3', 'ratio', 'B', 'NOT RUN', 'why')]
|
||
kept, notes = ticketpage.apply_muting(rows, {})
|
||
assert kept == rows
|
||
assert notes == []
|
||
|
||
|
||
def test_a_muted_item_stays_on_the_table_in_grey_and_is_listed():
|
||
"""Muting is a volume control, not a switch.
|
||
|
||
This ASSERTED THE OPPOSITE until the mute UI landed: a muted row used
|
||
to be dropped from the table. A row that vanishes cannot say it ran, so
|
||
a reader could not tell a muted-and-fine item from one nobody built.
|
||
"""
|
||
rows = [('3.3', 'ratio', 'B', 'NOT RUN', 'why'),
|
||
('1.1', 'name', 'A', 'OK', None)]
|
||
kept, notes = ticketpage.apply_muting(rows, {'3.3': 'no piece has one'})
|
||
assert [r[0] for r in kept] == ['3.3', '1.1']
|
||
assert kept[0][3] == ticketpage.MUTED_STATE
|
||
assert ticketpage.state_color(kept[0][3]) == 'grey'
|
||
assert notes[0][0] == '3.3'
|
||
assert 'no piece has one' in ticketpage.muting_lines(notes)[0]
|
||
|
||
|
||
def test_a_muted_row_still_says_what_it_really_reported():
|
||
rows = [('3.3', 'ratio', 'B', 'NOT RUN', 'no release rule on this job')]
|
||
kept, _notes = ticketpage.apply_muting(rows, {'3.3': 'never applies'})
|
||
note = kept[0][4]
|
||
assert 'NOT RUN' in note # the real answer survives
|
||
assert 'never applies' in note # and why it is quiet
|
||
assert 'no release rule on this job' in note # and the original note
|
||
|
||
|
||
def test_a_muted_row_keeps_every_other_field_of_the_row():
|
||
rows = [('3.3', 'ratio', 'B', 'OK', 'fine', 'an extra field')]
|
||
kept, _notes = ticketpage.apply_muting(rows, {'3.3': 'never applies'})
|
||
assert kept[0][0] == '3.3'
|
||
assert kept[0][1] == 'ratio'
|
||
assert kept[0][2] == 'B'
|
||
assert kept[0][5] == 'an extra field'
|
||
|
||
|
||
def test_a_muted_row_is_its_own_bucket_in_the_tally_and_not_a_pass():
|
||
"""OPEN QUESTION flagged to the owner: own bucket, or folded into OK?
|
||
|
||
Built as its own bucket, which is the honest default -- a muted row is
|
||
a row nobody read, and counting it as a pass is the quiet lie this
|
||
table exists to stop.
|
||
"""
|
||
rows = [('3.3', 'ratio', 'B', 'NOT RUN', 'why'),
|
||
('1.1', 'name', 'A', 'OK', None)]
|
||
kept, _notes = ticketpage.apply_muting(rows, {'3.3': 'no piece has one'})
|
||
counts = ticketpage.checklist_tally(kept)
|
||
assert counts == {ticketpage.MUTED_STATE: 1, 'OK': 1}
|
||
assert counts.get('OK') == 1
|
||
headline = ticketpage.checklist_headline(counts)
|
||
assert '1 ' + ticketpage.MUTED_STATE in headline
|
||
# Quietest last: OK is named before MUTED.
|
||
assert headline.index('OK') < headline.index(ticketpage.MUTED_STATE)
|
||
|
||
|
||
def test_the_muted_state_is_grey_and_grey_is_reachable():
|
||
"""`STATE_COLORS['grey']` existed and nothing could ever reach it."""
|
||
assert ticketpage.GREY_STATES == (ticketpage.MUTED_STATE,)
|
||
assert ticketpage.state_color(ticketpage.MUTED_STATE) == 'grey'
|
||
assert ticketpage.STATE_COLORS['grey']
|
||
|
||
|
||
def test_an_unknown_status_is_still_red_and_never_grey():
|
||
"""The rule grey must not weaken: a state nobody recognises is a state
|
||
nobody judged, and the safe reading of that is the loud one."""
|
||
assert ticketpage.state_color('SOMETHING NEW') == 'red'
|
||
assert ticketpage.state_color('') == 'red'
|
||
assert ticketpage.state_color(None) == 'red'
|
||
assert ticketpage.state_color('NOT RUN') == 'red'
|
||
|
||
|
||
def test_the_colour_key_names_the_grey_so_it_cannot_read_as_skipped():
|
||
html = ticketpage.checklist_key_html()
|
||
assert ticketpage.MUTED_STATE in html
|
||
assert ticketpage.STATE_COLORS['grey'][0] in html
|
||
|
||
|
||
def test_a_muted_row_renders_grey_in_the_table():
|
||
rows = [('3.3', 'ratio', 'B', 'NOT RUN', 'why')]
|
||
kept, _notes = ticketpage.apply_muting(rows, {'3.3': 'no piece has one'})
|
||
html = ticketpage.checklist_table_html(kept)
|
||
assert ticketpage.STATE_COLORS['grey'][0] in html
|
||
assert ticketpage.MUTED_STATE in html
|
||
|
||
|
||
def test_muting_cannot_hide_a_fail():
|
||
"""Muting quietens a permanent NOT RUN. It never softens a defect.
|
||
|
||
Judgement call (Claude 2026-08-30): the brief said a muted check leaves
|
||
the table entirely, and this narrows that by exactly one case -- FAIL.
|
||
A job file that could grey out a failure is a job file that eventually
|
||
will. Now that muting repaints rather than removes, the invariant has
|
||
a second half: the FAIL keeps its own WORD as well as its place, so it
|
||
is still red and still counted as a failure.
|
||
"""
|
||
rows = [('3.3', 'ratio', 'B', 'FAIL', 'it is out of band')]
|
||
kept, notes = ticketpage.apply_muting(rows, {'3.3': 'never applies'})
|
||
assert [r[0] for r in kept] == ['3.3']
|
||
assert kept[0][3] == 'FAIL'
|
||
assert kept[0] == rows[0] # untouched, not repainted
|
||
assert ticketpage.state_color(kept[0][3]) == 'red'
|
||
assert 'FAILED' in notes[0][3]
|
||
assert ticketpage.muting_problem(notes)
|
||
|
||
|
||
def test_muting_a_number_that_is_not_an_item_is_reported():
|
||
rows = [('1.1', 'name', 'A', 'OK', None)]
|
||
kept, notes = ticketpage.apply_muting(rows, {'9.9': 'typo'})
|
||
assert kept == rows
|
||
assert notes[0][0] == '9.9'
|
||
assert 'MUTES NOTHING' in notes[0][3]
|
||
assert ticketpage.muting_problem(notes)
|
||
|
||
|
||
def test_a_mute_with_no_reason_opens_the_section():
|
||
rows = [('3.3', 'ratio', 'B', 'NOT RUN', 'why')]
|
||
_kept, notes = ticketpage.apply_muting(rows, {'3.3': ''})
|
||
assert ticketpage.muting_problem(notes)
|
||
|
||
|
||
def test_tidy_muting_stays_folded():
|
||
rows = [('3.3', 'ratio', 'B', 'NOT RUN', 'why')]
|
||
_kept, notes = ticketpage.apply_muting(rows, {'3.3': 'no piece has one'})
|
||
assert ticketpage.muting_problem(notes) is None
|
||
|
||
|
||
def test_muting_takes_rows_and_returns_rows_only():
|
||
"""It cannot reach the findings, because it is never handed them.
|
||
|
||
The JSON writer and the clean/failing/warning tally both read
|
||
`r['findings']`, which never passes through here. This test pins the
|
||
signature that makes that true.
|
||
"""
|
||
findings = [_F('FAIL', 'FCI-RATIO', 'out of band')]
|
||
rows = [('3.3', 'ratio', 'B', 'NOT RUN', 'why')]
|
||
before = list(findings)
|
||
ticketpage.apply_muting(rows, {'3.3': 'never applies'})
|
||
assert findings == before
|
||
assert len(findings) == 1
|
||
|
||
|
||
# ------------------------------------------- the lines behind a count (8.1)
|
||
|
||
def test_81_detail_enumerates_every_line():
|
||
""""都哪些lines not interpreted?" -- the count resolves to the things."""
|
||
f = _F('WARN', 'SPEC-UNRESOLVED',
|
||
'4 line(s) of the design summary were not interpreted: '
|
||
'STRAND PATTERN | 1/2" DIA. STR. | #3S @ 18" O.C. | (2)@3" EA. END')
|
||
out = ticketpage.checklist_details([f])
|
||
assert out['8.1'] == ['STRAND PATTERN', '1/2" DIA. STR.',
|
||
'#3S @ 18" O.C.', '(2)@3" EA. END']
|
||
|
||
|
||
def test_a_single_line_needs_no_detail_block():
|
||
f = _F('WARN', 'SPEC-UNRESOLVED',
|
||
'1 line(s) of the design summary were not interpreted: ONE THING')
|
||
assert ticketpage.checklist_details([f]) == {}
|
||
|
||
|
||
def test_details_ignore_findings_that_do_not_enumerate():
|
||
assert ticketpage.checklist_details([_F('WARN', 'CLASH', 'a: b | c')]) == {}
|
||
|
||
|
||
# ------------------------------------------------------ the rendered table
|
||
|
||
def test_the_table_carries_a_colour_per_state():
|
||
rows = [('1.1', 'a', 'A', 'OK', None),
|
||
('1.2', 'b', 'A', 'WARN', 'look'),
|
||
('1.3', 'c', 'A', 'NOT RUN', 'nobody looked')]
|
||
html = ticketpage.checklist_table_html(rows)
|
||
assert ticketpage.STATE_COLORS['green'][0] in html
|
||
assert ticketpage.STATE_COLORS['yellow'][0] in html
|
||
assert ticketpage.STATE_COLORS['red'][0] in html
|
||
|
||
|
||
def test_the_table_uses_nothing_the_ie_panel_cannot_render():
|
||
"""pyRevit's output is an IE-engine WebBrowser.
|
||
|
||
`<details>`/`<summary>` do not exist there and fail SILENTLY -- the
|
||
"collapsed" content just stays visible. No HTML5 element, no flexbox,
|
||
no `rgba()`, anywhere in the table or its style.
|
||
"""
|
||
html = ticketpage.checklist_table_html(
|
||
[('1.1', 'a', 'A', 'OK', None)], table_id='X')
|
||
for banned in ('<details', '<summary', 'flex', 'rgba('):
|
||
assert banned not in html
|
||
for banned in ('display:flex', 'display: flex', 'rgba('):
|
||
assert banned not in ticketpage.CHECKLIST_STYLE
|
||
|
||
|
||
def test_a_row_with_a_short_note_carries_no_script_at_all():
|
||
"""THE CONTRACT THAT USED TO COVER THE WHOLE TABLE, NARROWED TWICE NOW.
|
||
|
||
Original: the table uses no script at all, so if every toggle on the
|
||
page is dead the checklist is still there in full. The note fold
|
||
(2026-08-30) narrowed that -- a folded note's text is all in the DOM,
|
||
selectable and copyable, but a dead toggle leaves it visually clipped --
|
||
and the narrowed promise used to read: a row whose status is RED, and a
|
||
row whose note is short, are plain HTML with no handler on them.
|
||
|
||
The owner overruled the RED half of that on 2026-08-31 ("notes太长的话
|
||
默认折叠" -- see `test_every_row_folds_a_long_note_now`): a long red note
|
||
folds like any other long note today, so it DOES carry `onclick`. What
|
||
is still true, on every status including red, is the other half -- a
|
||
note that already fits its item cell gets no control at all, because a
|
||
fold that would not hide anything is not drawn.
|
||
"""
|
||
plain = [('1.1', 'a', 'A', 'OK', None),
|
||
('1.2', 'b', 'A', 'WARN', 'short enough to read at a glance'),
|
||
('1.3', 'c', 'A', 'FAIL', 'also short enough for one glance'),
|
||
('1.4', 'd', 'A', 'NOT RUN', 'still short enough to read here')]
|
||
html = ticketpage.checklist_table_html(plain, table_id='X')
|
||
assert 'onclick' not in html
|
||
assert 'wt-ck-note-clip' not in html
|
||
|
||
|
||
# ------------------------------------------------- the note fold (08-30)
|
||
#
|
||
# Owner: "不要有截断,item不要折叠,需要一行就一行,需要两行就两行,但是note做成
|
||
# 默认折叠显示跟item一样的行数。如果note需要40行,但是item只有两行,那么默认显示
|
||
# 前两行,然后可以打开折叠。"
|
||
|
||
_LONG = 'the whole of it, ' * 40
|
||
|
||
|
||
def test_a_long_yellow_note_folds_and_keeps_every_character():
|
||
html = ticketpage.checklist_table_html(
|
||
[('4.5', 'Debonded strands called out where the design debonds',
|
||
'C', 'WARN', _LONG)], table_id='X')
|
||
assert 'wt-ck-note-clip' in html
|
||
assert _LONG in html # folded is not shortened
|
||
assert '...' not in html
|
||
|
||
|
||
def test_the_item_cell_is_never_folded():
|
||
""""item不要折叠" -- the question column takes the height it needs."""
|
||
long_item = 'a question long enough to wrap several times over ' * 4
|
||
html = ticketpage.checklist_table_html(
|
||
[('4.5', long_item, 'C', 'WARN', _LONG)], table_id='X')
|
||
item_cell = html.split('<td class="wt-ck-grade">')[0]
|
||
assert long_item in item_cell
|
||
assert 'max-height' not in item_cell
|
||
assert 'wt-ck-note-clip' not in item_cell
|
||
|
||
|
||
def test_the_collapsed_note_is_as_tall_as_its_own_rows_item():
|
||
"""Not a fixed number of lines: the row's own item decides. A one-line
|
||
item collapses its note to one line, a two-line item to two."""
|
||
one_line = 'Short item'
|
||
two_lines = 'x' * (ticketpage.ITEM_CHARS_PER_LINE + 5)
|
||
assert ticketpage.item_line_estimate(one_line) == 1
|
||
assert ticketpage.item_line_estimate(two_lines) == 2
|
||
short = ticketpage.checklist_table_html(
|
||
[('1.1', one_line, 'A', 'WARN', _LONG)], table_id='X')
|
||
tall = ticketpage.checklist_table_html(
|
||
[('1.1', two_lines, 'A', 'WARN', _LONG)], table_id='Y')
|
||
assert 'max-height:{0:.2f}em'.format(ticketpage.NOTE_LINE_HEIGHT) in short
|
||
assert 'max-height:{0:.2f}em'.format(2 * ticketpage.NOTE_LINE_HEIGHT) \
|
||
in tall
|
||
|
||
|
||
def test_the_fold_height_and_the_style_agree_on_what_a_line_is():
|
||
"""The height is computed as lines x NOTE_LINE_HEIGHT in `em`, so the
|
||
CSS `line-height` has to be the same number or "two lines" silently
|
||
stops meaning two lines."""
|
||
assert 'line-height: {0};'.format(ticketpage.NOTE_LINE_HEIGHT) \
|
||
in ticketpage.CHECKLIST_STYLE
|
||
|
||
|
||
def test_every_row_folds_a_long_note_now():
|
||
"""OVERRULED, owner, 2026-08-31: "notes太长的话默认折叠" -- long notes
|
||
fold by default, and the instruction names no status word. It replaces
|
||
the red exemption this test used to pin (`test_a_red_row_never_folds_
|
||
no_matter_how_long_its_note`, standing 2026-08-30 to 2026-08-31): row
|
||
probes, shared-silence cross-references and dated mute reasons have
|
||
since made checklist notes long as a matter of course, mostly on NOT
|
||
RUN -- the very status that used to be exempt -- and the owner is now
|
||
looking at walls of text on his screen, not a rare long WARN.
|
||
|
||
The new contract, pinned here:
|
||
|
||
* EVERY status folds a note that exceeds its item's height, FAIL and
|
||
NOT RUN included. No status is special-cased out any more.
|
||
* NOTHING IS EVER TRUNCATED. `_LONG` survives whole in the HTML on
|
||
every status -- the tombstone this suite already keeps
|
||
(`NOTE_CAP` / `capped_note` are gone for good) is untouched by this
|
||
ruling and stays a separate guarantee.
|
||
* THE FOLD CONTROL IS PRESENT AND STATES WHAT IS BEHIND IT. `[+] the
|
||
whole note (N characters)` -- a reader is never left guessing
|
||
whether a red row is hiding anything, only how much.
|
||
* Folding is not hiding: the state colour and the first `item_lines`
|
||
lines stay visible on every row before any click.
|
||
"""
|
||
for status in ticketpage.RED_STATES + ticketpage.YELLOW_STATES:
|
||
html = ticketpage.checklist_table_html(
|
||
[('1.1', 'Short item', 'A', status, _LONG)], table_id='X')
|
||
assert 'wt-ck-note-clip' in html, status
|
||
assert 'onclick' in html, status
|
||
assert _LONG in html, status # folded is not truncated
|
||
assert '...' not in html, status
|
||
assert '[+] the whole note ({0} characters)'.format(len(_LONG)) \
|
||
in html, status
|
||
assert ticketpage.note_folds(_LONG, 1, 'WARN') is True
|
||
assert ticketpage.note_folds(_LONG, 1, 'FAIL') is True
|
||
assert ticketpage.note_folds(_LONG, 1, 'NOT RUN') is True
|
||
|
||
|
||
def test_a_note_shorter_than_its_item_gets_no_control_that_does_nothing():
|
||
html = ticketpage.checklist_table_html(
|
||
[('1.1', 'A fairly long question that wraps onto two lines here',
|
||
'A', 'WARN', 'brief')], table_id='X')
|
||
assert 'wt-ck-note-clip' not in html
|
||
assert 'wt-ck-note-more' not in html
|
||
|
||
|
||
def test_the_file_link_is_rendered_outside_the_fold():
|
||
"""7.1's evidence IS the link. A fold that swallowed it would hide the
|
||
one thing the note exists to hand over."""
|
||
note = _LONG + ticketpage.NOTE_FILE_SEP + _CSV
|
||
html = ticketpage.checklist_table_html(
|
||
[('7.1', 'Interference check', 'B', 'WARN', note)], table_id='X')
|
||
clip = html.split('class="wt-ck-note-body wt-ck-note-clip"')[1]
|
||
clip = clip.split('</div>')[0]
|
||
assert _CSV not in clip # not inside the clipped div
|
||
assert 'wt-ck-note-path' in html
|
||
assert '>' + _CSV + '</a>' in html
|
||
|
||
|
||
def test_the_toggle_is_the_fold_convention_this_tool_already_uses():
|
||
"""One mechanism, extended -- not a second one. Inline `onclick`, an
|
||
element id, a header at `id + '_h'`, and the same [+]/[-] marker the
|
||
report folds swap."""
|
||
from vibe import foldreport
|
||
html = ticketpage.checklist_table_html(
|
||
[('4.5', 'Short item', 'C', 'WARN', _LONG)], table_id='X')
|
||
ident = ticketpage.note_fold_id('X', '4.5')
|
||
assert 'id="{0}"'.format(ident) in html
|
||
assert 'id="{0}_h"'.format(ident) in html
|
||
assert 'onclick="wtClip(\'{0}\')"'.format(ident) in html
|
||
assert '[+]' in html
|
||
assert 'function wtClip' in foldreport.SCRIPT
|
||
assert 'function wtFold' in foldreport.SCRIPT # still there
|
||
for banned in ('addEventListener', 'classList', 'querySelector'):
|
||
assert banned not in foldreport.SCRIPT
|
||
|
||
|
||
def test_the_control_says_how_much_is_behind_it():
|
||
"""A reader is never left guessing whether anything is folded away --
|
||
which is the one thing the old '...' did do."""
|
||
html = ticketpage.checklist_table_html(
|
||
[('4.5', 'Short item', 'C', 'WARN', _LONG)], table_id='X')
|
||
assert '({0} characters)'.format(len(_LONG)) in html
|
||
|
||
|
||
def test_two_tickets_on_one_page_do_not_share_a_fold_id():
|
||
"""A run prints one table per ticket and every one has a row 4.5. One
|
||
id twice means one toggle opens whichever the browser found first."""
|
||
row = [('4.5', 'Short item', 'C', 'WARN', _LONG)]
|
||
first = ticketpage.checklist_table_html(row)
|
||
second = ticketpage.checklist_table_html(row)
|
||
ids = []
|
||
for html in (first, second):
|
||
ids.append(html.split('<div id="')[1].split('"')[0])
|
||
assert ids[0] != ids[1]
|
||
assert ticketpage.note_fold_id('X', '4.5') \
|
||
!= ticketpage.note_fold_id('Y', '4.5')
|
||
|
||
|
||
def test_the_text_file_has_no_fold_and_no_markers():
|
||
""""The unfolded file copy keeps everything" -- it is what the owner
|
||
falls back to when the panel misbehaves."""
|
||
out = ticketpage.checklist_table_text(
|
||
[('4.5', 'Short item', 'C', 'WARN', _LONG)], width=70)
|
||
for markup in ('[+]', 'wt-ck', 'onclick', 'max-height', '...'):
|
||
assert markup not in out
|
||
# counted a word at a time: `textwrap` breaks at spaces, so a phrase
|
||
# spans lines while a word never does
|
||
assert out.count('whole') == 40
|
||
|
||
|
||
def test_the_table_is_bigger_than_the_panel_default():
|
||
""""字儿可以再大一点" -- the panel's own body text is 8pt."""
|
||
assert '11pt' in ticketpage.CHECKLIST_STYLE
|
||
assert '12pt' in ticketpage.CHECKLIST_STYLE
|
||
|
||
|
||
def test_a_note_cell_is_allowed_to_wrap_rather_than_be_cut():
|
||
""""一行放不下的就多来几行"."""
|
||
assert 'white-space: normal' in ticketpage.CHECKLIST_STYLE
|
||
assert 'word-wrap: break-word' in ticketpage.CHECKLIST_STYLE
|
||
|
||
|
||
def test_the_table_escapes_a_mark_containing_markup():
|
||
html = ticketpage.checklist_table_html(
|
||
[('1.1', 'a<b>c', 'A', 'OK', 'x & y')])
|
||
assert '<b>' in html
|
||
assert 'x & y' in html
|
||
|
||
|
||
def test_detail_lines_are_rendered_under_their_row():
|
||
rows = [('8.1', 'unresolved', 'C', 'WARN', '4 line(s) ...')]
|
||
html = ticketpage.checklist_table_html(
|
||
rows, {'8.1': ['STRAND PATTERN', '#3S @ 18" O.C.']})
|
||
assert 'STRAND PATTERN' in html
|
||
assert '#3S @ 18" O.C.' in html or '#3S @ 18" O.C.' in html
|
||
|
||
|
||
# --------------------------------- a detail block is a CHILD, not a band
|
||
#
|
||
# Owner, reading the WP296 run: "8.1 跟 8.2 之间那是啥?我总感觉它在的表格的
|
||
# 位置不太对。" It was 8.1's own detail block, and every visual cue said
|
||
# otherwise: full width from the table's left edge, painted in the parent's
|
||
# status colour, and a rule between it and the row it belonged to.
|
||
|
||
def test_the_detail_block_does_not_start_at_the_tables_left_edge():
|
||
"""It begins under the ITEM column; the # column is left blank.
|
||
|
||
A block that starts at x=0 is a row. A block that starts where the
|
||
parent's text starts is that parent's second half.
|
||
"""
|
||
html = ticketpage.checklist_table_html(
|
||
[('8.1', 'unresolved', 'C', 'WARN', 'count: a | b')],
|
||
{'8.1': ['a', 'b']})
|
||
assert 'wt-ck-detail-pad' in html
|
||
assert 'colspan="4"' in html
|
||
assert 'colspan="5"' not in html
|
||
|
||
|
||
def test_nothing_is_drawn_between_a_row_and_its_own_detail():
|
||
"""The parent loses its bottom rule, so the two read as one block."""
|
||
html = ticketpage.checklist_table_html(
|
||
[('8.1', 'unresolved', 'C', 'WARN', 'count: a | b')],
|
||
{'8.1': ['a', 'b']})
|
||
assert '<tr class="wt-ck-open">' in html
|
||
assert '.wt-ck tr.wt-ck-open td { border-bottom: none; }' \
|
||
in ticketpage.CHECKLIST_STYLE
|
||
|
||
|
||
def test_no_column_rule_is_written_so_weakly_that_it_never_binds():
|
||
"""THE BUG THAT MADE THE BLOCK LOOK LIKE A ROW, GUARDED.
|
||
|
||
`.wt-ck td` is (class + element); a bare `.wt-ck-detail` is (class), so
|
||
the bare form loses every property the td rule also sets -- and loses it
|
||
silently, declaration present, browser discarding it. The detail block
|
||
asked for a 51px left padding for months and rendered at the td rule's
|
||
7px: full width, at the table's left edge, indented by nothing.
|
||
|
||
Any rule for a cell class must therefore be qualified with `td`.
|
||
"""
|
||
cell_classes = ('wt-ck-num', 'wt-ck-grade', 'wt-ck-state',
|
||
'wt-ck-detail', 'wt-ck-detail-pad')
|
||
checked = 0
|
||
for line in ticketpage.CHECKLIST_STYLE.split('\n'):
|
||
if '{' not in line:
|
||
continue
|
||
selector = line.split('{')[0].strip()
|
||
if not selector.startswith('.wt-ck'):
|
||
continue
|
||
target = selector.split()[-1] # what the rule actually paints
|
||
for cls in cell_classes:
|
||
if target.endswith('.' + cls):
|
||
assert target == 'td.' + cls, selector
|
||
checked += 1
|
||
assert checked == len(cell_classes) # and every one of them is there
|
||
|
||
|
||
def test_a_row_with_no_detail_is_not_opened():
|
||
html = ticketpage.checklist_table_html([('8.2', 'b', 'A', 'OK', None)])
|
||
assert 'wt-ck-open' not in html
|
||
assert 'wt-ck-detail' not in html
|
||
|
||
|
||
def test_the_detail_block_is_not_painted_in_the_parents_status_colour():
|
||
"""A pale yellow band the full width of the table IS the thing he saw.
|
||
|
||
The tie to the parent is its status INK on the left rule, not its ground
|
||
across the whole width.
|
||
"""
|
||
ink, ground = ticketpage.STATE_COLORS['yellow']
|
||
html = ticketpage.checklist_table_html(
|
||
[('8.1', 'unresolved', 'C', 'WARN', 'count: a | b')],
|
||
{'8.1': ['a', 'b']})
|
||
detail = html.split('wt-ck-detail-pad')[1]
|
||
assert 'background:{0}'.format(ground) not in detail
|
||
assert 'border-left-color:{0}'.format(ink) in detail
|
||
|
||
|
||
def test_the_detail_block_is_smaller_than_the_row_above_it():
|
||
"""10pt under the table's own 11pt -- and it has to actually bind."""
|
||
assert '.wt-ck td.wt-ck-detail { font-size: 10pt;' \
|
||
in ticketpage.CHECKLIST_STYLE
|
||
assert '.wt-ck td { padding: 5px 7px' in ticketpage.CHECKLIST_STYLE
|
||
assert 'font-size: 11pt;' in ticketpage.CHECKLIST_STYLE
|
||
|
||
|
||
def test_the_status_column_is_the_biggest_thing_in_the_row():
|
||
""""OK用绿色..." -- and 12pt, which until now the browser discarded."""
|
||
assert '.wt-ck td.wt-ck-state { font-size: 12pt;' \
|
||
in ticketpage.CHECKLIST_STYLE
|
||
|
||
|
||
def test_the_note_does_not_repeat_the_lines_printed_under_it():
|
||
"""Said once. The note stops at the colon; the bullets carry the rest."""
|
||
note = ('4 line(s) of the design summary were not interpreted: '
|
||
'STRAND PATTERN | 1/2" DIA. STR.')
|
||
html = ticketpage.checklist_table_html(
|
||
[('8.1', 'unresolved', 'C', 'WARN', note)],
|
||
{'8.1': ['STRAND PATTERN', '1/2" DIA. STR.']})
|
||
assert html.count('STRAND PATTERN') == 1
|
||
assert 'were not interpreted:' in html
|
||
|
||
|
||
def test_the_note_is_left_whole_when_nothing_is_printed_under_it():
|
||
note = '1 line(s) of the design summary were not interpreted: ONE THING'
|
||
assert ticketpage.note_lead(note, None) == note
|
||
assert ticketpage.note_lead(note, []) == note
|
||
|
||
|
||
def test_the_text_report_says_it_once_too():
|
||
out = ticketpage.checklist_table_text(
|
||
[('8.1', 'unresolved', 'C', 'WARN', 'count: A THING | B THING')],
|
||
{'8.1': ['A THING', 'B THING']})
|
||
assert out.count('A THING') == 1
|
||
assert '- A THING' in out
|
||
|
||
|
||
# ------------------------------- a note that hands the reader a file (7.1)
|
||
#
|
||
# "note 那边应该是一个文件的链接,单击打开那个文件". The visible text stays the
|
||
# real Windows path -- that is what gets copied into Explorer -- and only the
|
||
# href takes the `file:///` form, exactly as specsave/PanelLiftCheck do it.
|
||
|
||
_CSV = r'C:\jobs\Some Job\_ticketqc\interference-PNL1.csv'
|
||
|
||
|
||
def _file_note(count=4, members=142, path=_CSV):
|
||
return ('{0} interference(s) among the {1} member(s) of this piece\'s '
|
||
'own assembly{2}{3}'.format(count, members,
|
||
ticketpage.NOTE_FILE_SEP, path))
|
||
|
||
|
||
def test_a_note_ending_in_a_path_renders_as_a_link():
|
||
html = ticketpage.checklist_table_html(
|
||
[('7.1', 'Interference check', 'B', 'WARN', _file_note())])
|
||
assert 'href="file:///C:/jobs/Some Job/_ticketqc/interference-PNL1.csv"' \
|
||
in html
|
||
# the visible text is the real path, backslashes and all
|
||
assert '>' + _CSV + '</a>' in html
|
||
|
||
|
||
def test_a_note_with_no_path_is_not_linkified():
|
||
html = ticketpage.checklist_table_html(
|
||
[('7.1', 'Interference check', 'B', 'WARN', 'nothing to open')])
|
||
assert '<a href' not in html
|
||
|
||
|
||
# ------------------------------------------ nothing truncates a note now
|
||
#
|
||
# Owner, 2026-08-30, having read a live 4.5: "4.5被截断了。我经常在notes里面有
|
||
# 截断。不要有截断". These three tests replace the three that pinned the cap's
|
||
# behaviour (`test_the_cap_never_cuts_the_path_in_half`,
|
||
# `..._still_bites_on_the_prose_in_front_of_the_path`,
|
||
# `test_an_ordinary_long_note_is_still_capped`). The cap is gone, and what
|
||
# is pinned now is its absence.
|
||
|
||
|
||
def test_a_long_note_reaches_the_row_whole():
|
||
"""`checklist_states` used to run every message through a 180-character
|
||
cap, so the ROW carried a stump and the HTML table, the plain-text file
|
||
and the JSON all inherited it. 4.5's real message is over 300."""
|
||
from vibe import ticketpage as TP
|
||
message = ('debonding is required here -- ' + 'w' * 300 +
|
||
' -- and this half is the half that vanished')
|
||
rows = TP.checklist_states(
|
||
[_F('WARN', 'STRAND-CALLOUT', message)],
|
||
log=[('check_debond_callout', 'RAN', None)])
|
||
note = _row(rows, '4.5')[4]
|
||
assert note == message
|
||
assert '...' not in note
|
||
|
||
|
||
def test_no_cap_survives_anywhere_in_the_module():
|
||
"""A tombstone. If `capped_note` comes back, so does the defect."""
|
||
assert not hasattr(ticketpage, 'capped_note')
|
||
assert not hasattr(ticketpage, 'NOTE_CAP')
|
||
|
||
|
||
def test_the_whole_note_reaches_the_html_and_the_text_alike():
|
||
"""Both consumers, one source. The panel may FOLD it; the file may WRAP
|
||
it; neither may lose a character of it."""
|
||
message = 'alpha ' + 'z' * 400 + ' omega'
|
||
rows = [('4.5', 'Debonded strands called out where the design debonds',
|
||
'C', 'WARN', message)]
|
||
html = ticketpage.checklist_table_html(rows, table_id='X')
|
||
text = ticketpage.checklist_table_text(rows, width=70)
|
||
for blob in (html, text):
|
||
assert 'alpha' in blob
|
||
assert 'omega' in blob
|
||
assert '...' not in blob
|
||
assert 'z' * 400 in html # in the DOM, uncut
|
||
assert text.count('z') == 400 # wrapped, not trimmed
|
||
|
||
|
||
# ------------- no row may print NOT RUN with an empty reason (2026-08-31)
|
||
#
|
||
# Seven checklist rows name no check function: they are answered by finding
|
||
# codes `ticketqc.check_ticket` emits. `run_all`'s skip probes are keyed by
|
||
# CHECK, so none of that machinery could reach them, and on a live run six of
|
||
# them (2.1, 2.3, 3.1, 3.2, 4.1, 4.2) came back NOT RUN with `reason: null`
|
||
# and printed as a bare red row with nothing under it. A row that cannot say
|
||
# why it is quiet gives the reader nothing to disagree with -- which is how a
|
||
# page of real refusals gets read as noise.
|
||
|
||
|
||
def _every_row(findings=(), log=()):
|
||
return dict((r[0], r) for r in ticketpage.checklist_states(
|
||
list(findings), list(log)))
|
||
|
||
|
||
def test_no_row_can_print_not_run_with_an_empty_reason():
|
||
"""THE MECHANICAL GUARANTEE, over the whole table with nothing supplied.
|
||
A new code-answered row added with neither a probe nor a declared reason
|
||
fails here rather than on the owner's screen."""
|
||
for num, row in _every_row().items():
|
||
if row[3] != 'NOT RUN':
|
||
continue
|
||
assert row[4], 'row {0} is NOT RUN with no reason'.format(num)
|
||
assert str(row[4]).strip(), num
|
||
|
||
|
||
def test_a_row_answered_only_by_codes_says_so_when_nothing_explained_it():
|
||
"""The last rung: no probe spoke and the item declares no reason. It is
|
||
reported as the defect it is -- the same ruling as RAN-EMPTY's "no probe
|
||
declared" -- never as a blank."""
|
||
row = _every_row()['3.1']
|
||
assert row[3] == 'NOT RUN'
|
||
assert 'FC' in row[4] # the code it waited for
|
||
assert 'nothing in this run recorded why' in row[4]
|
||
assert 'not a verdict on the piece' in row[4]
|
||
|
||
|
||
def test_a_row_probes_reason_reaches_the_row_through_the_log():
|
||
"""`run_all` writes it under `row:<number>` because the log is the one
|
||
channel that already reaches every consumer -- no caller had to change
|
||
to carry it."""
|
||
reason = 'no spec has been extracted for this design -- it carries no fc'
|
||
row = _every_row(log=[(ticketpage.ROW_NOTE_PREFIX + '3.1', 'NO-CODE',
|
||
reason)])['3.1']
|
||
assert row[3] == 'NOT RUN'
|
||
assert row[4] == reason
|
||
|
||
|
||
def test_a_code_that_did_arrive_still_beats_any_row_probe():
|
||
"""The probe explains a SILENCE. A row that was answered is answered, and
|
||
a probe that spoke anyway may not overwrite the verdict."""
|
||
finding = _F('OK', 'FC', "f'c 6000 psi")
|
||
row = _every_row([finding], [(ticketpage.ROW_NOTE_PREFIX + '3.1',
|
||
'NO-CODE', 'would have been quiet')])['3.1']
|
||
assert row[3] == 'OK'
|
||
assert row[4] is None
|
||
|
||
|
||
def test_four_rows_quiet_for_one_fact_say_so_to_each_other():
|
||
"""Rows 4.4 and 4.6 are answered by one check and stand down on one
|
||
sentence; four red rows carrying one fact read as four problems. Each
|
||
keeps its OWN sentence and gains a cross-reference -- a group summary
|
||
that replaced the sentences would trade one silence for another."""
|
||
reason = ('no strand geometry on this piece -- strand positions and row '
|
||
'spacing could not be judged')
|
||
rows = _every_row(log=[('check_strand_layout', 'SKIPPED', reason)])
|
||
for num, other in (('4.4', '4.6'), ('4.6', '4.4')):
|
||
assert rows[num][4].startswith(reason)
|
||
assert 'one fact, not 2 separate problems' in rows[num][4]
|
||
assert other in rows[num][4]
|
||
|
||
|
||
def test_a_row_quiet_on_its_own_is_not_told_it_has_company():
|
||
reason = 'no dunnage on this piece -- nothing to place under an opening'
|
||
rows = _every_row(log=[('check_dunnage_openings', 'SKIPPED', reason)])
|
||
assert rows['6.4'][4] == reason
|
||
|
||
|
||
# ---------------- the text report may not drop a character of a note
|
||
#
|
||
# `last-ticketqc.txt` is documented as the complete record. A run on
|
||
# 2026-08-31 wrote it with every CJK character of the job file's mute
|
||
# sentences REMOVED -- the ASCII fragments and the gaps between them
|
||
# survived, and the same sentences rendered correctly in the panel and in
|
||
# `qc-run.json`. A file promised as complete that silently drops half a
|
||
# sentence is worse than one that fails to write.
|
||
#
|
||
# The mechanism is engine-specific and CANNOT be reproduced on the host
|
||
# interpreter this suite runs on: `textwrap` munges whitespace with
|
||
# `text.translate(<256-entry table>)` for anything it takes for a `str`, and
|
||
# IronPython 2.7's `str` holds .NET UTF-16 text, so a character above U+00FF
|
||
# has no entry in that table. Hence two tests: one that a non-ASCII note
|
||
# survives (true on any engine, and the promise itself), and one that pins
|
||
# the SHAPE that keeps it true on the engine that ships.
|
||
|
||
_CJK_REASON = u'开口未建模 -- muted by the owner'
|
||
|
||
|
||
def test_a_non_ascii_note_reaches_the_text_report_intact():
|
||
out = ticketpage.checklist_table_text(
|
||
[('3.3', 'release ratio', 'B', ticketpage.MUTED_STATE, _CJK_REASON)])
|
||
assert _CJK_REASON in out
|
||
|
||
|
||
def test_a_non_ascii_mute_reason_survives_every_step_to_the_report():
|
||
"""Read from the job file, repainted onto the row, wrapped into the
|
||
file. Every step, because the loss only has to happen once."""
|
||
muted = ticketpage.muted_checks({'checks': {'muted': {'3.3': _CJK_REASON}}},
|
||
getter=lambda k, path, default=None:
|
||
k.get('checks', {}).get('muted', default))
|
||
assert muted['3.3'] == _CJK_REASON
|
||
rows, notes = ticketpage.apply_muting(
|
||
[('3.3', 'release ratio', 'B', 'NOT RUN', 'no spec')], muted)
|
||
assert _CJK_REASON in rows[0][4]
|
||
assert notes[0][2] == _CJK_REASON
|
||
text = ticketpage.checklist_table_text(rows)
|
||
assert _CJK_REASON in text
|
||
# and it is TEXT, so the button's one `encode('utf-8')` is all it takes
|
||
assert text.encode('utf-8').decode('utf-8') == text
|
||
|
||
|
||
def test_a_non_ascii_note_survives_wrapping_onto_several_lines():
|
||
"""The wrap is where the characters were lost, so the long case is the
|
||
one that matters -- a short note might never reach `textwrap` at all."""
|
||
note = (_CJK_REASON + ' ') * 12
|
||
out = ticketpage.checklist_table_text(
|
||
[('3.3', 'release ratio', 'B', ticketpage.MUTED_STATE, note)],
|
||
width=60)
|
||
assert out.count(u'开口未建模') == 12
|
||
assert len(out.split('\n')) > 3 # it really did wrap
|
||
|
||
|
||
def test_the_text_report_never_hands_a_note_to_textwraps_translate():
|
||
"""A TOMBSTONE, and the only kind of test that can hold here: the defect
|
||
lives in an engine this suite does not run on, so what is pinned is the
|
||
shape that avoids it -- whitespace flattened before the wrap, `textwrap`
|
||
told not to do it again, and no bare `str()` on a note."""
|
||
import inspect
|
||
|
||
def code_of(fn):
|
||
"""The function without its docstring -- these docstrings TALK about
|
||
`str()`, and a prose mention is not a call."""
|
||
return inspect.getsource(fn).replace(fn.__doc__ or '\n\n', '')
|
||
|
||
src = code_of(ticketpage._wrap_text)
|
||
assert 'replace_whitespace=False' in src
|
||
assert 'expand_tabs=False' in src
|
||
assert "' '.join(_text(value).split())" in src
|
||
table = code_of(ticketpage.checklist_table_text)
|
||
assert 'str(' not in table
|
||
assert '_tw.wrap' not in table # it goes through `_wrap_text`
|
||
for name in ('note_lead', 'note_parts', '_muted_row', 'muted_checks'):
|
||
assert 'str(' not in code_of(getattr(ticketpage, name)), name
|
||
|
||
|
||
def test_the_text_report_keeps_the_path_on_one_unwrapped_line():
|
||
"""`textwrap` would break it at the space in a folder name."""
|
||
out = ticketpage.checklist_table_text(
|
||
[('7.1', 'Interference check', 'B', 'WARN', _file_note())], width=60)
|
||
assert any(line.strip() == _CSV for line in out.split('\n'))
|
||
|
||
|
||
def test_a_hash_in_the_path_does_not_truncate_the_link():
|
||
"""`#` starts a URL fragment. One in a folder name kills the href.
|
||
|
||
The visible text keeps the real `#` -- it is what goes into Explorer.
|
||
"""
|
||
path = r'C:\jobs\mark#7\pairs.csv'
|
||
html = ticketpage.checklist_table_html(
|
||
[('7.1', 'x', 'B', 'WARN', _file_note(path=path))])
|
||
assert 'href="file:///C:/jobs/mark%237/pairs.csv"' in html
|
||
assert '>' + path + '</a>' in html
|
||
assert ticketpage.note_href(r'C:\a%b\c.csv') == 'file:///C:/a%25b/c.csv'
|
||
|
||
|
||
def test_note_parts_returns_three_values_either_way():
|
||
assert ticketpage.note_parts('plain') == ('plain', '', '')
|
||
assert ticketpage.note_parts(None) == ('', '', '')
|
||
head, sep, path = ticketpage.note_parts(_file_note())
|
||
assert sep == ticketpage.NOTE_FILE_SEP and path == _CSV
|
||
|
||
|
||
def test_the_text_table_wraps_a_long_note_onto_more_lines():
|
||
note = 'word ' * 60
|
||
out = ticketpage.checklist_table_text(
|
||
[('1.4', 'view names', 'B', 'WARN', note)], width=60)
|
||
lines = out.split('\n')
|
||
assert len(lines) > 3
|
||
# nothing was cut: every word survives
|
||
assert out.count('word') == 60
|
||
|
||
|
||
def test_the_text_table_prints_the_state_word_in_full():
|
||
out = ticketpage.checklist_table_text(
|
||
[('3.3', 'ratio', 'B', 'NOT RUN', None)])
|
||
assert 'NOT RUN' in out
|
||
|
||
|
||
def test_the_headline_names_the_loud_states_first():
|
||
counts = {'OK': 20, 'NOT RUN': 3, 'WARN': 2, 'FAIL': 1}
|
||
line = ticketpage.checklist_headline(counts)
|
||
assert line.index('1 FAIL') < line.index('3 NOT RUN')
|
||
assert line.index('3 NOT RUN') < line.index('2 WARN')
|
||
assert line.index('2 WARN') < line.index('20 OK')
|
||
|
||
|
||
def test_the_tally_counts_the_rows_the_reader_sees():
|
||
rows = [('1.1', 'a', 'A', 'OK', None), ('1.2', 'b', 'A', 'OK', None),
|
||
('1.3', 'c', 'A', 'NOT RUN', 'why')]
|
||
assert ticketpage.checklist_tally(rows) == {'OK': 2, 'NOT RUN': 1}
|
||
|
||
|
||
def test_the_key_line_says_what_not_run_means():
|
||
"""A red row read as "the tool is broken" gets ignored."""
|
||
key = ticketpage.checklist_key_html()
|
||
assert 'nobody checked' in key
|
||
assert 'NOT RUN' in key
|
||
|
||
|
||
# ==================================================== 5.7 bar length vs stock
|
||
#
|
||
# Owner, 2026-08-30: "rebars come in 40ft stock. unless overwritten by other
|
||
# knowledge, all rebars pieces must be less than 40ft." The number is
|
||
# published data (`catalogue.rebar.stock_length_ft`), the length is READ from
|
||
# the member.length role and never measured, and the finding is grouped by
|
||
# MARK because the live model carries 2,122 elements at or over 40 ft and a
|
||
# per-instance report would bury every other check on the ticket.
|
||
|
||
def _bar(mark, length, family='BAR_FAMILY_A'):
|
||
return {'mark': mark, 'family': family, 'length_ft': length}
|
||
|
||
|
||
def test_57_a_bar_at_or_over_stock_warns_and_names_the_mark():
|
||
findings, _u, _z, _e = ticketpage.bar_stock_length_status(
|
||
[_bar('SR1101', 60.0), _bar('SR310', 11.66)], 40.0)
|
||
assert len(findings) == 1
|
||
sev, msg = findings[0]
|
||
assert sev == 'WARN'
|
||
assert 'SR1101' in msg and '60 ft' in msg
|
||
assert '40' in msg
|
||
assert 'SR310' not in msg # the bar that is fine is not named
|
||
|
||
|
||
def test_57_exactly_at_stock_is_over():
|
||
"""40.0 ft cannot be cut from a 40 ft stick either -- >=, not >."""
|
||
findings, _u, _z, _e = ticketpage.bar_stock_length_status(
|
||
[_bar('SR900', 40.0)], 40.0)
|
||
assert findings[0][0] == 'WARN'
|
||
|
||
|
||
def test_57_groups_by_mark_and_counts_the_pieces():
|
||
"""2,122 instance findings is noise; "SR1101, 3 pieces" is a ruling."""
|
||
findings, _u, _z, _e = ticketpage.bar_stock_length_status(
|
||
[_bar('SR1101', 60.0), _bar('SR1101', 60.0), _bar('SR1101', 55.0)],
|
||
40.0)
|
||
assert len(findings) == 1
|
||
assert '1 bar mark(s)' in findings[0][1]
|
||
assert '3 piece(s)' in findings[0][1]
|
||
|
||
|
||
def test_57_everything_short_is_a_pass_that_says_how_many():
|
||
findings, _u, _z, _e = ticketpage.bar_stock_length_status(
|
||
[_bar('SR310', 11.66), _bar('SR324', 4.42)], 40.0)
|
||
assert findings[0][0] == 'OK'
|
||
assert '2 bar(s)' in findings[0][1]
|
||
|
||
|
||
def test_57_a_missing_length_is_could_not_judge_and_is_named():
|
||
"""Never silently skipped: a bar nobody measured is not a bar that passed."""
|
||
findings, unread, zero, _e = ticketpage.bar_stock_length_status(
|
||
[_bar('SR310', 11.66), _bar('SR777', None)], 40.0)
|
||
assert findings[0][0] == 'OK' # decided on the one that was read
|
||
assert unread == ['SR777']
|
||
assert zero == []
|
||
|
||
|
||
def test_57_zero_is_its_own_state_neither_a_length_nor_an_absence():
|
||
"""260 elements on the live model hold the parameter, holding 0.0."""
|
||
findings, unread, zero, _e = ticketpage.bar_stock_length_status(
|
||
[_bar('SR310', 11.66), _bar('SR9', 0.0), _bar('SR9', 0.0)], 40.0)
|
||
assert zero == ['SR9 (2)']
|
||
assert unread == []
|
||
assert 'SR9' not in findings[0][1] # not passed, and not flagged
|
||
|
||
|
||
def test_57_an_exempt_mark_is_set_aside_and_listed_not_silently_dropped():
|
||
findings, _u, _z, exempted = ticketpage.bar_stock_length_status(
|
||
[_bar('SR1101', 60.0), _bar('SR310', 11.66)], 40.0,
|
||
exempt_marks=['sr1101'])
|
||
assert findings[0][0] == 'OK' # nothing left over stock
|
||
assert exempted == ['SR1101']
|
||
|
||
|
||
def test_57_exemption_is_exact_never_a_substring():
|
||
"""`SR1` must not swallow `SR1101` -- the trap called_out_on_detail hit."""
|
||
findings, _u, _z, exempted = ticketpage.bar_stock_length_status(
|
||
[_bar('SR1101', 60.0)], 40.0, exempt_marks=['SR1'])
|
||
assert exempted == []
|
||
assert findings[0][0] == 'WARN'
|
||
|
||
|
||
def test_57_no_stock_length_decides_nothing_at_all():
|
||
"""A null key is NOT RUN through the probe, never an assumed 40."""
|
||
assert ticketpage.bar_stock_length_status(
|
||
[_bar('SR1101', 60.0)], None) == ([], [], [], [])
|
||
|
||
|
||
def test_57_an_unmarked_bar_is_named_by_its_family_not_dropped():
|
||
findings, _u, _z, _e = ticketpage.bar_stock_length_status(
|
||
[{'mark': None, 'family': 'BAR_FAMILY_B', 'length_ft': 60.0}], 40.0)
|
||
assert 'BAR_FAMILY_B' in findings[0][1]
|
||
|
||
|
||
# ============================================ 1.5 call-outs must not overlap
|
||
|
||
def _note(view, text, box, rotated=False, kind='text note'):
|
||
return {'view': view, 'text': text, 'kind': kind, 'box': box,
|
||
'rotated': rotated}
|
||
|
||
|
||
def test_15_positive_area_intersection_warns_with_the_size_and_both_texts():
|
||
findings, _u, _r = ticketpage.callout_overlap_status(
|
||
[_note('Elevation Front', 'SR310 (E.L.)', (0, 0, 1, 1)),
|
||
_note('Elevation Front', 'SR324 (F.F.)', (0.5, 0.5, 1.5, 1.5))])
|
||
sev, msg = findings[0]
|
||
assert sev == 'WARN'
|
||
assert 'SR310 (E.L.)' in msg and 'SR324 (F.F.)' in msg
|
||
assert '0.5" x 0.5"' in msg
|
||
assert 'Elevation Front' in msg
|
||
|
||
|
||
def test_15_touching_edges_are_not_an_overlap():
|
||
"""The owner's spec is POSITIVE area. Two boxes sharing a boundary print
|
||
clear of each other."""
|
||
assert ticketpage.rect_overlap((0, 0, 1, 1), (1, 1, 2, 2)) is None
|
||
assert ticketpage.rect_overlap((0, 0, 1, 1), (1, 0, 2, 1)) is None
|
||
findings, _u, _r = ticketpage.callout_overlap_status(
|
||
[_note('V', 'a', (0, 0, 1, 1)), _note('V', 'b', (1, 0, 2, 1))])
|
||
assert findings[0][0] == 'OK'
|
||
|
||
|
||
def test_15_two_views_are_never_compared_against_each_other():
|
||
"""View-space coordinates of two different views mean nothing together."""
|
||
findings, _u, _r = ticketpage.callout_overlap_status(
|
||
[_note('Elevation Front', 'a', (0, 0, 1, 1)),
|
||
_note('Elevation Top', 'b', (0, 0, 1, 1))])
|
||
assert findings[0][0] == 'OK'
|
||
|
||
|
||
def test_15_rotated_text_is_set_aside_not_flagged():
|
||
"""An axis-aligned box on rotated text overstates the ink by the length
|
||
of the string, so comparing it manufactures overlaps that do not print."""
|
||
findings, _u, rotated = ticketpage.callout_overlap_status(
|
||
[_note('V', 'straight', (0, 0, 1, 1)),
|
||
_note('V', 'turned', (0.5, 0.5, 1.5, 1.5), rotated=True)])
|
||
assert rotated == ['text note "turned"']
|
||
assert findings[0][0] == 'OK'
|
||
|
||
|
||
def test_15_an_unreadable_box_is_could_not_judge_and_is_named():
|
||
findings, unread, _r = ticketpage.callout_overlap_status(
|
||
[_note('V', 'readable', (0, 0, 1, 1)), _note('V', 'no box', None)])
|
||
assert unread == ['text note "no box"']
|
||
assert findings[0][0] == 'OK'
|
||
|
||
|
||
def test_15_a_rounding_sliver_is_not_an_overlap():
|
||
"""A thousandth of an inch is the box's own slop, not two notes touching."""
|
||
findings, _u, _r = ticketpage.callout_overlap_status(
|
||
[_note('V', 'a', (0, 0, 1, 1)), _note('V', 'b', (0.999, 0, 2, 1))])
|
||
assert findings[0][0] == 'OK'
|
||
|
||
|
||
def test_15_says_nothing_at_all_when_there_is_nothing_to_compare():
|
||
assert ticketpage.callout_overlap_status([]) == ([], [], [])
|
||
|
||
|
||
# =========================================== 5.6 bar marks unique (computed)
|
||
|
||
def test_56_one_mark_naming_two_parts_warns():
|
||
"""The expensive one: a mark is what a fabricator cuts to."""
|
||
findings, _u = ticketpage.bar_mark_uniqueness_status(
|
||
[{'text': 'SR310 (E.L.)', 'view': 'Elevation Front'}],
|
||
[{'mark': 'SR310', 'family': 'A', 'length_ft': 11.0},
|
||
{'mark': 'SR310', 'family': 'A', 'length_ft': 4.0}])
|
||
assert findings[0][0] == 'WARN'
|
||
assert 'name more than one part' in findings[0][1]
|
||
assert 'SR310' in findings[0][1]
|
||
|
||
|
||
def test_56_two_instances_of_the_same_part_are_not_two_populations():
|
||
findings, _u = ticketpage.bar_mark_uniqueness_status(
|
||
[{'text': 'SR310 (E.L.)', 'view': 'V'}],
|
||
[{'mark': 'SR310', 'family': 'A', 'length_ft': 11.0},
|
||
{'mark': 'SR310', 'family': 'A', 'length_ft': 11.0}])
|
||
assert findings[0][0] == 'OK'
|
||
|
||
|
||
def test_56_a_printed_mark_no_bar_carries_warns():
|
||
findings, _u = ticketpage.bar_mark_uniqueness_status(
|
||
[{'text': 'SR310 (E.L.)', 'view': 'V'},
|
||
{'text': 'SR999 (N.F.)', 'view': 'V'}],
|
||
[{'mark': 'SR310', 'family': 'A', 'length_ft': 11.0}])
|
||
warns = [m for (s, m) in findings if s == 'WARN']
|
||
assert any('match no bar in the model' in m and 'SR999' in m
|
||
for m in warns)
|
||
|
||
|
||
def test_56_a_modelled_bar_never_printed_warns():
|
||
findings, _u = ticketpage.bar_mark_uniqueness_status(
|
||
[{'text': 'SR310 (E.L.)', 'view': 'V'}],
|
||
[{'mark': 'SR310', 'family': 'A', 'length_ft': 11.0},
|
||
{'mark': 'SR777', 'family': 'A', 'length_ft': 3.0}])
|
||
warns = [m for (s, m) in findings if s == 'WARN']
|
||
assert any('never printed' in m and 'SR777' in m for m in warns)
|
||
|
||
|
||
def test_56_the_printed_string_contains_the_mark_it_never_equals_it():
|
||
"""Measured on the live model: `SR310 (E.L.)`, not `SR310`. A check that
|
||
compared strings for equality would call every printed mark an orphan."""
|
||
findings, _u = ticketpage.bar_mark_uniqueness_status(
|
||
[{'text': 'SR324 (F.F.)', 'view': 'V'}],
|
||
[{'mark': 'SR324', 'family': 'A', 'length_ft': 9.0}])
|
||
assert findings[0][0] == 'OK'
|
||
|
||
|
||
def test_56_a_bar_with_no_mark_is_could_not_judge_never_a_missing_print():
|
||
"""An absent mark cannot be looked for in the printed text."""
|
||
findings, unread = ticketpage.bar_mark_uniqueness_status(
|
||
[{'text': 'SR310 (E.L.)', 'view': 'V'}],
|
||
[{'mark': 'SR310', 'family': 'A', 'length_ft': 11.0},
|
||
{'mark': None, 'family': 'BAR_FAMILY_B', 'length_ft': 2.0}])
|
||
assert unread == ['BAR_FAMILY_B']
|
||
assert findings[0][0] == 'OK'
|
||
|
||
|
||
def test_56_mark_tokens_reads_the_shapes_the_plant_actually_prints():
|
||
assert ticketpage.mark_tokens('SR310 (E.L.)') == ['SR310']
|
||
assert ticketpage.mark_tokens('P76 W/ SR5602 (S.I.F.)') == ['P76', 'SR5602']
|
||
assert ticketpage.mark_tokens('P106 (S.I.F.)(E.S.)') == ['P106']
|
||
assert ticketpage.mark_tokens('') == []
|
||
assert ticketpage.mark_tokens(None) == []
|
||
|
||
|
||
def test_56_a_mark_token_is_whole_never_a_prefix_of_a_longer_one():
|
||
"""`SR1` inside `SR1101` must not become a mark of its own."""
|
||
assert ticketpage.mark_tokens('SR1101 (N.F.)') == ['SR1101']
|
||
|
||
|
||
# ------------------------------------------ the split of 5.5, in the table
|
||
|
||
def test_the_55_split_left_legibility_with_a_person_and_moved_uniqueness():
|
||
rows = dict((i[0], i) for i in ticketpage.CHECKLIST_ITEMS)
|
||
assert rows['5.5'][1] == 'Bar marks legible on the drawing'
|
||
assert rows['5.5'][2] == 'D'
|
||
assert rows['5.5'][5] is None # no check: a person reads it
|
||
assert '5.5' in ticketpage.CHECKLIST_FOR_ENGINEER
|
||
assert 'unique' in rows['5.6'][1]
|
||
assert rows['5.6'][5] == 'check_bar_mark_uniqueness'
|
||
assert '5.6' not in ticketpage.CHECKLIST_FOR_ENGINEER
|
||
|
||
|
||
def test_the_three_new_rows_are_owned_by_the_codes_their_checks_emit():
|
||
owners = dict((i[0], i[3]) for i in ticketpage.CHECKLIST_ITEMS)
|
||
assert owners['1.5'] == ('CALLOUT-OVERLAP',)
|
||
assert owners['5.6'] == ('BARMARK-UNIQUE',)
|
||
assert owners['5.7'] == ('BAR-LENGTH',)
|
||
|
||
|
||
def test_the_could_not_judge_codes_can_never_decide_a_row():
|
||
"""BAR-LENGTH-UNREAD and friends are OK-severity and UNOWNED on purpose:
|
||
they must be visible without masquerading as a verdict."""
|
||
every_owner = set()
|
||
for item in ticketpage.CHECKLIST_ITEMS:
|
||
every_owner.update(item[3] or ())
|
||
for code in ('BAR-LENGTH-UNREAD', 'BARMARK-UNREAD',
|
||
'CALLOUT-OVERLAP-UNREAD'):
|
||
assert code not in every_owner
|
||
|
||
|
||
# ============================================== 4.3 jacking force, in units
|
||
#
|
||
# `tolerances.it_lb` was declared, documented and offered in the config form
|
||
# -- and read by nothing, while the check compared kips against a hardcoded
|
||
# `0.1`. Wiring it is a units question before it is anything else: the key
|
||
# holds POUNDS, the ticket prints KIPS, and a factor of 1000 dropped here is
|
||
# a check a thousand times stricter that fails on correct tickets.
|
||
#
|
||
# The owner's ruling (2026-08-30) was 100 lb -- the same 0.1 kips the literal
|
||
# used -- so the point of the change is the knob, not the number.
|
||
|
||
def _old_verdict(printed_kips, design_it_lb):
|
||
"""The comparison exactly as the deleted literal did it."""
|
||
return abs(printed_kips - design_it_lb / 1000.0) > 0.1
|
||
|
||
|
||
def test_43_at_100_lb_every_verdict_is_the_one_the_old_literal_gave():
|
||
"""The behaviour did not move. 100 lb IS 0.1 kips, across the whole band
|
||
either side of the boundary."""
|
||
for design_lb in (28920.0, 31000.0, 20250.0):
|
||
for delta_lb in (0, 1, 50, 99, 101, 150, 900, 5000):
|
||
for sign in (1, -1):
|
||
printed_kips = (design_lb + sign * delta_lb) / 1000.0
|
||
sev, _msg = ticketpage.jacking_force_status(
|
||
printed_kips, design_lb, 100.0)
|
||
was_warn = _old_verdict(printed_kips, design_lb)
|
||
assert (sev == 'WARN') == was_warn, (design_lb, delta_lb, sign)
|
||
|
||
|
||
def test_43_the_one_case_that_did_move_is_exactly_on_the_boundary():
|
||
"""EXACTLY 100 lb apart, and only there. `28.82 - 28.92` in binary
|
||
floats is 0.10000000000000142, so the old kips comparison called a gap
|
||
of exactly the tolerance a WARN on some values and an OK on others,
|
||
depending on the numbers. Working in whole pounds removes the rounding
|
||
and the boundary is now inclusive everywhere, which is what `within
|
||
tolerance` has always meant. Said out loud rather than left as a
|
||
surprise: it is the only difference between the two."""
|
||
# the same gap of exactly 100 lb, judged two different ways
|
||
assert _old_verdict(28.82, 28920.0) is True # 100 lb low -> WARN
|
||
assert _old_verdict(29.02, 28920.0) is False # 100 lb high -> OK
|
||
for design_lb in (28920.0, 31000.0, 20250.0):
|
||
for sign in (1, -1):
|
||
printed_kips = (design_lb + sign * 100) / 1000.0
|
||
sev, _msg = ticketpage.jacking_force_status(
|
||
printed_kips, design_lb, 100.0)
|
||
assert sev == 'OK', (design_lb, sign)
|
||
|
||
|
||
def test_43_the_real_ticket_still_passes():
|
||
"""The measured case: the note prints 28.9 kips, the design carries
|
||
28,920 lb. 20 lb apart, and it read OK before the key was wired."""
|
||
sev, msg = ticketpage.jacking_force_status(28.9, 28920, 100.0)
|
||
assert sev == 'OK'
|
||
assert '28.9 kips' in msg
|
||
# the sentence names BOTH units and the key, so nobody has to guess
|
||
# which of the two numbers the tolerance is in
|
||
assert '20 lb apart' in msg
|
||
assert '100 lb' in msg
|
||
assert 'tolerances.it_lb' in msg
|
||
|
||
|
||
def test_43_the_tolerance_is_pounds_and_the_print_is_kips():
|
||
"""A 1 lb tolerance -- what the key DECLARED while nothing read it --
|
||
is a hundred times stricter, and that is the whole reason the value
|
||
went to the owner instead of the key being wired as written."""
|
||
assert ticketpage.jacking_force_status(28.9, 28920, 100.0)[0] == 'OK'
|
||
assert ticketpage.jacking_force_status(28.9, 28920, 1.0)[0] == 'WARN'
|
||
# and a plant that wants it looser can have that too: the knob turns
|
||
# both ways, which is what wiring the key bought
|
||
assert ticketpage.jacking_force_status(28.4, 28920, 100.0)[0] == 'WARN'
|
||
assert ticketpage.jacking_force_status(28.4, 28920, 600.0)[0] == 'OK'
|
||
|
||
|
||
def test_43_a_value_that_is_not_a_number_is_not_run_and_never_a_pass():
|
||
for args in ((None, 28920, 100.0), ('n/a', 28920, 100.0),
|
||
(28.9, 'lots', 100.0), (28.9, 28920, 'tight')):
|
||
sev, msg = ticketpage.jacking_force_status(*args)
|
||
assert sev == 'NOT RUN', args
|
||
assert msg
|
||
|
||
|
||
# ========================== 5.1's detail-sheet half, and what it cannot do
|
||
#
|
||
# `nomenclature.detail_sheet_prefix` -- the plant naming the series its
|
||
# detail sheets are numbered from -- was declared and read by nothing,
|
||
# because 5.1's detail-sheet half was never built. The owner ruled: read it.
|
||
#
|
||
# What it buys is the ability to recognise a detail BUBBLE (a detail number
|
||
# over a sheet name) on the BED PLAN and tell it from a part call-out. What
|
||
# it cannot buy is the CONTENTS of that sheet, which nothing here opens --
|
||
# so a plan that cites a detail leaves 5.1 unanswered, out loud.
|
||
|
||
def test_51_a_bubble_needs_the_plants_prefix_to_be_recognised_at_all():
|
||
"""No prefix is could-not-judge (None), not 'no details' ([])."""
|
||
assert ticketpage.detail_sheet_refs('06/X1', None) is None
|
||
assert ticketpage.detail_sheet_refs('06/X1', '') is None
|
||
assert ticketpage.detail_sheet_refs('06/X1', 'X') == ['06/X1']
|
||
|
||
|
||
def test_51_a_bubble_is_found_and_a_part_callout_is_not():
|
||
# the dotted sheet name is deliberate: a plant numbers its detail
|
||
# series how it likes, and the prefix is all this may assume
|
||
text = 'P76 W/ SR5602 (S.I.F.)\n06/X2.1\nSP126 (S.I.F.)\n12 / X1'
|
||
assert ticketpage.detail_sheet_refs(text, 'X') == ['06/X2.1', '12/X1']
|
||
# a bare part call-out is never mistaken for a bubble
|
||
assert ticketpage.detail_sheet_refs(
|
||
'P106 (S.I.F.)(E.S.)\nSR309 (E.L.)', 'X') == []
|
||
assert ticketpage.detail_sheets_of(['06/X2.1', '12/X1', '07/X2.1']) == \
|
||
['X1', 'X2.1']
|
||
|
||
|
||
def test_51_a_plan_citing_no_detail_is_a_verdict_not_a_silence():
|
||
"""The one pass this half has, and it is a real one: nothing on the
|
||
piece reaches the model through a detail sheet."""
|
||
findings, reason = ticketpage.detail_callout_status(
|
||
'P106 (S.I.F.)\nSR309 (E.L.)', 'X')
|
||
assert reason is None
|
||
assert [f[0] for f in findings] == ['OK']
|
||
assert 'no X-sheet detail' in findings[0][1]
|
||
|
||
|
||
def test_51_a_cited_detail_is_not_run_and_names_the_sheet():
|
||
findings, reason = ticketpage.detail_callout_status(
|
||
'P106 (S.I.F.)\n06/X1', 'X')
|
||
assert findings == []
|
||
assert reason
|
||
assert '06/X1' in reason and 'X1' in reason
|
||
assert 'nothing reads' in reason
|
||
|
||
|
||
def test_51_an_undeclared_prefix_names_the_key_it_is_missing():
|
||
findings, reason = ticketpage.detail_callout_status('06/X1', None)
|
||
assert findings == []
|
||
assert 'nomenclature.detail_sheet_prefix' in reason
|
||
|
||
|
||
def test_51_an_unreadable_bed_plan_is_its_own_reason():
|
||
findings, reason = ticketpage.detail_callout_status(None, 'X')
|
||
assert findings == []
|
||
assert 'BED PLAN could not be read' in reason
|
||
|
||
|
||
# ------------------------- a row that names two checks is answered by both
|
||
|
||
def test_51_names_both_halves_and_both_codes():
|
||
rows = dict((i[0], i) for i in ticketpage.CHECKLIST_ITEMS)
|
||
assert rows['5.1'][3] == ('BOM-MEMBERS', 'DETAIL-CALLOUT')
|
||
assert rows['5.1'][5] == ('check_bom_members', 'check_detail_callouts')
|
||
|
||
|
||
def test_a_row_with_two_checks_is_ok_only_when_both_of_them_ran():
|
||
log = [('check_bom_members', 'RAN', None),
|
||
('check_detail_callouts', 'RAN', None)]
|
||
rows = dict((r[0], r) for r in ticketpage.checklist_states([], log))
|
||
assert rows['5.1'][3] == 'OK'
|
||
|
||
|
||
def test_a_quiet_half_makes_the_row_not_run_however_green_the_other_is():
|
||
"""The silent pass this table exists to prevent: 5.1's BOM half passing
|
||
while the half that could still fail it was never asked."""
|
||
log = [('check_bom_members', 'RAN', None),
|
||
('check_detail_callouts', 'SKIPPED',
|
||
'the BED PLAN reaches 1 part(s) through a detail bubble')]
|
||
findings = [_F('OK', 'BOM-MEMBERS')]
|
||
rows = dict((r[0], r) for r in ticketpage.checklist_states(findings, log))
|
||
assert rows['5.1'][3] == 'NOT RUN'
|
||
assert 'detail bubble' in rows['5.1'][4]
|
||
assert ticketpage.state_color(rows['5.1'][3]) == 'red'
|
||
|
||
|
||
def test_both_halves_quiet_reports_both_reasons_and_cuts_neither():
|
||
log = [('check_bom_members', 'SKIPPED', 'this ticket has no assembly'),
|
||
('check_detail_callouts', 'SKIPPED', 'no detail_sheet_prefix')]
|
||
rows = dict((r[0], r) for r in ticketpage.checklist_states([], log))
|
||
assert rows['5.1'][3] == 'NOT RUN'
|
||
assert 'no assembly' in rows['5.1'][4]
|
||
assert 'no detail_sheet_prefix' in rows['5.1'][4]
|
||
|
||
|
||
def test_a_check_that_never_ran_at_all_is_not_a_pass_either():
|
||
"""Absent from the log is not RAN. A row whose second half never ran
|
||
must not read OK off the first half."""
|
||
log = [('check_bom_members', 'RAN', None)]
|
||
rows = dict((r[0], r) for r in ticketpage.checklist_states([], log))
|
||
assert rows['5.1'][3] == 'NOT RUN'
|
||
|
||
|
||
def test_the_one_check_rows_are_untouched_by_the_tuple_support():
|
||
log = [('check_lifter_capacity', 'RAN', None)]
|
||
rows = dict((r[0], r) for r in ticketpage.checklist_states([], log))
|
||
assert rows['6.1'][3] == 'OK'
|
||
log = [('check_lifter_capacity', 'SKIPPED', 'no lifters modelled')]
|
||
rows = dict((r[0], r) for r in ticketpage.checklist_states([], log))
|
||
assert rows['6.1'][3] == 'NOT RUN'
|
||
assert rows['6.1'][4] == 'no lifters modelled'
|
||
|
||
|
||
# ============================================ 6.6: parity and a bed note
|
||
#
|
||
# Owner's ruling, 2026-08-31: when the count of one part on a piece is ODD,
|
||
# the ticket's BED PLAN must carry a particular phrase.
|
||
#
|
||
# THE PART NUMBER BELOW IS INVENTED, and that is the point: a test that
|
||
# asserted on the plant's real part would pin the plant's rule into the
|
||
# repository from the test side, which is the same defect as writing it
|
||
# into the check. Every assertion here is about the SHAPE of the answer,
|
||
# and not one of them would change if the plant changed its hardware.
|
||
#
|
||
# The WORDING is the real one, deliberately, and it is doing a job: the
|
||
# sibling guard in `test_ticketchecks_host.py` asserts that this exact
|
||
# string appears in neither module, so the day somebody hardcodes it the
|
||
# suite says so. It identifies nobody -- it is an English drafting note.
|
||
|
||
PART = ('ACME_PART_9',)
|
||
PHRASE = 'FOR ERECTION ONLY'
|
||
|
||
|
||
def _parity(count, unknown=None, bed_text=None, parity='odd',
|
||
phrases=(PHRASE,), names=PART, verified=True, **kw):
|
||
return ticketpage.parity_note_status(
|
||
count, list(unknown or []), bed_text, list(names), parity,
|
||
list(phrases), verified=verified, **kw)
|
||
|
||
|
||
def test_66_odd_with_the_note_is_ok():
|
||
findings, reason = _parity(3, bed_text='NOTES: FOR ERECTION ONLY.')
|
||
assert reason is None
|
||
assert [f[0] for f in findings] == ['OK']
|
||
assert 'odd' in findings[0][1]
|
||
|
||
|
||
def test_66_odd_without_the_note_is_the_defect():
|
||
findings, reason = _parity(3, bed_text='LIFT WITH CARE')
|
||
assert reason is None
|
||
assert findings[0][0] == 'FAIL'
|
||
assert 'must carry' in findings[0][1]
|
||
|
||
|
||
def test_66_even_is_ok_and_says_the_rule_does_not_apply():
|
||
"""The BED PLAN is not even read: an even count answers the question
|
||
on its own, and demanding a readable plan here would print NOT RUN for
|
||
a piece that was fully judged."""
|
||
findings, reason = _parity(4, bed_text=None)
|
||
assert reason is None
|
||
assert findings[0][0] == 'OK'
|
||
assert 'does not apply' in findings[0][1]
|
||
|
||
|
||
def test_66_zero_is_a_judged_ok_and_not_a_could_not_judge():
|
||
"""Zero is even, so the rule does not apply -- a real answer. The
|
||
member total is printed with it so a family name that matches nothing
|
||
is visible rather than silently even."""
|
||
findings, reason = _parity(0, bed_text=None, total=112)
|
||
assert reason is None
|
||
assert findings[0][0] == 'OK'
|
||
assert '112' in findings[0][1]
|
||
assert 'Judged' in findings[0][1]
|
||
|
||
|
||
def test_66_zero_and_unrecognised_do_not_collapse_into_each_other():
|
||
zero, _ = _parity(0, total=112)
|
||
silent, reason = _parity(0, unknown=[{'family': '', 'why': 'no name'}],
|
||
total=112)
|
||
assert zero and zero[0][0] == 'OK'
|
||
assert silent == [] and reason
|
||
|
||
|
||
def test_66_one_unread_member_stands_the_whole_count_down():
|
||
"""One member flips an odd count to even, so a partial census is not a
|
||
slightly worse answer, it is the opposite answer half the time."""
|
||
findings, reason = _parity(
|
||
3, unknown=[{'family': '', 'why': 'a member carries no family name'}],
|
||
bed_text=PHRASE)
|
||
assert findings == []
|
||
assert 'not trustworthy' in reason
|
||
assert 'no family name' in reason
|
||
|
||
|
||
def test_66_unreadable_bed_plan_is_not_run_with_its_own_reason():
|
||
findings, reason = _parity(3, bed_text=None,
|
||
bed_reason='no BED PLAN view on this ticket')
|
||
assert findings == []
|
||
assert reason == 'no BED PLAN view on this ticket'
|
||
|
||
|
||
def test_66_no_phrase_declared_is_not_run_naming_the_key():
|
||
findings, reason = _parity(3, phrases=(), bed_text=PHRASE)
|
||
assert findings == []
|
||
assert 'require_text' in reason
|
||
|
||
|
||
def test_66_no_part_declared_is_not_run_naming_the_key():
|
||
findings, reason = _parity(3, names=(), bed_text=PHRASE)
|
||
assert findings == []
|
||
assert 'family_names' in reason
|
||
|
||
|
||
def test_66_no_parity_declared_is_not_run_naming_the_key():
|
||
findings, reason = _parity(3, parity=None, bed_text=PHRASE)
|
||
assert findings == []
|
||
assert 'parity' in reason
|
||
|
||
|
||
def test_66_an_unverified_family_name_stands_down_and_quotes_the_reason():
|
||
"""The guard `reinforcing_rule.parameter_verified` carries, for the same
|
||
reason: a family name nobody has confirmed matches nothing, counts zero
|
||
on every piece, and zero is even -- a permanent invisible OK."""
|
||
findings, reason = _parity(3, verified=False, bed_text=PHRASE,
|
||
unverified_because='nobody has read it live')
|
||
assert findings == []
|
||
assert 'family_verified' in reason
|
||
assert 'nobody has read it live' in reason
|
||
|
||
|
||
def test_66_an_unmakeable_census_reports_its_own_reason():
|
||
findings, reason = _parity(None, gather_reason='no assembly on this '
|
||
'ticket', bed_text=PHRASE)
|
||
assert findings == []
|
||
assert reason == 'no assembly on this ticket'
|
||
|
||
|
||
def test_66_a_count_of_none_with_no_reason_is_never_a_pass():
|
||
findings, reason = _parity(None, bed_text=PHRASE)
|
||
assert findings == []
|
||
assert 'not run' in reason
|
||
|
||
|
||
def test_66_phrase_match_is_case_insensitive_and_survives_line_breaks():
|
||
for text in ('for erection only', 'FOR\nERECTION\nONLY',
|
||
'For Erection Only', 'NOTE 4: (FOR ERECTION ONLY)'):
|
||
findings, reason = _parity(1, bed_text=text)
|
||
assert reason is None
|
||
assert findings[0][0] == 'OK', text
|
||
|
||
|
||
def test_66_an_empty_bed_plan_is_a_fail_not_a_silence():
|
||
"""A view that was READ and holds no text does not carry the phrase.
|
||
That is a verdict about the ticket; only an unreadable view is silence."""
|
||
findings, reason = _parity(1, bed_text='')
|
||
assert reason is None
|
||
assert findings[0][0] == 'FAIL'
|
||
|
||
|
||
def test_66_the_even_plant_is_a_plant_and_not_a_bug():
|
||
"""Which parity demands the note is the plant's, so the mirror rule has
|
||
to work: an even count without the note fails, an odd one passes."""
|
||
findings, _ = _parity(4, parity='even', bed_text='NOTHING HERE')
|
||
assert findings[0][0] == 'FAIL'
|
||
findings, _ = _parity(3, parity='even', bed_text=None)
|
||
assert findings[0][0] == 'OK'
|
||
|
||
|
||
def test_66_a_present_phrase_never_claims_the_note_is_about_this_part():
|
||
findings, _ = _parity(1, bed_text=PHRASE)
|
||
assert 'not claimed' in findings[0][1]
|
||
|
||
|
||
def test_66_several_acceptable_wordings_are_allowed():
|
||
findings, _ = _parity(1, phrases=('ERECTION USE ONLY', PHRASE),
|
||
bed_text='... FOR ERECTION ONLY ...')
|
||
assert findings[0][0] == 'OK'
|
||
|
||
|
||
def test_66_squash_phrase_keeps_none_distinct_from_empty():
|
||
assert ticketpage.squash_phrase(None) is None
|
||
assert ticketpage.squash_phrase(' ') == ''
|
||
|
||
|
||
def test_66_row_exists_and_names_its_check_and_its_code():
|
||
row = dict((r[0], r) for r in ticketpage.CHECKLIST_ITEMS)['6.6']
|
||
assert row[3] == ('PARITY-NOTE',)
|
||
assert row[5] == 'check_parity_note'
|
||
assert '6.6' not in ticketpage.CHECKLIST_FOR_ENGINEER
|
||
|
||
|
||
def test_66_a_skipped_check_makes_the_row_not_run_with_the_reason():
|
||
log = [('check_parity_note', 'SKIPPED', 'no family_names declared')]
|
||
rows = dict((r[0], r) for r in ticketpage.checklist_states([], log))
|
||
assert rows['6.6'][3] == 'NOT RUN'
|
||
assert rows['6.6'][4] == 'no family_names declared'
|
||
assert ticketpage.state_color(rows['6.6'][3]) == 'red'
|
||
|
||
|
||
def test_66_a_deferred_bed_plan_reader_is_called_only_when_needed():
|
||
"""The BED PLAN may arrive as a callable returning (text, reason).
|
||
Walking every sheet's placed views is not free, and an even count never
|
||
needs it -- so the gate order has to be observable, not just intended."""
|
||
calls = []
|
||
|
||
def reader():
|
||
calls.append(True)
|
||
return 'NOTE: FOR ERECTION ONLY', None
|
||
|
||
findings, reason = _parity(4, bed_text=reader)
|
||
assert findings[0][0] == 'OK' and reason is None
|
||
assert calls == []
|
||
|
||
findings, reason = _parity(3, bed_text=reader)
|
||
assert findings[0][0] == 'OK' and reason is None
|
||
assert calls == [True]
|
||
|
||
|
||
def test_66_a_deferred_reader_may_hand_back_the_reason_it_found():
|
||
def reader():
|
||
return None, 'no view of that name is placed on this ticket'
|
||
|
||
findings, reason = _parity(3, bed_text=reader)
|
||
assert findings == []
|
||
assert reason == 'no view of that name is placed on this ticket'
|
||
|
||
|
||
# ============================================================================
|
||
# THE FIFTH STATE: a question that is not about this kind of piece
|
||
# ============================================================================
|
||
|
||
|
||
def _off(**over):
|
||
entry = {'not_applicable': True, 'condition': 'insulated',
|
||
'reason': 'these have no release requirement',
|
||
'since': '2026-08-31', 'declared_by': 'Will',
|
||
'layer': 'plant', 'unaskable': False}
|
||
entry.update(over)
|
||
return entry
|
||
|
||
|
||
def test_not_applicable_has_its_own_colour_and_not_a_reused_one():
|
||
"""A new state needs its own entry, never a reuse.
|
||
|
||
Grey already means "it ran and this job asked for it quietly". A reader
|
||
who has to work out which kind of quiet a grey row is has stopped
|
||
trusting the table, which is the whole asset here.
|
||
"""
|
||
colour = ticketpage.state_color(ticketpage.NOT_APPLICABLE_STATE)
|
||
assert colour == 'blue'
|
||
for other in ('OK', 'WARN', 'FOR ENGINEER', 'FAIL', 'NOT RUN',
|
||
ticketpage.MUTED_STATE):
|
||
assert colour != ticketpage.state_color(other), other
|
||
assert colour in ticketpage.STATE_COLORS
|
||
|
||
|
||
def test_the_existing_colours_are_untouched_by_the_new_one():
|
||
assert ticketpage.state_color('OK') == 'green'
|
||
assert ticketpage.state_color('WARN') == 'yellow'
|
||
assert ticketpage.state_color('FOR ENGINEER') == 'yellow'
|
||
assert ticketpage.state_color('NOT RUN') == 'red'
|
||
assert ticketpage.state_color('FAIL') == 'red'
|
||
assert ticketpage.state_color(ticketpage.MUTED_STATE) == 'grey'
|
||
assert ticketpage.state_color('WHATEVER') == 'red'
|
||
|
||
|
||
def test_not_applicable_is_not_judged_and_has_its_own_machine_key():
|
||
assert ticketpage.state_key(ticketpage.NOT_APPLICABLE_STATE) == \
|
||
'not_applicable'
|
||
assert ticketpage.state_is_judged(ticketpage.NOT_APPLICABLE_STATE) is False
|
||
keys = [k for (_s, k, _j) in ticketpage.STATE_VOCABULARY]
|
||
assert len(keys) == len(set(keys))
|
||
|
||
|
||
def test_a_switched_off_row_is_repainted_and_keeps_the_declaration():
|
||
rows = [('3.3', "f'ci/f'c release ratio in band", 'B', 'NOT RUN', 'why')]
|
||
kept, notes = ticketpage.apply_applicability(rows, {'3.3': _off()})
|
||
assert kept[0][3] == ticketpage.NOT_APPLICABLE_STATE
|
||
note = kept[0][4]
|
||
assert 'these have no release requirement' in note
|
||
assert '2026-08-31' in note
|
||
assert 'insulated' in note
|
||
assert 'plant' in note
|
||
assert notes[0][0] == '3.3'
|
||
|
||
|
||
def test_a_row_that_failed_is_never_switched_off():
|
||
"""The honesty invariant, in the one place it can actually bite.
|
||
|
||
Two rows can name one check (4.4 and 4.6 both name check_strand_layout),
|
||
so a check kept alive for the row that still applies can put a finding on
|
||
the row that does not. Silencing a FAIL that already exists is the one
|
||
thing no switch in this module may do.
|
||
"""
|
||
rows = [('4.4', 'Strand positions', 'B', 'FAIL', 'off by 3 in')]
|
||
kept, notes = ticketpage.apply_applicability(rows, {'4.4': _off()})
|
||
assert kept[0][3] == 'FAIL'
|
||
assert kept[0][4] == 'off by 3 in'
|
||
assert 'DECLINED' in notes[0][7]
|
||
|
||
|
||
def test_a_row_with_no_declaration_is_untouched():
|
||
rows = [('3.1', "f'c", 'A', 'OK', None)]
|
||
kept, notes = ticketpage.apply_applicability(rows, {})
|
||
assert kept == rows
|
||
assert notes == []
|
||
|
||
|
||
def test_a_scoped_row_on_an_unclassifiable_piece_is_red_not_blue():
|
||
"""NOT RUN, never NOT APPLICABLE and never OK.
|
||
|
||
Blue would claim the piece is not that type -- exactly what could not be
|
||
established. Red says nobody could ask it, which is true, and it is cured
|
||
by making the classification work rather than by anybody's ruling.
|
||
"""
|
||
rows = [('6.6', 'Odd part count', 'B', 'OK', None)]
|
||
kept, _notes = ticketpage.apply_applicability(rows, {'6.6': {
|
||
'not_applicable': False, 'unaskable': True,
|
||
'reason': 'this row is about insulated pieces only and this piece '
|
||
'could not be classified. It was NOT ASKED.'}})
|
||
assert kept[0][3] == 'NOT RUN'
|
||
assert ticketpage.state_color(kept[0][3]) == 'red'
|
||
assert 'NOT ASKED' in kept[0][4]
|
||
|
||
|
||
def test_muting_never_repaints_a_row_that_is_already_not_applicable():
|
||
"""Two mechanisms doing one job, said out loud rather than layered.
|
||
|
||
Grey would replace "this question is not about this piece" with "this job
|
||
asked for it quietly", which is a weaker and different claim.
|
||
"""
|
||
rows = [('3.3', 'release ratio', 'B',
|
||
ticketpage.NOT_APPLICABLE_STATE, 'declared')]
|
||
kept, notes = ticketpage.apply_muting(rows, {'3.3': 'muted too'})
|
||
assert kept[0][3] == ticketpage.NOT_APPLICABLE_STATE
|
||
assert 'THE MUTE DID NOTHING' in notes[0][3]
|
||
assert ticketpage.muting_problem(notes)
|
||
|
||
|
||
def test_the_headline_gives_the_new_state_its_own_bucket():
|
||
rows = [('3.1', 'a', 'A', 'OK', None),
|
||
('3.3', 'b', 'B', ticketpage.NOT_APPLICABLE_STATE, 'declared')]
|
||
counts = ticketpage.checklist_tally(rows)
|
||
assert counts[ticketpage.NOT_APPLICABLE_STATE] == 1
|
||
headline = ticketpage.checklist_headline(counts)
|
||
assert '1 ' + ticketpage.NOT_APPLICABLE_STATE in headline
|
||
assert '1 OK' in headline
|
||
assert headline.index('OK') < headline.index(
|
||
ticketpage.NOT_APPLICABLE_STATE)
|
||
|
||
|
||
def test_the_colour_key_explains_the_new_state_on_the_page():
|
||
key = ticketpage.checklist_key_html()
|
||
assert ticketpage.NOT_APPLICABLE_STATE in key
|
||
assert 'not run' in key
|
||
assert ticketpage.STATE_COLORS['blue'][0] in key
|
||
|
||
|
||
def test_the_section_opens_itself_whenever_anything_was_switched_off():
|
||
"""Unlike muting, which folds when it is tidy.
|
||
|
||
A mute leaves the check running; this stops it. A mistaken rule here is a
|
||
safety check disabled in silence, so the page says so every run.
|
||
"""
|
||
notes = [('3.3', 'release ratio', 'why', 'insulated', '2026-08-31',
|
||
'Will', 'plant', None)]
|
||
assert ticketpage.applicability_problem(notes)
|
||
assert ticketpage.applicability_problem([], []) is None
|
||
assert 'REFUSED' in ticketpage.applicability_problem(
|
||
[], [('3.3', 'the entry has no date')])
|
||
|
||
|
||
def test_every_switched_off_line_names_the_layer_that_declared_it():
|
||
notes = [('3.3', 'release ratio', 'why', 'insulated', '2026-08-31',
|
||
'Will', 'plant', None)]
|
||
line = ticketpage.applicability_lines(notes)[0]
|
||
assert 'plant' in line
|
||
assert 'why' in line
|
||
unknown = ticketpage.applicability_lines(
|
||
[('3.3', 'r', 'why', 'any', '2026-08-31', None, None, None)])[0]
|
||
assert 'UNKNOWN' in unknown
|
||
|
||
|
||
def test_the_profile_line_names_the_classification_and_the_rows():
|
||
line = ticketpage.classification_line(
|
||
'PNL101', {'panel_type': 'insulated', 'verified': True,
|
||
'why': 'two signals agree'},
|
||
[('3.3', 'release ratio', 'why', 'insulated', '2026-08-31', 'Will',
|
||
'plant', None)])
|
||
assert 'PNL101' in line and 'insulated' in line
|
||
assert '3.3' in line
|
||
assert 'two signals agree' in line
|
||
|
||
|
||
def test_the_profile_line_says_when_a_classification_was_not_used():
|
||
line = ticketpage.classification_line(
|
||
'PNL101', {'panel_type': 'insulated', 'verified': False,
|
||
'why': 'two signals agree'}, [])
|
||
assert 'detect_verified' in line
|
||
assert 'no row was declared not applicable' in line
|
||
|
||
|
||
def test_the_profile_line_says_the_full_checklist_ran_when_unclassified():
|
||
line = ticketpage.classification_line(
|
||
'PNL101', {'panel_type': None, 'verified': True, 'why': 'nothing'}, [])
|
||
assert 'NOT CLASSIFIED' in line
|
||
assert 'FULL checklist' in line
|
||
|
||
|
||
def test_applicability_decisions_covers_every_row_and_never_a_subset():
|
||
"""An absence is not a value here either. A caller reading this as "the
|
||
rows in here are the switched-off ones" would be inferring from a gap."""
|
||
calls = []
|
||
|
||
def _resolver(know, item, panel_type=None, verified=False):
|
||
calls.append(item)
|
||
return {'not_applicable': False}
|
||
|
||
got = ticketpage.applicability_decisions({}, resolver=_resolver)
|
||
assert sorted(got) == sorted([i[0] for i in ticketpage.CHECKLIST_ITEMS])
|
||
assert len(calls) == len(ticketpage.CHECKLIST_ITEMS)
|
||
assert ticketpage.switched_off(got) == []
|
||
|
||
|
||
# ============================================================================
|
||
# 5.6: A BAR THAT SHIPS INSIDE ANOTHER PART, AND A PART THAT IS NOT A BAR
|
||
# ============================================================================
|
||
#
|
||
# Owner, 2026-08-31: "SR4996 是 SP29/30 的附属 rebar,我记得我增加过这条知识了,
|
||
# 为什么还在 WP117 报 warn?" He had recorded it -- three times over -- and this
|
||
# check read none of it. The fixture below is the WP115/WP117 shape.
|
||
|
||
WP115_PRINTED = [
|
||
{'text': 'P78 W/ SR5502 (S.I.F.) (E.S.)', 'view': 'Detail 1'},
|
||
{'text': 'P114 (F.F.)', 'view': 'Detail 1'},
|
||
{'text': 'SP29 (N.F.)', 'view': 'Detail 1'},
|
||
{'text': 'SP30 (N.F.)', 'view': 'Detail 1'},
|
||
{'text': 'SP31 (N.F.)', 'view': 'Detail 1'},
|
||
{'text': 'SR310 (E.L.)', 'view': 'Detail 2'},
|
||
]
|
||
|
||
WP115_BARS = [
|
||
{'mark': 'SR310', 'family': 'REBAR', 'length_ft': 8.0},
|
||
{'mark': 'SR5502', 'family': 'REBAR', 'length_ft': 4.0},
|
||
{'mark': 'SR4996', 'family': 'REBAR', 'length_ft': 1.5},
|
||
]
|
||
|
||
WP115_ATTACHED = {'SR4996': ['SP29', 'SP30']}
|
||
|
||
|
||
def _grammar(**kinds):
|
||
"""A `classify` callable over a tiny declared grammar."""
|
||
def classify(token):
|
||
for prefix in sorted(kinds, key=len, reverse=True):
|
||
if token.upper().startswith(prefix):
|
||
return kinds[prefix], 'declared {0}'.format(prefix)
|
||
return 'unknown', 'no declared prefix matches {0}'.format(token)
|
||
return classify
|
||
|
||
|
||
WP115_GRAMMAR = _grammar(P='hardware', SP='hardware', SR='bar_mark')
|
||
|
||
|
||
def test_a_covered_bar_whose_parent_is_printed_is_accounted_for():
|
||
"""The owner's complaint, as a test.
|
||
|
||
SR4996 is never drawn on its own -- it ships inside the SP29/SP30 angles
|
||
and their callout covers it. The plant file says so in as many words, and
|
||
5.6 reported it as never printed anyway.
|
||
"""
|
||
findings, unread = ticketpage.bar_mark_uniqueness_status(
|
||
WP115_PRINTED, WP115_BARS,
|
||
attached=WP115_ATTACHED, classify=WP115_GRAMMAR)
|
||
assert [sev for (sev, _m) in findings] == ['OK']
|
||
message = findings[0][1]
|
||
assert 'SR4996 is attached to SP29/SP30' in message
|
||
assert unread == []
|
||
|
||
|
||
def test_the_coverage_is_named_and_never_a_silent_exclusion():
|
||
"""A covered bar is a JUDGED outcome. A coverage nobody can see on the
|
||
report is a coverage nobody can question."""
|
||
findings, _unread = ticketpage.bar_mark_uniqueness_status(
|
||
WP115_PRINTED, WP115_BARS,
|
||
attached=WP115_ATTACHED, classify=WP115_GRAMMAR)
|
||
message = findings[0][1]
|
||
assert 'never drawn on their own' in message
|
||
assert 'parent item callout covers them' in message
|
||
|
||
|
||
def test_a_covered_bar_whose_parent_is_also_unprinted_is_still_a_finding():
|
||
"""And the finding is about the PARENT.
|
||
|
||
The bar is unaccounted for either way; what changed is whose callout is
|
||
missing. A report that named the bar would send the reader to fix the
|
||
wrong thing.
|
||
"""
|
||
printed = [row for row in WP115_PRINTED
|
||
if not row['text'].startswith(('SP29', 'SP30'))]
|
||
findings, _unread = ticketpage.bar_mark_uniqueness_status(
|
||
printed, WP115_BARS,
|
||
attached=WP115_ATTACHED, classify=WP115_GRAMMAR)
|
||
warns = [m for (sev, m) in findings if sev == 'WARN']
|
||
assert len(warns) == 1
|
||
assert 'PARENT callout' in warns[0]
|
||
assert 'SR4996 is attached to SP29/SP30' in warns[0]
|
||
# ... and it is NOT reported as an ordinary never-printed bar.
|
||
assert 'are never printed on this ticket' not in warns[0]
|
||
|
||
|
||
def test_hardware_part_numbers_are_not_candidate_bar_marks():
|
||
"""The WP115 half of the same run: P78, P114, SP29, SP30 and SP31 were
|
||
reported as printed bar marks matching no bar in the model. They match no
|
||
bar because they are hardware."""
|
||
findings, _unread = ticketpage.bar_mark_uniqueness_status(
|
||
WP115_PRINTED, WP115_BARS,
|
||
attached=WP115_ATTACHED, classify=WP115_GRAMMAR)
|
||
blob = ' '.join(m for (_s, m) in findings)
|
||
assert 'match no bar in the model' not in blob
|
||
for part in ('P78', 'P114', 'SP29', 'SP30', 'SP31'):
|
||
assert part in blob # set aside OUT LOUD, not dropped
|
||
assert 'hardware part numbers' in blob
|
||
|
||
|
||
def test_the_w_slash_form_carries_both_populations():
|
||
"""`P78 W/ SR5502 (S.I.F.)` is a part AND a bar. The part is set aside
|
||
and the bar after it still counts -- and no `W/` splitter is needed,
|
||
because the grammar answers per token."""
|
||
printed = [{'text': 'P78 W/ SR5502 (S.I.F.) (E.S.)', 'view': 'Detail 1'}]
|
||
bars = [{'mark': 'SR5502', 'family': 'REBAR', 'length_ft': 4.0}]
|
||
findings, _unread = ticketpage.bar_mark_uniqueness_status(
|
||
printed, bars, classify=WP115_GRAMMAR)
|
||
assert [sev for (sev, _m) in findings] == ['OK']
|
||
assert 'P78' in findings[0][1]
|
||
|
||
# The bar half really is being compared: take it out of the model and the
|
||
# row has something to say about it.
|
||
findings, _unread = ticketpage.bar_mark_uniqueness_status(
|
||
printed, [{'mark': 'SR310', 'family': 'REBAR', 'length_ft': 8.0}],
|
||
classify=WP115_GRAMMAR)
|
||
warns = ' '.join(m for (sev, m) in findings if sev == 'WARN')
|
||
assert 'SR5502' in warns
|
||
assert 'P78' not in warns
|
||
|
||
|
||
def test_a_token_the_grammar_cannot_place_stays_a_finding():
|
||
"""Could-not-judge, never a quiet drop. An unplaceable token is a fact
|
||
about the sheet or about the grammar, and it is reported as one."""
|
||
printed = [{'text': 'MK12 (F.F.)', 'view': 'Detail 1'},
|
||
{'text': 'SR310 (E.L.)', 'view': 'Detail 2'}]
|
||
bars = [{'mark': 'SR310', 'family': 'REBAR', 'length_ft': 8.0}]
|
||
findings, _unread = ticketpage.bar_mark_uniqueness_status(
|
||
printed, bars, classify=WP115_GRAMMAR)
|
||
warns = [m for (sev, m) in findings if sev == 'WARN']
|
||
assert len(warns) == 1
|
||
assert 'could not be placed' in warns[0]
|
||
assert 'MK12' in warns[0]
|
||
|
||
|
||
def test_what_was_set_aside_is_said_even_when_the_row_is_not_green():
|
||
"""A narrowing visible only on a green row is a narrowing nobody audits
|
||
on the runs that matter."""
|
||
bars = WP115_BARS + [{'mark': 'SR999', 'family': 'REBAR',
|
||
'length_ft': 3.0}]
|
||
findings, _unread = ticketpage.bar_mark_uniqueness_status(
|
||
WP115_PRINTED, bars,
|
||
attached=WP115_ATTACHED, classify=WP115_GRAMMAR)
|
||
sevs = [sev for (sev, _m) in findings]
|
||
assert 'WARN' in sevs and 'OK' in sevs
|
||
aside = [m for (sev, m) in findings if sev == 'OK'][0]
|
||
assert 'what 5.6 set aside' in aside
|
||
assert 'SR4996' in aside and 'P78' in aside
|
||
|
||
|
||
def test_a_bar_mark_the_grammar_misfiled_is_still_ink_on_the_sheet():
|
||
"""(c) asks "is this mark printed anywhere", and it answers from EVERY
|
||
token, not from the narrowed candidate set. Otherwise a grammar mistake
|
||
would turn a printed bar into a never-printed one -- a finding invented
|
||
by the narrowing itself."""
|
||
printed = [{'text': 'SR310 (E.L.)', 'view': 'Detail 1'}]
|
||
bars = [{'mark': 'SR310', 'family': 'REBAR', 'length_ft': 8.0}]
|
||
findings, _unread = ticketpage.bar_mark_uniqueness_status(
|
||
printed, bars, classify=_grammar(SR='hardware'))
|
||
assert [sev for (sev, _m) in findings] == ['OK']
|
||
assert 'never printed' not in findings[0][1]
|
||
|
||
|
||
def test_with_no_knowledge_at_all_the_check_behaves_exactly_as_before():
|
||
"""Both narrowings default to off. A plant that has declared neither key
|
||
gets the comparison it had before either existed -- noisy, never quiet."""
|
||
findings, _unread = ticketpage.bar_mark_uniqueness_status(
|
||
WP115_PRINTED, WP115_BARS)
|
||
warns = ' '.join(m for (sev, m) in findings if sev == 'WARN')
|
||
assert 'match no bar in the model' in warns
|
||
assert 'P78' in warns
|
||
assert 'SR4996' in warns
|
||
|
||
|
||
def test_a_parent_named_by_family_resolves_to_the_number_the_ticket_prints():
|
||
"""TWO VOCABULARIES, BOTH LEGITIMATE, MEASURED ON THE LIVE LAYERS.
|
||
|
||
The plant file names a parent by its FAMILY (`<vendor>_EMBED_P76`); the
|
||
job file names it by its PART NUMBER (`SP29`). The ticket prints the part
|
||
number. Demanding one spelling would have turned every plant-declared
|
||
parent into a finding nobody could act on -- a false finding invented by
|
||
the fix.
|
||
"""
|
||
# The bar itself is NOT printed -- that is the whole case. Only the
|
||
# parent is, and only under its family spelling in the knowledge.
|
||
printed = [{'text': 'P76 (S.I.F.)', 'view': 'Detail 1'}]
|
||
bars = [{'mark': 'SR5602', 'family': 'REBAR', 'length_ft': 1.0}]
|
||
findings, _unread = ticketpage.bar_mark_uniqueness_status(
|
||
printed, bars, attached={'SR5602': ['VENDOR_EMBED_P76']},
|
||
classify=WP115_GRAMMAR)
|
||
assert [sev for (sev, _m) in findings] == ['OK']
|
||
assert 'SR5602 is attached to VENDOR_EMBED_P76' in findings[0][1]
|
||
|
||
|
||
def test_part_numbers_in_finds_what_mark_tokens_cannot():
|
||
"""Exactly one `\\b` apart, and the underscore is why.
|
||
|
||
In regex `_` is a WORD character, so `\\bP76\\b` never matches
|
||
`VENDOR_EMBED_P76` -- the same blindness that let the scrub gate miss
|
||
`PREFIX_TERM`. `mark_tokens` keeps its boundary because it reads printed
|
||
call-outs, where it is what stops `SR1` being read out of `SR1101`.
|
||
"""
|
||
assert ticketpage.part_numbers_in('VENDOR_EMBED_P76') == ['P76']
|
||
assert ticketpage.mark_tokens('VENDOR_EMBED_P76') == []
|
||
assert ticketpage.mark_tokens('SR1101 (E.L.)') == ['SR1101']
|
||
|
||
|
||
def test_a_parent_nothing_can_be_read_out_of_claims_nothing_either_way():
|
||
""""The parent is missing" is not something this may assert on the
|
||
strength of a lookup it could not perform."""
|
||
printed = [{'text': 'SR310 (E.L.)', 'view': 'Detail 1'}]
|
||
bars = [{'mark': 'SR310', 'family': 'REBAR', 'length_ft': 8.0},
|
||
{'mark': 'SR4996', 'family': 'REBAR', 'length_ft': 1.5}]
|
||
findings, _unread = ticketpage.bar_mark_uniqueness_status(
|
||
printed, bars, attached={'SR4996': ['THE ANGLE ASSEMBLY']},
|
||
classify=WP115_GRAMMAR)
|
||
warns = [m for (sev, m) in findings if sev == 'WARN']
|
||
assert len(warns) == 1
|
||
assert 'could not be established' in warns[0]
|
||
assert 'PARENT callout' not in warns[0]
|
||
assert 'are never printed on this ticket' not in warns[0]
|
||
|
||
|
||
# ================================ 5.11 - 5.15: solid zones and recesses
|
||
#
|
||
# Owner, 2026-08-31, having asked before: "没有 solid zone 相关的检查。lifter
|
||
# 自带 solid zone,没检查这些 solid zone 是不是被加入了 assembly,是不是被显示
|
||
# 在 ticket 上面,hatch pattern 对不对。这些都没有啊" -- then "不光是 solid
|
||
# zone,insulation recess 也得加上。"
|
||
#
|
||
# EVERY PLANT STRING BELOW IS INVENTED except the Revit pattern names, which
|
||
# are Autodesk's and identify nobody. A test that pinned this plant's
|
||
# `KP ...` type names would put the plant's vocabulary into the repository
|
||
# from the test side, which is the same defect as writing it into the check.
|
||
# What is asserted is the SHAPE of the answer.
|
||
|
||
VIEW = 'Layout View 1'
|
||
|
||
|
||
def _zone(member):
|
||
return {'id': 1, 'lifter_id': 2, 'member': member}
|
||
|
||
|
||
def test_511_all_members_is_a_genuine_ok():
|
||
sev, msg = ticketpage.solid_zone_membership_status(
|
||
[_zone(True), _zone(True)])
|
||
assert sev == 'OK'
|
||
assert '2 solid zone(s)' in msg
|
||
|
||
|
||
def test_511_a_zone_outside_the_assembly_is_a_fail_and_says_why_it_matters():
|
||
"""Not a drafting untidiness: the shop is shown the wrong piece."""
|
||
sev, msg = ticketpage.solid_zone_membership_status(
|
||
[_zone(True), _zone(False), _zone(False)])
|
||
assert sev == 'FAIL'
|
||
assert '2 of 3' in msg
|
||
assert 'assembly-scoped' in msg
|
||
|
||
|
||
def test_511_unreadable_membership_is_never_a_pass_and_never_a_failure():
|
||
"""The third answer. It rides the SAME finding, because a second one on
|
||
the same code would silently replace the first (6.7's lesson)."""
|
||
sev, msg = ticketpage.solid_zone_membership_status(
|
||
[_zone(None), _zone(None)])
|
||
assert sev is None
|
||
assert 'not judged' in msg or 'nothing was judged' in msg
|
||
|
||
|
||
def test_511_a_judged_row_still_names_what_it_could_not_read():
|
||
sev, msg = ticketpage.solid_zone_membership_status(
|
||
[_zone(True), _zone(None)])
|
||
assert sev == 'OK'
|
||
assert 'could not be read' in msg
|
||
|
||
|
||
def test_511_no_zone_at_all_is_a_silence_and_not_an_ok():
|
||
""""Every zone is a member" is a different statement from "there are no
|
||
zones", and this must never print the first when it means the second."""
|
||
sev, msg = ticketpage.solid_zone_membership_status([])
|
||
assert sev is None
|
||
assert 'never printed as one' in msg
|
||
|
||
|
||
def test_511_an_unread_lifter_is_carried_into_the_sentence():
|
||
sev, msg = ticketpage.solid_zone_membership_status(
|
||
[_zone(True)], lifters_unread=3)
|
||
assert sev == 'OK'
|
||
assert '3 lifter(s) could not be read' in msg
|
||
|
||
|
||
def test_511_a_lifter_with_no_zone_is_its_own_finding_not_a_membership_one():
|
||
"""Zero today (0 of 192, measured 2026-08-31) and built anyway: the day
|
||
it fires there is no zone to add, so the repair is in the model."""
|
||
assert ticketpage.solid_zone_absent_status(0, 12) is None
|
||
sev, msg = ticketpage.solid_zone_absent_status(2, 12)
|
||
assert sev == 'FAIL'
|
||
assert '2 of 12' in msg
|
||
assert 'the model is what has to change' in msg
|
||
|
||
|
||
def test_512_a_non_member_is_reported_as_absent_from_the_ticket():
|
||
sev, msg = ticketpage.solid_zone_ticket_status(
|
||
[_zone(True), _zone(False)], VIEW)
|
||
assert sev == 'FAIL'
|
||
assert VIEW in msg
|
||
assert 'INFERRED' in msg
|
||
|
||
|
||
def test_512_says_the_visibility_is_inferred_even_when_it_passes():
|
||
"""The basis is what tells a reader how to repair it, so it is in the
|
||
OK too -- not only in the failure."""
|
||
sev, msg = ticketpage.solid_zone_ticket_status([_zone(True)], VIEW)
|
||
assert sev == 'OK'
|
||
assert 'INFERRED' in msg
|
||
|
||
|
||
def test_512_with_no_view_named_stands_down_naming_that():
|
||
sev, msg = ticketpage.solid_zone_ticket_status([_zone(True)], None)
|
||
assert sev is None
|
||
assert 'not named for this plant' in msg
|
||
|
||
|
||
def test_512_can_be_told_to_stop_inferring():
|
||
"""The escape hatch for the day somebody measures the rendered view."""
|
||
sev, msg = ticketpage.solid_zone_ticket_status(
|
||
[_zone(False)], VIEW, members_are_visible=False)
|
||
assert sev is None
|
||
assert 'nothing is claimed' in msg
|
||
|
||
|
||
def test_513_a_wrong_hatch_names_both_patterns_and_both_provenances():
|
||
""""The hatch is wrong" is not actionable. This is."""
|
||
sev, msg = ticketpage.hatch_status(
|
||
'the solid zones', 'TRIANG', 'ANSI35', 'legend',
|
||
'the legend printed on this ticket draws solid_zone as ANSI35')
|
||
assert sev == 'FAIL'
|
||
assert 'TRIANG' in msg and 'ANSI35' in msg
|
||
assert 'this ticket\'s own legend' in msg
|
||
|
||
|
||
def test_513_a_plant_sourced_expectation_is_named_as_the_weaker_claim():
|
||
sev, msg = ticketpage.hatch_status(
|
||
'the solid zones', 'TRIANG', 'ANSI35', 'plant', 'because I say so')
|
||
assert sev == 'FAIL'
|
||
assert 'the plant layer\'s copy of the legend' in msg
|
||
|
||
|
||
def test_513_matching_is_case_insensitive_and_passes():
|
||
sev, msg = ticketpage.hatch_status(
|
||
'the solid zones', 'ansi35', 'ANSI35', 'legend', 'why')
|
||
assert sev == 'OK'
|
||
|
||
|
||
def test_513_no_expectation_at_all_is_a_silence_carrying_its_own_reason():
|
||
sev, msg = ticketpage.hatch_status(
|
||
'the solid zones', 'TRIANG', None, None, 'nobody wrote it down')
|
||
assert sev is None
|
||
assert msg == 'nobody wrote it down'
|
||
|
||
|
||
def test_513_an_ungathered_render_is_a_silence_and_never_a_pass():
|
||
"""The gather half. It must not read as agreement."""
|
||
sev, msg = ticketpage.hatch_status(
|
||
'the solid zones', None, 'ANSI35', 'legend', 'the legend says ANSI35')
|
||
assert sev is None
|
||
assert 'could not be read on this run' in msg
|
||
|
||
|
||
def test_514_stands_down_today_and_names_both_unblockers():
|
||
"""It compares nothing, and says so rather than printing a count as a
|
||
verdict. That is still worth the row."""
|
||
sev, msg = ticketpage.recess_presence_status(
|
||
{'2in RECESS': 3, '3in RECESS': 1}, None)
|
||
assert sev is None
|
||
assert 'NOTHING WAS COMPARED' in msg
|
||
assert '2in RECESS x3' in msg
|
||
assert 'design-summary reader' in msg
|
||
assert 'modelled' in msg
|
||
|
||
|
||
def test_514_carries_the_dormant_modelled_path_note_when_given_one():
|
||
sev, msg = ticketpage.recess_presence_status(
|
||
{}, None, 'The modelled-recess path was NOT consulted: no ruling.')
|
||
assert sev is None
|
||
assert 'NOT consulted' in msg
|
||
|
||
|
||
def test_514_becomes_a_real_comparison_the_day_a_requirement_arrives():
|
||
"""No new plumbing: the row is written for the requirement it has not
|
||
got yet, which is what makes standing down worth doing."""
|
||
sev, msg = ticketpage.recess_presence_status(
|
||
{'2in RECESS': 2}, {'2in RECESS': 2})
|
||
assert sev == 'OK'
|
||
sev, msg = ticketpage.recess_presence_status(
|
||
{'2in RECESS': 2}, {'2in RECESS': 2, '3in RECESS': 1})
|
||
assert sev == 'FAIL'
|
||
assert '3in RECESS' in msg
|
||
|
||
|
||
def test_515_the_size_question_is_named_and_never_answered():
|
||
caveat = ticketpage.recess_size_caveat()
|
||
assert 'not this row\'s question' in caveat
|
||
assert 'compares PATTERNS' in caveat
|
||
assert 'he has been asked' in caveat
|
||
|
||
|
||
# ---------------------------------------------------------------- the master
|
||
# checklist, read the way the BUTTON reads it
|
||
#
|
||
# THE DEFECT THIS SECTION LOCKS DOWN (2026-08-31, seen live by the owner).
|
||
# `ticketpage._read_yaml` prefers pyRevit's YAML reader, which is a YamlDotNet
|
||
# wrapper whose whole scalar conversion is `return ynode.Value`. Every scalar
|
||
# comes back a STRING, so `null` arrives as the four characters 'null'.
|
||
# `knowledge._merge_file` has always coerced after that read; `_read_yaml` did
|
||
# not. On the button that made `owner_codes: null` into `('null',)` and
|
||
# `checks: null` into `'null'` -- both truthy -- so `CHECKLIST_FOR_ENGINEER`
|
||
# derived EMPTY and 5.3/5.5 printed red NOT RUN; and it put the WORD 'null'
|
||
# into 37 of 39 rows' `not_run_reason`, where, being a non-empty string, it
|
||
# BEAT the derived probe sentence and SATISFIED the guard that demands a
|
||
# sentence from every row.
|
||
#
|
||
# Every test in this file, and every other guard in the suite, was blind to it
|
||
# for one reason: host pytest reads through PyYAML, which types its scalars.
|
||
# These tests read through a reader that does not. See
|
||
# `pyrevit_yaml_fake.py`, which is where that reader's semantics live.
|
||
|
||
def _import_ticketpage_through(monkeypatch, load_as_dict, name):
|
||
"""A FRESH ticketpage, imported with `load_as_dict` as the pyRevit reader.
|
||
|
||
A fresh module object rather than a reload: `CHECKLIST_ITEMS` is built at
|
||
import and much of the package holds references to the live module's
|
||
copy, so reloading it in place would leave the rest of the suite reading
|
||
a checklist assembled under a test's fake reader.
|
||
"""
|
||
import importlib.util
|
||
import os
|
||
from pyrevit_yaml_fake import install_pyrevit_yaml
|
||
install_pyrevit_yaml(monkeypatch, load_as_dict)
|
||
# The live module's own file: no second copy of where `vibe` lives, and
|
||
# no import of `conftest` by name (there are two in this repo).
|
||
spec = importlib.util.spec_from_file_location(
|
||
name, os.path.abspath(ticketpage.__file__.replace('.pyc', '.py')))
|
||
module = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(module)
|
||
return module
|
||
|
||
|
||
def test_the_strings_only_reader_really_is_unforgiving():
|
||
"""The double has to BITE, or everything below passes for free.
|
||
|
||
This is the assertion the old yamlkit-backed fake could never make: read
|
||
the real catalogue with the real reader's semantics and the nulls come
|
||
back as the word.
|
||
"""
|
||
from pyrevit_yaml_fake import strings_only_yaml_reader
|
||
raw = strings_only_yaml_reader()(ticketpage.master_path())
|
||
rows = raw['checklist']['master']
|
||
assert [r for r in rows if r.get('not_run_reason') == 'null'], (
|
||
'the catalogue must still carry `not_run_reason: null` rows for this '
|
||
'regression to mean anything')
|
||
assert [r for r in rows if r.get('owner_codes') == 'null']
|
||
|
||
|
||
def test_for_engineer_derives_under_the_reader_the_button_actually_uses(
|
||
monkeypatch):
|
||
"""5.3 and 5.5 are a person's rows on the button too, not only on the host.
|
||
|
||
They are the only two master rows with `owner_codes: null`, and they are
|
||
exactly the two the corruption ate.
|
||
"""
|
||
from pyrevit_yaml_fake import strings_only_yaml_reader
|
||
strict = _import_ticketpage_through(
|
||
monkeypatch, strings_only_yaml_reader(), 'ticketpage_stringsonly_fe')
|
||
assert strict.CHECKLIST_FOR_ENGINEER == ('5.3', '5.5')
|
||
assert strict._for_engineer_rows() == ('5.3', '5.5')
|
||
|
||
|
||
def test_no_row_carries_the_word_null_as_its_reason_on_the_button(monkeypatch):
|
||
"""A reason slot holding 'null' is a silent row wearing a sentence.
|
||
|
||
Non-empty, so the no-empty-reason guard is satisfied; truthy, so it
|
||
overrides the derived probe sentence. 37 of 39 rows had it.
|
||
"""
|
||
from pyrevit_yaml_fake import strings_only_yaml_reader
|
||
strict = _import_ticketpage_through(
|
||
monkeypatch, strings_only_yaml_reader(), 'ticketpage_stringsonly_rs')
|
||
absent = set(strict._NULL_TEXT)
|
||
for (ident, _nm, grade, owners, reason, checks) in strict.CHECKLIST_ITEMS:
|
||
assert reason is None or reason.strip().lower() not in absent, (
|
||
'row {0} carries a spelled-out null as its reason'.format(ident))
|
||
assert grade == '' or grade.strip().lower() not in absent, (
|
||
'row {0} carries a spelled-out null as its grade'.format(ident))
|
||
for owner in (owners or ()):
|
||
assert owner.strip().lower() not in absent, (
|
||
'row {0} has a null owner code'.format(ident))
|
||
for check in strict._check_names(checks):
|
||
assert check.strip().lower() not in absent, (
|
||
'row {0} names a check function called null'.format(ident))
|
||
|
||
|
||
# THE THIRD SYMPTOM -- nine rows naming a check function called `null` -- is
|
||
# NOT re-asserted here, on purpose rather than by omission.
|
||
# `test_every_master_entry_names_a_check_that_exists` in
|
||
# `test_ticketchecks_host.py` already pairs every named check against the
|
||
# module, and it can only run there: importing `vibe.ticketchecks` pulls in
|
||
# `vibe.ticketqc`, which imports the Revit API at module scope, and that
|
||
# file owns the deliberately inert stub that makes it importable. What
|
||
# closes the gap for the button is `test_the_two_readers_agree_row_for_row`
|
||
# below: the two tables are identical field for field, so the pairing guard's
|
||
# verdict on the host's table is a verdict on the button's.
|
||
|
||
|
||
def test_every_master_id_survives_the_read_as_a_string(monkeypatch):
|
||
"""THE 5.10 PIN, and why the fix is null-only rather than `_coerce`.
|
||
|
||
`knowledge._coerce` also numifies, and coercion runs after parsing, so
|
||
the quotes on `id: '5.10'` are long gone by then: it would become the
|
||
float 5.1, collide with the real row 5.1, and `load_master` would refuse
|
||
the WHOLE checklist -- the button dead rather than merely wrong. So the
|
||
absence test is null-only, and ids never go through it at all.
|
||
"""
|
||
from pyrevit_yaml_fake import strings_only_yaml_reader
|
||
strict = _import_ticketpage_through(
|
||
monkeypatch, strings_only_yaml_reader(), 'ticketpage_stringsonly_id')
|
||
ids = [item[0] for item in strict.CHECKLIST_ITEMS]
|
||
for ident in ids:
|
||
assert isinstance(ident, str), '{0!r} is not text'.format(ident)
|
||
assert len(ids) == len(set(ids))
|
||
assert '5.10' in ids and '5.1' in ids
|
||
assert ids.index('5.10') != ids.index('5.1')
|
||
|
||
|
||
def test_the_two_readers_agree_row_for_row(monkeypatch):
|
||
"""Host and button must build the SAME table, field for field.
|
||
|
||
The migration was verified field-for-field through a typing reader while
|
||
production ran through one that types nothing; that is the blind spot,
|
||
and this closes it for every field of every row at once.
|
||
"""
|
||
from pyrevit_yaml_fake import strings_only_yaml_reader
|
||
strict = _import_ticketpage_through(
|
||
monkeypatch, strings_only_yaml_reader(), 'ticketpage_stringsonly_all')
|
||
assert strict.CHECKLIST_ITEMS == ticketpage.CHECKLIST_ITEMS
|
||
|
||
|
||
def test_is_absent_is_null_only_and_never_touches_a_real_value():
|
||
"""It must not swallow a legitimate string, and must not numify."""
|
||
assert ticketpage._is_absent(None)
|
||
assert ticketpage._is_absent('null')
|
||
assert ticketpage._is_absent('NULL')
|
||
assert ticketpage._is_absent('~')
|
||
assert ticketpage._is_absent(' none ')
|
||
assert ticketpage._is_absent('')
|
||
assert not ticketpage._is_absent('5.10')
|
||
assert not ticketpage._is_absent('check_solid_zone_members')
|
||
assert not ticketpage._is_absent('0')
|
||
assert not ticketpage._is_absent('nullify the run')
|
||
|
||
|
||
# ------------------------------------------------------ demo-safe: no CJK
|
||
#
|
||
# Owner, 2026-09-01: no Chinese anywhere in a report or a UI surface, even
|
||
# when quoting him -- he is demoing this to colleagues before the weekend.
|
||
# The owner's rulings on this checklist (dunnage, weight, note folding --
|
||
# `dunnage_clearance`, `weight_three_way`, `note_folds`) now live in `#`
|
||
# comments only; the CJK-survival tests elsewhere in this file are about a
|
||
# JOB'S OWN mute reason surviving intact, a separate and still-valid
|
||
# guarantee. This fixture is the shape a real ticket-QC page renders: the
|
||
# checklist table (HTML for the panel, TEXT for `last-ticketqc.txt`) plus
|
||
# the style block and the key.
|
||
|
||
_DEMO_ROWS = [
|
||
('4.5', 'debond call-out', 'B', 'WARN',
|
||
'Everything of this kind goes out as WARN.'),
|
||
('6.4', 'dunnage clearance', 'B', ticketpage.MUTED_STATE,
|
||
'no opening -- muted by the owner'),
|
||
('8.1', 'reinforcing', 'A', 'OK', 'ran, found nothing'),
|
||
]
|
||
|
||
|
||
def test_the_checklist_table_renders_with_no_chinese_text_or_html():
|
||
cjk = re.compile(u'[一-鿿]')
|
||
html_out = (ticketpage.checklist_table_html(_DEMO_ROWS)
|
||
+ ticketpage.CHECKLIST_STYLE + ticketpage.checklist_key_html())
|
||
text_out = ticketpage.checklist_table_text(_DEMO_ROWS)
|
||
assert not cjk.search(html_out), html_out
|
||
assert not cjk.search(text_out), text_out
|