52d94303bc
ci / test (push) Has been cancelled
Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
1372 lines
56 KiB
Python
1372 lines
56 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""The reinforcing index's Revit-free half: eligibility, features, staleness.
|
|
|
|
No Revit, no model. Everything here is the part of the index that has to be
|
|
right before a single piece of geometry is read -- who may be a donor, what a
|
|
feature record says, what gets rebuilt when something moves, and the
|
|
distinction between a piece that has no openings and a piece nobody could
|
|
measure.
|
|
|
|
The numbers in the feature tests are the measured WP157/WP168 rectangles from
|
|
the worked example: the two panels are different lengths and their two big
|
|
openings sit at the identical ABSOLUTE feet-from-start, which is exactly the
|
|
match a proportional record would have thrown away.
|
|
"""
|
|
# pylint: disable=import-error,invalid-name,broad-except
|
|
import json
|
|
import os
|
|
|
|
from vibe import reinforcecore as core
|
|
|
|
|
|
# --------------------------------------------------------------- eligibility
|
|
|
|
def test_reinforced_and_not_on_hold_is_the_whole_rule():
|
|
assert core.is_eligible(1, 0) is True
|
|
assert core.is_eligible(1, None) is True
|
|
|
|
|
|
def test_on_hold_disqualifies_even_a_reinforced_piece():
|
|
"""The measured case: the worked example's second donor is on hold."""
|
|
assert core.is_eligible(1, 1) is False
|
|
|
|
|
|
def test_unreinforced_is_never_a_donor():
|
|
assert core.is_eligible(0, 0) is False
|
|
assert core.is_eligible(None, None) is False
|
|
assert core.is_eligible(None, 0) is False
|
|
|
|
|
|
# ------------------------------------------------------------------ features
|
|
|
|
def test_feature_records_absolute_position_not_a_fraction():
|
|
"""Same rectangle, two panels of different lengths -> identical fields."""
|
|
a = core.feature('WP157', 0, 6.5, 10.583, 0.354, 4.479, True, 0.0,
|
|
27.625, 11.5, 8.0)
|
|
b = core.feature('WP168', 0, 6.5, 10.583, 0.354, 4.479, True, 0.0,
|
|
24.917, 11.5, 8.0)
|
|
for key in ('x0', 'x1', 'y0', 'y1', 'w', 'h', 'cx', 'cy'):
|
|
assert a[key] == b[key], key
|
|
assert a['piece_length_ft'] != b['piece_length_ft']
|
|
|
|
|
|
def test_feature_is_flat_and_two_dimensional():
|
|
f = core.feature('WP157', 0, 24.25, 27.625, 6.25, 11.5, True, 0.0,
|
|
27.625, 11.5, 8.0)
|
|
assert f['w'] == 3.375 and f['h'] == 5.25
|
|
assert f['cx'] == 20.938 or round(f['cx'], 3) == round((24.25 + 27.625) / 2, 3)
|
|
assert f['cy'] == 8.875
|
|
for value in f.values():
|
|
assert not isinstance(value, (dict, list)), 'features must stay flat'
|
|
|
|
|
|
def test_feature_carries_no_category():
|
|
"""A blockout and a notch produce the same shape of record, on purpose."""
|
|
blockout = core.feature('WP157', 0, 24.25, 27.625, 6.25, 11.5, True, 0.0,
|
|
27.625, 11.5, 8.0)
|
|
notch = core.feature('WP157', 1, 0.0, 0.875, 1.125, 3.0, True, 0.0,
|
|
27.625, 11.5, 8.0)
|
|
assert blockout['kind'] == notch['kind'] == core.KIND_OPENING
|
|
|
|
|
|
def test_unknown_penetration_is_none_not_false():
|
|
f = core.feature('WP157', 0, 0.0, 0.667, 11.292, 11.5, None, None,
|
|
27.625, 11.5, 8.0)
|
|
assert f['through'] is None
|
|
assert f['concrete_in'] is None
|
|
|
|
|
|
def test_blind_recess_keeps_its_concrete_depth():
|
|
f = core.feature('WP157', 0, 0.0, 0.667, 11.292, 11.5, False, 3.5,
|
|
27.625, 11.5, 8.0)
|
|
assert f['through'] is False
|
|
assert f['concrete_in'] == 3.5
|
|
|
|
|
|
def test_features_are_reindexed_in_a_stable_order():
|
|
late = core.feature('WP157', 0, 24.25, 27.625, 6.25, 11.5, True, 0.0,
|
|
27.625, 11.5, 8.0)
|
|
early = core.feature('WP157', 1, 0.0, 0.875, 1.125, 3.0, True, 0.0,
|
|
27.625, 11.5, 8.0)
|
|
rec = core.piece_record('WP157', 1, 'sig', 1, 0, features=[late, early])
|
|
assert [f['x0'] for f in rec['features']] == [0.0, 24.25]
|
|
assert [f['index'] for f in rec['features']] == [0, 1]
|
|
|
|
|
|
# ------------------------------------------------- could-not-judge vs nothing
|
|
|
|
def test_no_openings_and_could_not_measure_are_different_answers():
|
|
measured = core.piece_record('WP393', 1, 'sig', 1, 0, features=[])
|
|
unread = core.piece_record('WP999', 2, 'sig', 1, 0,
|
|
error='no concrete solid')
|
|
assert measured['features'] == []
|
|
assert measured['error'] is None
|
|
assert unread['features'] is None
|
|
assert unread['error']
|
|
|
|
|
|
def test_unjudged_pieces_are_held_back_from_the_donor_list_and_reported():
|
|
cache = core.new_cache('m')
|
|
cache['pieces'] = {
|
|
'A': core.piece_record('A', 1, 's', 1, 0, features=[]),
|
|
'B': core.piece_record('B', 2, 's', 1, 0, error='no panel frame'),
|
|
}
|
|
names = [r['name'] for r in core.donors(cache)]
|
|
assert names == ['A']
|
|
assert [r['name'] for r in core.donors(cache, include_unjudged=True)] \
|
|
== ['A', 'B']
|
|
plan = {'build': [], 'fresh': ['A', 'B']}
|
|
assert core.coverage(cache, plan)['unjudged'] == ['B']
|
|
|
|
|
|
# ----------------------------------------------------------------- staleness
|
|
|
|
def test_signature_moves_when_a_member_is_added_or_removed():
|
|
base = core.signature(1, 0, [10, 11, 12])
|
|
assert core.signature(1, 0, [10, 11, 12, 13]) != base
|
|
assert core.signature(1, 0, [10, 11]) != base
|
|
|
|
|
|
def test_signature_ignores_member_order():
|
|
assert core.signature(1, 0, [12, 10, 11]) == core.signature(1, 0, [10, 11, 12])
|
|
|
|
|
|
def test_signature_moves_when_either_flag_moves():
|
|
base = core.signature(1, 0, [10, 11])
|
|
assert core.signature(0, 0, [10, 11]) != base
|
|
assert core.signature(1, 1, [10, 11]) != base
|
|
|
|
|
|
def test_signature_cannot_see_a_geometry_edit_in_place():
|
|
"""The honest limit, asserted so nobody assumes otherwise.
|
|
|
|
Moving or resizing an opening changes no member id and no flag, so the
|
|
signature is unchanged and the cached record stays. `rebuild_all` is the
|
|
only answer to this, and it is cheap.
|
|
"""
|
|
before = core.signature(1, 0, [10, 11, 12])
|
|
after = core.signature(1, 0, [10, 11, 12])
|
|
assert before == after
|
|
|
|
|
|
# ---------------------------------------------------------------- the plan
|
|
|
|
def _live(**kw):
|
|
return kw
|
|
|
|
|
|
def test_plan_builds_new_and_stale_reuses_fresh():
|
|
cache = core.new_cache('m')
|
|
cache['pieces'] = {
|
|
'A': core.piece_record('A', 1, 'sigA', 1, 0, features=[]),
|
|
'B': core.piece_record('B', 2, 'sigB', 1, 0, features=[]),
|
|
}
|
|
live = _live(A=('sigA', 1, 0), # unchanged
|
|
B=('sigB2', 1, 0), # moved
|
|
C=('sigC', 1, 0)) # new
|
|
plan = core.plan(cache, live)
|
|
assert plan['fresh'] == ['A']
|
|
assert plan['build'] == ['B', 'C']
|
|
assert plan['drop'] == []
|
|
|
|
|
|
def test_plan_drops_a_piece_that_has_gone_on_hold():
|
|
cache = core.new_cache('m')
|
|
cache['pieces'] = {'A': core.piece_record('A', 1, 'sigA', 1, 0, features=[])}
|
|
plan = core.plan(cache, _live(A=('sigA2', 1, 1)))
|
|
assert plan['build'] == [] and plan['fresh'] == []
|
|
assert plan['drop'] == ['A']
|
|
|
|
|
|
def test_plan_picks_up_a_piece_taken_off_hold():
|
|
cache = core.new_cache('m')
|
|
plan = core.plan(cache, _live(A=('sigA', 1, 0)))
|
|
assert plan['build'] == ['A']
|
|
|
|
|
|
def test_plan_drops_a_piece_that_left_the_model():
|
|
cache = core.new_cache('m')
|
|
cache['pieces'] = {'A': core.piece_record('A', 1, 'sigA', 1, 0, features=[])}
|
|
assert core.plan(cache, {})['drop'] == ['A']
|
|
|
|
|
|
def test_a_schema_bump_rebuilds_everything():
|
|
cache = core.new_cache('m')
|
|
cache['schema'] = core.SCHEMA - 1
|
|
cache['pieces'] = {'A': core.piece_record('A', 1, 'sigA', 1, 0, features=[])}
|
|
plan = core.plan(cache, _live(A=('sigA', 1, 0)))
|
|
assert plan['build'] == ['A']
|
|
|
|
|
|
# --------------------------------------------------- partial builds are ok
|
|
|
|
def test_a_partial_build_is_kept_and_says_what_is_missing():
|
|
cache = core.new_cache('m')
|
|
live = _live(A=('sA', 1, 0), B=('sB', 1, 0), C=('sC', 1, 0))
|
|
plan = core.plan(cache, live)
|
|
# only A got built before the budget ran out
|
|
cache = core.apply_plan(cache, plan,
|
|
[core.piece_record('A', 1, 'sA', 1, 0, features=[])],
|
|
built_at='now')
|
|
cov = core.coverage(cache, plan)
|
|
assert cov['eligible'] == 3
|
|
assert cov['indexed'] == 1
|
|
assert cov['missing'] == ['B', 'C']
|
|
assert cov['complete'] is False
|
|
|
|
|
|
def test_a_finished_build_reports_complete():
|
|
cache = core.new_cache('m')
|
|
live = _live(A=('sA', 1, 0))
|
|
plan = core.plan(cache, live)
|
|
cache = core.apply_plan(cache, plan,
|
|
[core.piece_record('A', 1, 'sA', 1, 0, features=[])])
|
|
assert core.coverage(cache, plan)['complete'] is True
|
|
|
|
|
|
# ------------------------------------------------------------------- io
|
|
|
|
def test_round_trip_through_disk(tmp_path):
|
|
path = str(tmp_path / 'reinforce' / 'index.json')
|
|
cache = core.new_cache('model')
|
|
f = core.feature('A', 0, 6.5, 10.583, 0.354, 4.479, True, 0.0,
|
|
27.625, 11.5, 8.0)
|
|
cache['pieces'] = {'A': core.piece_record('A', 1, 'sA', 1, 0, features=[f])}
|
|
core.save(path, cache)
|
|
assert os.path.isfile(path)
|
|
back = core.load(path)
|
|
assert back['pieces']['A']['features'][0]['x0'] == 6.5
|
|
assert back['doc_key'] == 'model'
|
|
|
|
|
|
def test_a_damaged_cache_costs_a_rebuild_and_says_so(tmp_path):
|
|
path = str(tmp_path / 'index.json')
|
|
with open(path, 'wb') as handle:
|
|
handle.write(b'{not json')
|
|
cache = core.load(path)
|
|
assert cache['pieces'] == {}
|
|
assert cache['_load_error']
|
|
|
|
|
|
def test_an_old_schema_is_not_read_as_data(tmp_path):
|
|
path = str(tmp_path / 'index.json')
|
|
with open(path, 'wb') as handle:
|
|
handle.write(json.dumps({'schema': 0, 'pieces': {'A': {}}}).encode('utf-8'))
|
|
cache = core.load(path)
|
|
assert cache['pieces'] == {}
|
|
assert cache['_load_error']
|
|
|
|
|
|
def test_a_missing_cache_is_an_empty_one_not_an_error():
|
|
cache = core.load(None)
|
|
assert cache['schema'] == core.SCHEMA
|
|
assert cache['pieces'] == {}
|
|
assert '_load_error' not in cache
|
|
|
|
|
|
def test_all_features_is_flat_across_donors():
|
|
cache = core.new_cache('m')
|
|
fa = core.feature('A', 0, 1.0, 2.0, 1.0, 2.0, True, 0.0, 20.0, 10.0, 8.0)
|
|
fb = core.feature('B', 0, 3.0, 4.0, 1.0, 2.0, True, 0.0, 20.0, 10.0, 8.0)
|
|
cache['pieces'] = {
|
|
'A': core.piece_record('A', 1, 's', 1, 0, features=[fa]),
|
|
'B': core.piece_record('B', 2, 's', 1, 0, features=[fb]),
|
|
'C': core.piece_record('C', 3, 's', 1, 0, error='unreadable'),
|
|
}
|
|
feats = core.all_features(cache)
|
|
assert len(feats) == 2
|
|
assert sorted(f['piece'] for f in feats) == ['A', 'B']
|
|
|
|
|
|
def test_disk_form_is_packed_but_the_caller_sees_the_full_feature(tmp_path):
|
|
"""Derived fields are recomputed on load, not stored 690 times over."""
|
|
path = str(tmp_path / 'index.json')
|
|
cache = core.new_cache('m')
|
|
f = core.feature('A', 0, 6.5, 10.583, 0.354, 4.479, True, 0.0,
|
|
27.625, 11.5, 8.0)
|
|
cache['pieces'] = {'A': core.piece_record(
|
|
'A', 1, 'sA', 1, 0, length_ft=27.625, width_ft=11.5, thickness_in=8.0,
|
|
features=[f])}
|
|
core.save(path, cache)
|
|
with open(path, 'rb') as handle:
|
|
raw = handle.read().decode('utf-8')
|
|
assert 'piece_length_ft' not in raw # stored once per piece, not per feature
|
|
assert '"cx"' not in raw # derived, not stored
|
|
back = core.load(path)
|
|
assert back['pieces']['A']['features'][0] == f
|
|
|
|
|
|
def test_a_measured_empty_piece_survives_the_round_trip(tmp_path):
|
|
path = str(tmp_path / 'index.json')
|
|
cache = core.new_cache('m')
|
|
cache['pieces'] = {
|
|
'A': core.piece_record('A', 1, 'sA', 1, 0, features=[]),
|
|
'B': core.piece_record('B', 2, 'sB', 1, 0, error='no concrete solid'),
|
|
}
|
|
core.save(path, cache)
|
|
back = core.load(path)
|
|
assert back['pieces']['A']['features'] == []
|
|
assert back['pieces']['B']['features'] is None
|
|
|
|
|
|
# ---------------------------------------------------------- MASTER, the one
|
|
# provenance signal the donor pool has. `TICKET_IS_REINFORCED` says somebody
|
|
# drew bars; MASTER says somebody reviewed them. It is typed BY HAND into the
|
|
# assembly's identity comment, so the rule that reads it has to be tolerant
|
|
# about case and about surrounding text -- and has to be predictable enough
|
|
# that a reader can say in advance what it will and will not accept.
|
|
|
|
|
|
def test_master_accepts_the_bare_token_in_any_case():
|
|
for text in ('MASTER', 'master', 'Master', 'mAsTeR'):
|
|
assert core.is_master_text(text) is True
|
|
|
|
|
|
def test_master_accepts_the_token_with_text_around_it():
|
|
# Every one of these is a shape that really occurs in the field, with the
|
|
# job-specific words replaced.
|
|
assert core.is_master_text('MASTER;reverse strands') is True
|
|
assert core.is_master_text('MASTER, bend bars to fit inside stirrups')
|
|
assert core.is_master_text('reverse strands; master') is True
|
|
assert core.is_master_text(' MASTER ') is True
|
|
assert core.is_master_text('MASTER-PANEL') is True
|
|
|
|
|
|
def test_master_rejects_an_empty_or_absent_field():
|
|
assert core.is_master_text(None) is False
|
|
assert core.is_master_text('') is False
|
|
assert core.is_master_text('deflect strand 1"') is False
|
|
|
|
|
|
def test_master_rejects_the_token_inside_another_word():
|
|
"""A whole-word rule, stated so it can be argued with rather than guessed.
|
|
|
|
`MASTERED` and `REMASTER` are not this plant's marker and are not treated
|
|
as one. The cost is that a plural or a typo is missed -- which is visible,
|
|
because the raw string is kept on the record as `master_note`.
|
|
"""
|
|
assert core.is_master_text('MASTERED') is False
|
|
assert core.is_master_text('REMASTER') is False
|
|
assert core.is_master_text('MASTERS') is False
|
|
|
|
|
|
def test_master_token_is_plant_knowledge_not_a_literal():
|
|
assert core.is_master_text('GEPRUEFT', 'GEPRUEFT') is True
|
|
assert core.is_master_text('MASTER', 'GEPRUEFT') is False
|
|
# An empty token switches the signal off rather than matching everything.
|
|
assert core.is_master_text('MASTER', '') is False
|
|
|
|
|
|
def test_a_piece_record_carries_the_boolean_and_the_raw_string():
|
|
rec = core.piece_record('PNL101', 1, 's', 1, 0, features=[],
|
|
master=True, master_note='MASTER;reverse strands')
|
|
assert rec['master'] is True
|
|
assert rec['master_note'] == 'MASTER;reverse strands'
|
|
|
|
|
|
def test_a_piece_record_defaults_to_not_a_master():
|
|
rec = core.piece_record('PNL102', 1, 's', 1, 0, features=[])
|
|
assert rec['master'] is False
|
|
assert rec['master_note'] is None
|
|
|
|
|
|
def test_the_signature_moves_when_a_piece_is_typed_up_as_master():
|
|
"""Without this the signal would never take effect.
|
|
|
|
Typing MASTER into the identity comment changes nothing else about the
|
|
piece -- not its members, not its flags -- so a stamp that ignored it
|
|
would leave the cached record standing and the new marking would do
|
|
nothing at all until something unrelated moved.
|
|
"""
|
|
before = core.signature(1, 0, [10, 11], master=False)
|
|
after = core.signature(1, 0, [10, 11], master=True)
|
|
assert before != after
|
|
|
|
|
|
def test_the_signature_moves_when_elevation_group_is_recorded():
|
|
"""TIER 1 (`elevation_group`) is cheap -- a single parameter read, the
|
|
same class of cost `master` already pays -- so it IS folded into the
|
|
stamp, unlike tier 2 (`elevation_band`, see below). Without this, MC
|
|
mapping `Tagged_Sheet` for the first time, or correcting a value on one
|
|
panel, would do nothing until something unrelated about the piece moved.
|
|
"""
|
|
before = core.signature(1, 0, [10, 11], elevation_group=None)
|
|
after = core.signature(1, 0, [10, 11], elevation_group='E3.16')
|
|
assert before != after
|
|
|
|
|
|
def test_the_signature_treats_case_and_whitespace_as_the_same_group():
|
|
"""`elevation_group_key`'s normalisation, at the seam that matters: two
|
|
spellings of the same sheet must not force a rebuild of a piece that has
|
|
not actually changed."""
|
|
assert (core.signature(1, 0, [10, 11], elevation_group='E3.16') ==
|
|
core.signature(1, 0, [10, 11], elevation_group=' e3.16 '))
|
|
|
|
|
|
def test_signature_is_five_pipe_joined_fields_of_flags_and_id_stamp():
|
|
"""The general shape, pinned so a future change to it is deliberate:
|
|
reinforced | on_hold | master | elevation_group | id-count | checksum
|
|
-- six segments now that tier 1 rejoined it."""
|
|
reinforced, on_hold, ids = 1, 0, [10, 11]
|
|
six_field = '{0}|{1}|{2}|{3}|{4}|{5}'.format(
|
|
1 if reinforced == 1 else 0, 1 if on_hold == 1 else 0, 0, '',
|
|
len(ids), sum(ids) % 1000000007)
|
|
assert core.signature(reinforced, on_hold, ids) == six_field
|
|
|
|
|
|
# --------------------------------------------------------- elevation_band
|
|
#
|
|
# Owner's ruling, 2026-09-02: elevation is a purely geometric fact -- same
|
|
# axis AND overlapping coordinate band, never a parameter. See
|
|
# `reinforcecore.elevation_band`'s module note for the full reasoning; these
|
|
# tests pin the definition itself, off plain numbers, no Revit involved.
|
|
|
|
def _basis(axis):
|
|
"""A clean unit basis vector along one global axis, for fixtures."""
|
|
return {'X': (1.0, 0.0, 0.0), 'Y': (0.0, 1.0, 0.0),
|
|
'Z': (0.0, 0.0, 1.0)}[axis]
|
|
|
|
|
|
def test_elevation_band_picks_the_smallest_dimension_as_thickness():
|
|
"""20 ft long, 10 ft wide, 0.667 ft (8 in) thick -- clean and
|
|
orthogonal. Local axis 2 (span 0.667) is thinnest; its basis (the
|
|
THIRD argument) points world +X, so the band lands on the X axis and
|
|
its range is read off the box at that axis.
|
|
"""
|
|
band = core.elevation_band(
|
|
[20.0, 10.0, 0.667],
|
|
_basis('Y'), _basis('Z'), _basis('X'),
|
|
[100.0, 0.0, 0.0], [100.667, 20.0, 10.0])
|
|
assert band == {'axis': 'X', 'min': 100.0, 'max': 100.667}
|
|
|
|
|
|
def test_elevation_band_normalises_a_reversed_lo_hi_pair():
|
|
band = core.elevation_band(
|
|
[20.0, 10.0, 0.667],
|
|
_basis('Y'), _basis('Z'), _basis('X'),
|
|
[100.667, 20.0, 10.0], [100.0, 0.0, 0.0])
|
|
assert band == {'axis': 'X', 'min': 100.0, 'max': 100.667}
|
|
|
|
|
|
def test_elevation_band_is_none_when_near_square():
|
|
"""Smallest and second-smallest spans are 8.0 and 8.5 -- ratio 1.0625,
|
|
under the 1.25 near-square ratio -- so which one is genuinely "the
|
|
thickness" cannot be told, and the band declines rather than guesses.
|
|
"""
|
|
band = core.elevation_band(
|
|
[20.0, 8.5, 8.0],
|
|
_basis('X'), _basis('Y'), _basis('Z'),
|
|
[0.0, 0.0, 0.0], [20.0, 8.5, 8.0],
|
|
near_square_ratio=1.25)
|
|
assert band is None
|
|
|
|
|
|
def test_elevation_band_is_none_when_not_cleanly_axis_aligned():
|
|
"""The thinnest axis's world direction is a 45-degree blend of X and Y
|
|
-- a skewed wall -- so no global axis is a clean match and the band
|
|
declines rather than forcing the nearest one."""
|
|
diag = (0.70710678, 0.70710678, 0.0)
|
|
band = core.elevation_band(
|
|
[20.0, 10.0, 0.667],
|
|
_basis('X'), _basis('Y'), diag,
|
|
[0.0, 0.0, 0.0], [10.0, 10.0, 10.0])
|
|
assert band is None
|
|
|
|
|
|
def test_elevation_band_declines_gracefully_on_bad_input():
|
|
"""Never raises: bad input is could-not-judge, the same as ambiguous or
|
|
off-axis geometry is."""
|
|
assert core.elevation_band(None, (1, 0, 0), (0, 1, 0), (0, 0, 1),
|
|
[0, 0, 0], [1, 1, 1]) is None
|
|
assert core.elevation_band([1.0, 2.0], (1, 0, 0), (0, 1, 0), (0, 0, 1),
|
|
[0, 0, 0], [1, 1, 1]) is None
|
|
assert core.elevation_band([1.0, 2.0, 0.5], (1, 0, 0), (0, 1, 0),
|
|
(0, 0, 1), None, [1, 1, 1]) is None
|
|
|
|
|
|
# ----------------------------------------------------------- same_elevation
|
|
|
|
def test_same_elevation_true_for_overlapping_bands_on_the_same_axis():
|
|
a = {'axis': 'X', 'min': 100.0, 'max': 100.667}
|
|
b = {'axis': 'X', 'min': 100.1, 'max': 100.767}
|
|
assert core.same_elevation(a, b) is True
|
|
|
|
|
|
def test_same_elevation_false_for_non_overlapping_bands_on_the_same_axis():
|
|
a = {'axis': 'X', 'min': 100.0, 'max': 100.667}
|
|
b = {'axis': 'X', 'min': 120.0, 'max': 120.667}
|
|
assert core.same_elevation(a, b) is False
|
|
|
|
|
|
def test_same_elevation_false_for_a_different_axis_even_when_close():
|
|
"""The owner's explicit ruling: perpendicular walls never match, however
|
|
numerically close the coordinates happen to be."""
|
|
a = {'axis': 'X', 'min': 100.0, 'max': 100.667}
|
|
b = {'axis': 'Y', 'min': 100.0, 'max': 100.667}
|
|
assert core.same_elevation(a, b) is False
|
|
|
|
|
|
def test_same_elevation_false_when_bands_only_abut():
|
|
"""Pinned: touching at exactly one point (no interior overlap) is NOT
|
|
the same elevation -- the signature of a stepped facade, not a shared
|
|
wall plane. See `same_elevation`'s own docstring for the reasoning."""
|
|
a = {'axis': 'X', 'min': 100.0, 'max': 100.667}
|
|
b = {'axis': 'X', 'min': 100.667, 'max': 101.334}
|
|
assert core.same_elevation(a, b) is False
|
|
|
|
|
|
def test_same_elevation_false_when_either_band_is_unknown():
|
|
"""UNKNOWN IS NEVER A MATCH -- two could-not-judge bands never agree
|
|
with each other, and a known band is never matched against nothing."""
|
|
a = {'axis': 'X', 'min': 100.0, 'max': 100.667}
|
|
assert core.same_elevation(a, None) is False
|
|
assert core.same_elevation(None, a) is False
|
|
assert core.same_elevation(None, None) is False
|
|
|
|
|
|
def test_same_elevation_never_raises_on_a_malformed_band():
|
|
assert core.same_elevation({'axis': 'X'},
|
|
{'axis': 'X', 'min': 1, 'max': 2}) is False
|
|
assert core.same_elevation('not a dict',
|
|
{'axis': 'X', 'min': 1, 'max': 2}) is False
|
|
|
|
|
|
# ----------------------------------------------------- elevation_group_key
|
|
#
|
|
# TIER 1. Owner's ruling, 2026-09-02 (the SAME day as the geometric ruling):
|
|
# MC's model already carries a grouping parameter (`Tagged_Sheet`, mapped
|
|
# through the role `panel.elevation_group`) -- two panels with the same
|
|
# non-empty value are the same elevation with no geometry involved. "对MC而
|
|
# 言,所有的墙应该都有一个参数叫Tagged_Sheet。如果俩panel这个参数一样,那么
|
|
# 他们就是同一个elevation,不用跑哪个几何引擎。"
|
|
|
|
def test_elevation_group_key_normalises_case_and_whitespace():
|
|
assert (core.elevation_group_key('E3.16') ==
|
|
core.elevation_group_key(' e3.16 '))
|
|
|
|
|
|
def test_elevation_group_key_is_none_for_none_and_for_blank():
|
|
"""Unmapped role and never-typed are the same could-not-judge answer."""
|
|
assert core.elevation_group_key(None) is None
|
|
assert core.elevation_group_key('') is None
|
|
assert core.elevation_group_key(' ') is None
|
|
|
|
|
|
def test_elevation_group_key_never_raises_on_a_non_string():
|
|
assert core.elevation_group_key(42) is None
|
|
assert core.elevation_group_key([1, 2]) is None
|
|
|
|
|
|
def test_two_different_groups_are_different_keys():
|
|
assert core.elevation_group_key('E3.16') != core.elevation_group_key(
|
|
'E3.22')
|
|
|
|
|
|
# --------------------------------------------------------- elevation_match
|
|
#
|
|
# The two-tier comparator itself: tier 1 (`elevation_group`) tried first,
|
|
# tier 2 (`elevation_band`) as the fallback. See the module note above
|
|
# `elevation_band` for the full ordering rule.
|
|
|
|
_BAND_X = {'axis': 'X', 'min': 100.0, 'max': 100.667}
|
|
_BAND_X_OVERLAP = {'axis': 'X', 'min': 100.1, 'max': 100.767}
|
|
_BAND_Y = {'axis': 'Y', 'min': 100.0, 'max': 100.667}
|
|
|
|
|
|
def test_elevation_match_by_group_needs_no_geometry_at_all():
|
|
"""Same non-empty group, WILDLY different (even absent) bands -- the
|
|
group still decides it, exactly the MC shortcut."""
|
|
target = {'elevation_group': 'E3.16', 'elevation_band': _BAND_X}
|
|
donor = {'elevation_group': ' e3.16 ', 'elevation_band': None}
|
|
assert core.elevation_match(target, donor) == core.ELEVATION_BY_GROUP
|
|
|
|
|
|
def test_elevation_match_falls_back_to_band_when_group_does_not_answer():
|
|
target = {'elevation_group': None, 'elevation_band': _BAND_X}
|
|
donor = {'elevation_group': None, 'elevation_band': _BAND_X_OVERLAP}
|
|
assert core.elevation_match(target, donor) == core.ELEVATION_BY_BAND
|
|
|
|
|
|
def test_elevation_match_falls_back_to_band_when_groups_differ():
|
|
"""A group mismatch does not veto the geometric answer -- it simply
|
|
fails to answer tier 1, and tier 2 gets to try."""
|
|
target = {'elevation_group': 'E3.16', 'elevation_band': _BAND_X}
|
|
donor = {'elevation_group': 'E3.22', 'elevation_band': _BAND_X_OVERLAP}
|
|
assert core.elevation_match(target, donor) == core.ELEVATION_BY_BAND
|
|
|
|
|
|
def test_elevation_match_is_none_when_neither_tier_answers():
|
|
target = {'elevation_group': None, 'elevation_band': _BAND_X}
|
|
donor = {'elevation_group': None, 'elevation_band': _BAND_Y}
|
|
assert core.elevation_match(target, donor) is None
|
|
|
|
|
|
def test_elevation_match_never_raises_on_missing_keys():
|
|
"""A pre-migration record with neither field at all -- could-not-judge,
|
|
not an exception."""
|
|
assert core.elevation_match({}, {}) is None
|
|
assert core.elevation_match(None, None) is None
|
|
assert core.elevation_match({'elevation_group': 'E3.16'}, {}) is None
|
|
|
|
|
|
# ------------------------------------------------------- piece_record: band
|
|
|
|
def test_a_piece_record_carries_its_elevation_band():
|
|
band = {'axis': 'X', 'min': 100.0, 'max': 100.667}
|
|
rec = core.piece_record('PNL425', 1, 's', 1, 0, features=[],
|
|
elevation_band=band)
|
|
assert rec['elevation_band'] == band
|
|
|
|
|
|
def test_a_piece_record_defaults_to_no_elevation_band():
|
|
"""None, not a missing key -- a record built with no argument still
|
|
reads the same could-not-judge value one built for an ambiguous or
|
|
off-axis panel would."""
|
|
rec = core.piece_record('PNL426', 1, 's', 1, 0, features=[])
|
|
assert rec['elevation_band'] is None
|
|
|
|
|
|
def test_elevation_band_survives_a_save_and_load_round_trip(tmp_path):
|
|
path = str(tmp_path / 'index.json')
|
|
band = {'axis': 'X', 'min': 100.0, 'max': 100.667}
|
|
cache = core.new_cache('doc')
|
|
cache['pieces'] = {
|
|
'A': core.piece_record('A', 1, 's', 1, 0, features=[],
|
|
elevation_band=band),
|
|
}
|
|
core.save(path, cache)
|
|
back = core.load(path)
|
|
assert back['pieces']['A']['elevation_band'] == band
|
|
|
|
|
|
# ------------------------------------------------------ piece_record: group
|
|
|
|
def test_a_piece_record_carries_its_elevation_group():
|
|
rec = core.piece_record('PNL425', 1, 's', 1, 0, features=[],
|
|
elevation_group='E3.16')
|
|
assert rec['elevation_group'] == 'E3.16'
|
|
|
|
|
|
def test_a_piece_record_defaults_to_no_elevation_group():
|
|
rec = core.piece_record('PNL426', 1, 's', 1, 0, features=[])
|
|
assert rec['elevation_group'] is None
|
|
|
|
|
|
def test_elevation_group_survives_a_save_and_load_round_trip(tmp_path):
|
|
path = str(tmp_path / 'index.json')
|
|
cache = core.new_cache('doc')
|
|
cache['pieces'] = {
|
|
'A': core.piece_record('A', 1, 's', 1, 0, features=[],
|
|
elevation_group='E3.16'),
|
|
}
|
|
core.save(path, cache)
|
|
back = core.load(path)
|
|
assert back['pieces']['A']['elevation_group'] == 'E3.16'
|
|
|
|
|
|
def test_masters_lists_only_the_reviewed_donors():
|
|
cache = core.new_cache('doc')
|
|
cache['pieces'] = {
|
|
'A': core.piece_record('A', 1, 's', 1, 0, features=[], master=True),
|
|
'B': core.piece_record('B', 2, 's', 1, 0, features=[]),
|
|
'C': core.piece_record('C', 3, 's', 1, 0, error='unreadable',
|
|
master=True),
|
|
}
|
|
assert core.masters(cache) == ['A']
|
|
assert core.masters(cache, include_unjudged=True) == ['A', 'C']
|
|
|
|
|
|
def test_master_survives_a_save_and_load_round_trip(tmp_path):
|
|
path = str(tmp_path / 'index.json')
|
|
cache = core.new_cache('doc')
|
|
cache['pieces'] = {
|
|
'A': core.piece_record('A', 1, 's', 1, 0, features=[],
|
|
master=True, master_note='MASTER'),
|
|
}
|
|
core.save(path, cache)
|
|
back = core.load(path)
|
|
assert back['pieces']['A']['master'] is True
|
|
assert back['pieces']['A']['master_note'] == 'MASTER'
|
|
|
|
|
|
def test_an_index_built_before_master_existed_is_not_read_as_no_masters(
|
|
tmp_path):
|
|
"""The whole answer to "what happens to yesterday's cache".
|
|
|
|
A file written under the previous schema is thrown away WHOLE. It is
|
|
never partially adopted, so not one piece from it can be served as a
|
|
record that was checked for MASTER and found not to be one. It costs a
|
|
rebuild, which is six seconds, and the reason is reported.
|
|
"""
|
|
path = str(tmp_path / 'index.json')
|
|
old = {'schema': core.SCHEMA - 1, 'doc_key': 'doc', 'built_at': 'y',
|
|
'pieces': {'A': {'name': 'A', 'sig': 's', 'features': []}}}
|
|
handle = open(path, 'wb')
|
|
handle.write(json.dumps(old).encode('utf-8'))
|
|
handle.close()
|
|
|
|
cache = core.load(path)
|
|
assert cache['pieces'] == {}
|
|
assert cache['_load_error']
|
|
# and nothing from it can be mistaken for a judged non-master
|
|
assert core.masters(cache) == []
|
|
|
|
|
|
def test_master_unknown_is_not_master_false():
|
|
"""The role unmapped is a third answer and the record keeps it.
|
|
|
|
`master=None` means the plant has not said which parameter carries the
|
|
marker, so nothing was read. It must not collapse into False, which is
|
|
the verdict of a field that WAS read.
|
|
"""
|
|
rec = core.piece_record('PNL103', 1, 's', 1, 0, features=[], master=None)
|
|
assert rec['master'] is None
|
|
|
|
cache = core.new_cache('doc')
|
|
cache['pieces'] = {'PNL103': rec}
|
|
assert core.masters(cache) == []
|
|
assert core.master_signal_read(cache) is False
|
|
|
|
|
|
def test_the_signal_reads_as_run_as_soon_as_one_piece_was_judged():
|
|
cache = core.new_cache('doc')
|
|
cache['pieces'] = {
|
|
'A': core.piece_record('A', 1, 's', 1, 0, features=[], master=False),
|
|
}
|
|
assert core.master_signal_read(cache) is True
|
|
assert core.masters(cache) == []
|
|
|
|
|
|
# ------------------------------------------------------ the design number
|
|
#
|
|
# `design_number` decides who may be a template at all (owner ruling,
|
|
# 2026-09-01: a donor must be the same design as its target). Before that rule
|
|
# may be trusted, this has to answer whether the index records the field --
|
|
# and there are THREE answers, not two.
|
|
|
|
|
|
def test_a_recorded_design_number_reads_as_recorded():
|
|
assert core.design_status([
|
|
core.piece_record('A', 1, 's', 1, 0, design_number='PNL1.0'),
|
|
core.piece_record('B', 2, 's', 1, 0),
|
|
]) == core.DESIGN_RECORDED
|
|
|
|
|
|
def test_records_that_all_carry_an_empty_design_number_read_as_unread():
|
|
"""Measured on the real index, 2026-09-01: 773 pieces, every one of them
|
|
`design_number: null`, because the build ran with no knowledge layer. The
|
|
field was there and nobody had read it -- which is 'nobody looked', not
|
|
'every piece matches'."""
|
|
assert core.design_status([
|
|
core.piece_record('A', 1, 's', 1, 0),
|
|
core.piece_record('B', 2, 's', 1, 0),
|
|
]) == core.DESIGN_UNREAD
|
|
|
|
|
|
def test_records_written_before_the_field_read_as_not_recorded():
|
|
old = core.piece_record('A', 1, 's', 1, 0)
|
|
del old['design_number']
|
|
assert core.design_status([old]) == core.DESIGN_NOT_RECORDED
|
|
|
|
|
|
def test_nothing_at_all_is_not_recorded_never_recorded():
|
|
"""An empty sequence is 'nothing was looked at', which is the
|
|
could-not-judge answer and not a clean bill."""
|
|
assert core.design_status([]) == core.DESIGN_NOT_RECORDED
|
|
assert core.design_status(None) == core.DESIGN_NOT_RECORDED
|
|
|
|
|
|
def test_an_index_that_never_stamped_a_pattern_says_none_not_a_pattern():
|
|
"""None means 'this file does not say', and the caller then falls back the
|
|
way an unmapped nomenclature always has. It is not 'no pattern'."""
|
|
assert core.design_re_in(core.new_cache('doc')) is None
|
|
assert core.design_re_in({}) is None
|
|
assert core.design_re_in({'nomenclature': 'nonsense'}) is None
|
|
assert core.design_re_in({'nomenclature': {'design_number_re': ''}}) is None
|
|
|
|
|
|
def test_the_stamped_pattern_survives_a_save_and_load_round_trip(tmp_path):
|
|
"""The offline reader has no knowledge layer, so the pattern the button
|
|
reduced designs with has to be IN THE FILE or the two disagree."""
|
|
path = str(tmp_path / 'index.json')
|
|
cache = core.new_cache('doc')
|
|
cache['nomenclature'] = {'design_number_re': r'^([A-Za-z]+)'}
|
|
core.save(path, cache)
|
|
assert core.design_re_in(core.load(path)) == r'^([A-Za-z]+)'
|
|
|
|
|
|
def test_the_three_design_answers_are_three_different_strings():
|
|
assert len({core.DESIGN_RECORDED, core.DESIGN_UNREAD,
|
|
core.DESIGN_NOT_RECORDED}) == 3
|
|
|
|
|
|
# ------------------------------------------------- targets, and who may donate
|
|
#
|
|
# ELIGIBILITY FILTERS DONORS. IT MUST NOT DECIDE WHO CAN BE A TARGET.
|
|
#
|
|
# Up to schema 4 it decided both, and the consequence was invisible until
|
|
# somebody asked to run with Revit closed: the piece an engineer wants
|
|
# suggestions FOR is the one not yet reinforced, so it was the one piece the
|
|
# index never held. `plan(include=...)` measures it too. Nothing about the
|
|
# donor rule moves, and these tests are mostly about that: the pool must be
|
|
# exactly the same size afterwards.
|
|
|
|
def test_a_piece_that_may_not_donate_is_still_measured_as_a_target():
|
|
cache = core.new_cache('m')
|
|
live = _live(A=('sA', 1, 0), # a donor
|
|
T=('sT', 0, 0)) # not reinforced -- an ordinary target
|
|
plan = core.plan(cache, live, include=lambda name: name == 'T')
|
|
assert plan['build'] == ['A', 'T']
|
|
assert plan['donors'] == ['A']
|
|
assert plan['targets'] == ['T']
|
|
|
|
|
|
def test_without_a_predicate_the_index_is_donors_only_as_before():
|
|
cache = core.new_cache('m')
|
|
plan = core.plan(cache, _live(A=('sA', 1, 0), T=('sT', 0, 0)))
|
|
assert plan['build'] == ['A']
|
|
assert plan['targets'] == []
|
|
|
|
|
|
def test_widening_what_is_measured_never_widens_the_donor_pool():
|
|
"""The whole safety of the change, as arithmetic."""
|
|
cache = core.new_cache('m')
|
|
cache['pieces'] = {
|
|
'A': core.piece_record('A', 1, 's', 1, 0, features=[]),
|
|
'T': core.piece_record('T', 2, 's', 0, 0, features=[]),
|
|
'H': core.piece_record('H', 3, 's', 1, 1, features=[]),
|
|
}
|
|
assert [r['name'] for r in core.donors(cache)] == ['A']
|
|
assert [r['name'] for r in core.targets(cache)] == ['H', 'T']
|
|
|
|
|
|
def test_a_target_typed_master_is_not_counted_as_a_reviewed_donor():
|
|
"""A MASTER that may not donate is not a template this answer could use."""
|
|
cache = core.new_cache('m')
|
|
cache['pieces'] = {
|
|
'A': core.piece_record('A', 1, 's', 1, 0, features=[], master=True),
|
|
'T': core.piece_record('T', 2, 's', 0, 0, features=[], master=True),
|
|
}
|
|
assert core.masters(cache) == ['A']
|
|
|
|
|
|
def test_coverage_counts_donors_and_targets_apart():
|
|
cache = core.new_cache('m')
|
|
live = _live(A=('sA', 1, 0), B=('sB', 1, 0), T=('sT', 0, 0))
|
|
plan = core.plan(cache, live, include=lambda name: True)
|
|
cache = core.apply_plan(cache, plan, [
|
|
core.piece_record('A', 1, 'sA', 1, 0, features=[]),
|
|
core.piece_record('T', 3, 'sT', 0, 0, features=[]),
|
|
])
|
|
cov = core.coverage(cache, plan)
|
|
assert cov['eligible'] == 2 # A and B -- never T
|
|
assert cov['indexed'] == 1
|
|
assert cov['missing'] == ['B']
|
|
assert cov['targets'] == 1
|
|
assert cov['complete'] is False
|
|
|
|
|
|
def test_a_plan_without_the_donor_split_still_reads_as_it_did():
|
|
"""Back-compat: a hand-built plan from before the split."""
|
|
cache = core.new_cache('m')
|
|
cache['pieces'] = {'A': core.piece_record('A', 1, 's', 1, 0, features=[])}
|
|
cov = core.coverage(cache, {'build': [], 'fresh': ['A']})
|
|
assert cov['eligible'] == 1 and cov['complete'] is True
|
|
|
|
|
|
def test_a_predicate_that_raises_measures_the_piece_rather_than_dropping_it():
|
|
"""Could-not-tell may only ever resolve towards measuring, never towards
|
|
a silent exclusion -- `vibe.scope`'s standing rule, applied here."""
|
|
def boom(_name):
|
|
raise ValueError('no idea')
|
|
|
|
plan = core.plan(core.new_cache('m'), _live(T=('sT', 0, 0)), include=boom)
|
|
assert plan['build'] == ['T']
|
|
|
|
|
|
# ------------------------------------------- what must never reach the file
|
|
|
|
def test_a_load_error_is_never_written_into_the_index():
|
|
"""Measured on the real index, 2026-08-30: a healthy schema-4 file carried
|
|
`_load_error: schema 3, expected 4` for good, because `load()` stamps the
|
|
key on the empty cache it returns, `refresh` fills that same dict and
|
|
saves it. Every later press then reported a load failure that had not
|
|
happened and forced a rebuild it did not need."""
|
|
import os
|
|
import tempfile
|
|
folder = tempfile.mkdtemp()
|
|
path = os.path.join(folder, 'index.json')
|
|
cache = core.new_cache('m')
|
|
cache['_load_error'] = 'schema 4, expected 5'
|
|
cache['_anything_else'] = 'also transient'
|
|
cache['pieces'] = {'A': core.piece_record('A', 1, 's', 1, 0, features=[])}
|
|
core.save(path, cache)
|
|
|
|
raw = open(path, 'rb').read().decode('utf-8')
|
|
assert '_load_error' not in raw
|
|
assert '_anything_else' not in raw
|
|
assert core.load(path).get('_load_error') is None
|
|
|
|
|
|
def test_the_declared_scope_survives_a_round_trip():
|
|
"""The offline reader has no knowledge layer, so the scope has to travel
|
|
in the file itself."""
|
|
import os
|
|
import tempfile
|
|
path = os.path.join(tempfile.mkdtemp(), 'index.json')
|
|
cache = core.new_cache('m')
|
|
cache['scope'] = {'prefixes': ['PNL'], 'note': 'cladding only',
|
|
'declared_by': 'somebody'}
|
|
core.save(path, cache)
|
|
assert core.load(path)['scope']['prefixes'] == ['PNL']
|
|
|
|
|
|
def test_a_stray_load_error_in_a_healthy_file_is_not_a_load_error():
|
|
"""The leg that turned a cosmetic defect into a full re-scan per press.
|
|
|
|
Files written before `_pack` learned to strip transient keys carry the
|
|
message from the schema bump that CAUSED the rebuild, copied into the
|
|
rebuilt file. A reader that believes it rebuilds the index on every press
|
|
for ever. This file parsed and its schema matches; what some earlier load
|
|
thought is not a fact about it.
|
|
"""
|
|
import json
|
|
import os
|
|
import tempfile
|
|
path = os.path.join(tempfile.mkdtemp(), 'index.json')
|
|
healthy = {'schema': core.SCHEMA, 'doc_key': 'm', 'built_at': 'x',
|
|
'scope': None, 'pieces': {},
|
|
'_load_error': 'schema 4, expected 5'}
|
|
handle = open(path, 'wb')
|
|
try:
|
|
handle.write(json.dumps(healthy).encode('utf-8'))
|
|
finally:
|
|
handle.close()
|
|
|
|
got = core.load(path)
|
|
assert got.get('_load_error') is None
|
|
assert got['built_at'] == 'x' # and the file was otherwise adopted
|
|
|
|
|
|
def test_a_real_schema_mismatch_still_discards_the_whole_file(tmp_path):
|
|
"""The third leg: a genuine mismatch must still be reported once, and
|
|
nothing may be adopted from it."""
|
|
import json
|
|
path = str(tmp_path / 'index.json')
|
|
old = {'schema': core.SCHEMA - 1, 'doc_key': 'm', 'built_at': 'x',
|
|
'pieces': {'A': {'name': 'A'}}}
|
|
handle = open(path, 'wb')
|
|
try:
|
|
handle.write(json.dumps(old).encode('utf-8'))
|
|
finally:
|
|
handle.close()
|
|
|
|
got = core.load(path)
|
|
assert got['_load_error']
|
|
assert got['pieces'] == {}
|
|
assert got['built_at'] is None
|
|
|
|
|
|
def test_the_plan_a_two_argument_caller_gets_matches_the_one_refresh_builds():
|
|
"""The button computes its own plan with two arguments, to size a
|
|
progress bar and to decide whether a refresh is needed at all. If that
|
|
plan were donors-only while `refresh`'s was not, every target in the file
|
|
would land in its `drop` list on every press -- and a caller that fed that
|
|
plan to `apply_plan` would evict them all."""
|
|
cache = core.new_cache('m')
|
|
cache['scope'] = {'prefixes': ['PNL'], 'note': None, 'declared_by': None}
|
|
cache['pieces'] = {
|
|
'PNL1': core.piece_record('PNL1', 1, 's1', 1, 0, features=[]),
|
|
'PNL2': core.piece_record('PNL2', 2, 's2', 0, 0, features=[]),
|
|
}
|
|
live = _live(PNL1=('s1', 1, 0), PNL2=('s2', 0, 0))
|
|
plan = core.plan(cache, live) # two arguments, as the button
|
|
assert plan['drop'] == []
|
|
assert plan['build'] == []
|
|
assert plan['targets'] == ['PNL2']
|
|
|
|
|
|
def test_an_index_with_no_scope_stamp_keeps_the_donors_only_behaviour():
|
|
"""A file that never recorded a scope has no targets in it to protect."""
|
|
cache = core.new_cache('m') # scope is None
|
|
plan = core.plan(cache, _live(A=('sA', 1, 0), T=('sT', 0, 0)))
|
|
assert plan['build'] == ['A']
|
|
|
|
|
|
# ==================================================================
|
|
# THE FEATURE REGISTRY (schema 6). The feature-id is an INDEX/ACCELERATOR
|
|
# only: `covers()` stays the sole ranker, the id never ranks and never becomes
|
|
# a tolerance bin. Three owner-locked decisions (2026-09-02): the grain is the
|
|
# index's EXISTING rounding (no new tolerance), the edge condition is the
|
|
# binary concrete-edge vs block-out-edge, and the id is a positive integer.
|
|
# These fixtures are neutral -- generic piece marks, no client or plant names.
|
|
# ==================================================================
|
|
|
|
# A strip (ligament) feature, in the shape `reinforcebind._record` produces:
|
|
# both axes, plus the derived `strip_ft` (width across) and `span_ft` (span
|
|
# along), `between`, and the outline flag. The registry reads exactly these.
|
|
def _strip(kind, x0, x1, y0, y1, between, outline_approximate=False, edge=None):
|
|
x0, x1, y0, y1 = float(x0), float(x1), float(y0), float(y1)
|
|
strip = (x1 - x0) if kind == 'strip_x' else (y1 - y0)
|
|
span = (y1 - y0) if kind == 'strip_x' else (x1 - x0)
|
|
return {'kind': kind, 'x0': x0, 'x1': x1, 'y0': y0, 'y1': y1,
|
|
'w': round(x1 - x0, 3), 'h': round(y1 - y0, 3),
|
|
'strip_ft': round(strip, 3), 'span_ft': round(span, 3),
|
|
'between': between, 'edge': edge,
|
|
'outline_approximate': outline_approximate}
|
|
|
|
|
|
# --------------------------------------------- descriptor: determinism + grain
|
|
|
|
def test_a_descriptor_is_deterministic_hashable_and_json_serialisable():
|
|
f = _strip('strip_x', 0.0, 3.0, 1.0, 2.0, 'void-void')
|
|
d1 = core.canonical_descriptor(f)
|
|
d2 = core.canonical_descriptor(dict(f))
|
|
assert d1 == d2
|
|
assert hash(d1) == hash(d2) # hashable
|
|
assert {d1: 'ok'}[d2] == 'ok' # usable as a dict key
|
|
assert json.loads(json.dumps(d1)) == list( # JSON-serialisable
|
|
[d1[0], list(d1[1]), d1[2], d1[3]])
|
|
|
|
|
|
def test_the_grain_is_the_existing_rounding_and_no_new_tolerance():
|
|
"""3.00 vs 3.02 -> DIFFERENT (covers() ranks them); 3.000 vs 3.0004 ->
|
|
SAME (the index's existing 3-dp / EXACT_TOL grain already collapsed them).
|
|
Driven by `canonical_descriptor`'s own `_f`, not a fresh bin -- the values
|
|
are passed raw here to prove the descriptor does the graining."""
|
|
def strip(width):
|
|
return {'kind': 'strip_x', 'strip_ft': width, 'span_ft': 1.0,
|
|
'between': 'void-void'}
|
|
assert (core.canonical_descriptor(strip(3.00)) !=
|
|
core.canonical_descriptor(strip(3.02)))
|
|
assert (core.canonical_descriptor(strip(3.000)) ==
|
|
core.canonical_descriptor(strip(3.0004)))
|
|
|
|
|
|
# ------------------------------------------------- descriptor: the binary edge
|
|
|
|
def test_the_specific_panel_edge_is_dropped_from_the_descriptor():
|
|
"""A VOID_EDGE strip against x0 and one against x1 -- same kind and dims --
|
|
share ONE descriptor: only concrete-vs-blockout is encoded, never the
|
|
x0/x1/y0/y1 sub-label."""
|
|
at_x0 = _strip('strip_x', 0.0, 2.0, 1.0, 3.0, 'void-edge', edge='x0')
|
|
at_x1 = _strip('strip_x', 5.0, 7.0, 1.0, 3.0, 'void-edge', edge='x1')
|
|
assert (core.canonical_descriptor(at_x0) ==
|
|
core.canonical_descriptor(at_x1))
|
|
|
|
|
|
def test_concrete_edge_and_block_out_edge_are_distinguished():
|
|
"""Same kind and dims, differing only in `between`: a VOID_EDGE (touches a
|
|
concrete panel edge) and a VOID_VOID (between two block-outs) land in
|
|
DIFFERENT descriptors."""
|
|
edge = _strip('strip_x', 0.0, 2.0, 1.0, 3.0, 'void-edge')
|
|
between = _strip('strip_x', 5.0, 7.0, 1.0, 3.0, 'void-void')
|
|
assert (core.canonical_descriptor(edge) !=
|
|
core.canonical_descriptor(between))
|
|
assert core.canonical_descriptor(edge)[2] == 'concrete_edge'
|
|
assert core.canonical_descriptor(between)[2] == 'block_out_edge'
|
|
|
|
|
|
def test_an_opening_is_block_out_edge_and_uses_its_own_dims():
|
|
op = core.feature('P1', 0, 1.0, 4.0, 2.0, 5.0, True, 0.0, 20.0, 10.0, 8.0)
|
|
d = core.canonical_descriptor(op)
|
|
assert d[0] == core.KIND_OPENING
|
|
assert d[1] == (3.0, 3.0) # (w, h)
|
|
assert d[2] == 'block_out_edge'
|
|
assert d[3] is False
|
|
|
|
|
|
# ------------------------------------------------------- descriptor: approx
|
|
|
|
def test_an_approximate_edge_feature_never_shares_a_descriptor_with_a_measured():
|
|
measured = _strip('strip_x', 0.0, 2.0, 1.0, 3.0, 'void-edge')
|
|
approx = _strip('strip_x', 0.0, 2.0, 1.0, 3.0, 'void-edge',
|
|
outline_approximate=True)
|
|
d_m = core.canonical_descriptor(measured)
|
|
d_a = core.canonical_descriptor(approx)
|
|
assert d_m != d_a
|
|
assert d_m[3] is False and d_a[3] is True
|
|
reg = core.Registry()
|
|
assert reg.get_or_add(d_m) != reg.get_or_add(d_a) # -> different ids
|
|
|
|
|
|
def test_outline_approximate_only_splits_edge_touching_features():
|
|
"""A VOID_VOID strip measures exactly even on an approximate-outline piece,
|
|
exactly as `covers()` treats it -- so the flag must NOT split it off. Only
|
|
edge-touching features (VOID_EDGE, opening) carry the approximation."""
|
|
plain = _strip('strip_x', 0.0, 2.0, 1.0, 3.0, 'void-void')
|
|
approx = _strip('strip_x', 0.0, 2.0, 1.0, 3.0, 'void-void',
|
|
outline_approximate=True)
|
|
assert (core.canonical_descriptor(plain) ==
|
|
core.canonical_descriptor(approx))
|
|
|
|
|
|
# ---------------------------------------------------- the ids themselves
|
|
|
|
def test_ids_are_positive_sequential_and_deduped_across_panels():
|
|
reg = core.Registry()
|
|
a = _strip('strip_x', 0.0, 3.0, 0.0, 2.0, 'void-void')
|
|
b = _strip('strip_y', 0.0, 2.0, 0.0, 3.0, 'void-void')
|
|
id_a = reg.get_or_add(core.canonical_descriptor(a))
|
|
id_b = reg.get_or_add(core.canonical_descriptor(b))
|
|
assert id_a == 1 and id_b == 2 # positive, sequential from 1
|
|
# DEDUP: an identical descriptor on another panel (different absolute
|
|
# position, same kind/dims/edge) shares the id.
|
|
elsewhere = _strip('strip_x', 10.0, 13.0, 4.0, 6.0, 'void-void')
|
|
assert reg.get_or_add(core.canonical_descriptor(elsewhere)) == id_a
|
|
|
|
|
|
def test_ids_are_stable_across_a_rebuild_and_never_renumbered():
|
|
a = {'kind': 'strip_x', 'strip_ft': 3.0, 'span_ft': 2.0,
|
|
'between': 'void-void'}
|
|
b = {'kind': 'opening', 'w': 3.0, 'h': 3.0}
|
|
c = {'kind': 'strip_y', 'strip_ft': 1.0, 'span_ft': 4.0,
|
|
'between': 'void-edge'}
|
|
cache = core.new_cache('m')
|
|
reg1 = core.register_features(cache, [{'name': 'P1', 'features': [a, b]}])
|
|
id_a = reg1.lookup(core.canonical_descriptor(a))
|
|
id_b = reg1.lookup(core.canonical_descriptor(b))
|
|
# A rebuild re-registers off the SAME persisted registry (cache['registry']
|
|
# was updated in place), and a NEW descriptor is appended, not renumbered.
|
|
reg2 = core.register_features(cache, [{'name': 'P1', 'features': [a, b, c]}])
|
|
assert reg2.lookup(core.canonical_descriptor(a)) == id_a
|
|
assert reg2.lookup(core.canonical_descriptor(b)) == id_b
|
|
assert reg2.lookup(core.canonical_descriptor(c)) == 3
|
|
|
|
|
|
def test_register_features_skips_a_never_measured_record():
|
|
cache = core.new_cache('m')
|
|
reg = core.register_features(cache, [
|
|
{'name': 'P1', 'features': None}, # could-not-judge
|
|
{'name': 'P2', 'features': [
|
|
{'kind': 'opening', 'w': 2.0, 'h': 2.0}]},
|
|
])
|
|
assert reg.serialize()['next_id'] == 2 # one descriptor, from P2
|
|
|
|
|
|
# --------------------------------------------------- lookup is read-only
|
|
|
|
def test_lookup_is_read_only_and_never_grows_the_vocabulary():
|
|
"""A target-style lookup of an ABSENT descriptor returns None and does not
|
|
add it or move next_id -- the target must never mutate the shared
|
|
vocabulary."""
|
|
reg = core.Registry()
|
|
reg.get_or_add(core.canonical_descriptor(
|
|
_strip('strip_x', 0.0, 3.0, 0.0, 2.0, 'void-void')))
|
|
before = reg.serialize()
|
|
absent = core.canonical_descriptor(
|
|
_strip('strip_y', 0.0, 2.0, 0.0, 9.0, 'void-edge'))
|
|
assert reg.lookup(absent) is None
|
|
assert reg.serialize() == before # unchanged, next_id and all
|
|
assert reg.serialize()['next_id'] == 2
|
|
|
|
|
|
# --------------------------------------------------- feature_ids_of projection
|
|
|
|
def test_feature_ids_of_is_a_correct_derived_projection():
|
|
a = {'kind': 'strip_x', 'strip_ft': 3.0, 'span_ft': 2.0,
|
|
'between': 'void-void'}
|
|
b = {'kind': 'opening', 'w': 3.0, 'h': 3.0}
|
|
cache = core.new_cache('m')
|
|
reg = core.register_features(cache, [{'name': 'P1', 'features': [a, b]}])
|
|
record = {'name': 'P1', 'features': [a, b]}
|
|
ids = core.feature_ids_of(record, reg)
|
|
assert ids == [reg.lookup(core.canonical_descriptor(a)),
|
|
reg.lookup(core.canonical_descriptor(b))]
|
|
assert ids == [1, 2]
|
|
# DERIVED, not stored: nothing wrote a feature-id array onto the record.
|
|
assert 'feature_ids' not in record
|
|
|
|
|
|
def test_feature_ids_of_projects_an_unregistered_feature_to_none():
|
|
"""A target feature no donor ever produced is an accelerator MISS (None),
|
|
which `covers()` then resolves the ordinary way -- never a fresh id, so the
|
|
read-only projection cannot grow the vocabulary."""
|
|
reg = core.Registry() # empty vocabulary
|
|
novel = {'kind': 'opening', 'w': 5.0, 'h': 5.0}
|
|
assert core.feature_ids_of({'features': [novel]}, reg) == [None]
|
|
assert reg.serialize()['next_id'] == 1 # not grown
|
|
|
|
|
|
def test_feature_ids_of_propagates_could_not_judge_as_none():
|
|
reg = core.Registry()
|
|
assert core.feature_ids_of({'features': None}, reg) is None
|
|
assert core.feature_ids_of({'features': []}, reg) == []
|
|
|
|
|
|
# ----------------------------------------------------- the newly-marked diff
|
|
|
|
def test_newly_reinforced_names_only_pieces_that_became_donors():
|
|
old = core.new_cache('m')
|
|
old['pieces'] = {
|
|
'A': core.piece_record('A', 1, 's', 1, 0, features=[]), # a donor
|
|
'H': core.piece_record('H', 2, 's', 1, 1, features=[]), # on hold
|
|
'T': core.piece_record('T', 3, 's', 0, 0, features=[]), # unreinforced
|
|
}
|
|
live = {
|
|
'A': ('s', 1, 0), # still a donor -- not "newly"
|
|
'H': ('s2', 1, 0), # taken off hold -> NEW
|
|
'T': ('s2', 1, 0), # newly reinforced -> NEW
|
|
'N': ('s', 1, 0), # brand new eligible piece -> NEW
|
|
'X': ('s', 0, 0), # still ineligible -> not
|
|
}
|
|
assert core.newly_reinforced(old, live) == {'H', 'T', 'N'}
|
|
|
|
|
|
def test_newly_reinforced_against_an_empty_old_cache_is_all_current_donors():
|
|
live = {'A': ('s', 1, 0), 'B': ('s', 0, 0), 'C': ('s', 1, 1)}
|
|
assert core.newly_reinforced(core.new_cache('m'), live) == {'A'}
|
|
assert core.newly_reinforced(None, live) == {'A'}
|
|
|
|
|
|
# ------------------------------------------ the snapshot update statistics
|
|
|
|
_OPEN = {'kind': 'opening', 'w': 2.0, 'h': 2.0}
|
|
_STRIP = {'kind': 'strip_x', 'strip_ft': 3.0, 'span_ft': 2.0,
|
|
'between': 'void-void'}
|
|
|
|
|
|
def _donor_rec(name, features, reinforced=1, on_hold=0):
|
|
# A plain donor record for the diff -- bare feature dicts (no x0/x1), so
|
|
# NOT built through `piece_record` (which sorts on absolute position); the
|
|
# registry helpers key off `canonical_descriptor`, which needs no position.
|
|
return {'name': name, 'reinforced': reinforced, 'on_hold': on_hold,
|
|
'features': features}
|
|
|
|
|
|
def test_snapshot_stats_splits_new_vs_known_ids_for_newly_marked_panels():
|
|
"""The owner's ask: this press marked P2; it has 2 features; one shape was
|
|
already in the vocabulary, one is new this press."""
|
|
old = core.new_cache('m')
|
|
old['pieces'] = {'P1': _donor_rec('P1', [_OPEN])} # already a donor
|
|
core.register_features(old, [old['pieces']['P1']]) # _OPEN -> id 1
|
|
|
|
new = core.new_cache('m')
|
|
new['registry'] = old['registry'] # ids persist a rebuild
|
|
p1 = _donor_rec('P1', [_OPEN])
|
|
p2 = _donor_rec('P2', [_OPEN, _STRIP]) # newly marked
|
|
new['pieces'] = {'P1': p1, 'P2': p2}
|
|
core.register_features(new, [p1, p2]) # _STRIP gets a fresh id
|
|
|
|
live = {'P1': ('s', 1, 0), 'P2': ('s', 1, 0)}
|
|
stats = core.snapshot_stats(old, new, live)
|
|
|
|
a_id = core.Registry.deserialize(old['registry']).lookup(
|
|
core.canonical_descriptor(_OPEN))
|
|
c_id = core.Registry.deserialize(new['registry']).lookup(
|
|
core.canonical_descriptor(_STRIP))
|
|
|
|
assert stats['first_snapshot'] is False
|
|
assert stats['newly'] == ['P2'] # P1 was already a donor
|
|
panel = stats['panels'][0]
|
|
assert panel['name'] == 'P2'
|
|
assert panel['feature_count'] == 2
|
|
assert panel['known_ids'] == [a_id] # _OPEN, seen before
|
|
assert panel['new_ids'] == [c_id] # _STRIP, new this press
|
|
assert stats['known_ids'] == [a_id]
|
|
assert stats['new_ids'] == [c_id]
|
|
|
|
|
|
def test_snapshot_stats_first_snapshot_is_flagged_and_everything_is_new():
|
|
"""An absent/empty old cache is the first snapshot: every reinforced piece
|
|
is new to the pool, and its feature-IDs are all new -- never a silent
|
|
empty delta."""
|
|
new = core.new_cache('m')
|
|
p1 = _donor_rec('P1', [_OPEN, _STRIP])
|
|
new['pieces'] = {'P1': p1}
|
|
core.register_features(new, [p1])
|
|
live = {'P1': ('s', 1, 0)}
|
|
|
|
for old in (core.new_cache('m'), None):
|
|
stats = core.snapshot_stats(old, new, live)
|
|
assert stats['first_snapshot'] is True
|
|
assert stats['newly'] == ['P1']
|
|
assert stats['panels'][0]['known_ids'] == [] # nothing was known
|
|
assert len(stats['panels'][0]['new_ids']) == 2
|
|
assert len(stats['new_ids']) == 2
|
|
|
|
|
|
def test_snapshot_stats_could_not_measure_panel_propagates_none():
|
|
"""A newly-marked piece whose geometry could not be read carries
|
|
features=None; the stat propagates could-not-judge rather than reporting
|
|
zero features."""
|
|
new = core.new_cache('m')
|
|
new['pieces'] = {'P9': _donor_rec('P9', None)} # could not judge
|
|
live = {'P9': ('s', 1, 0)}
|
|
stats = core.snapshot_stats(None, new, live)
|
|
panel = stats['panels'][0]
|
|
assert panel['name'] == 'P9'
|
|
assert panel['feature_count'] is None
|
|
assert panel['feature_ids'] is None
|
|
|
|
|
|
def test_snapshot_stats_no_new_donors_is_an_empty_diff_not_a_crash():
|
|
"""Nothing newly marked -> empty `newly`, empty pools. This is "nothing
|
|
changed", which the button distinguishes from "nothing is reinforced"."""
|
|
old = core.new_cache('m')
|
|
old['pieces'] = {'P1': _donor_rec('P1', [_OPEN])}
|
|
core.register_features(old, [old['pieces']['P1']])
|
|
new = core.new_cache('m')
|
|
new['registry'] = old['registry']
|
|
new['pieces'] = {'P1': _donor_rec('P1', [_OPEN])}
|
|
core.register_features(new, [new['pieces']['P1']])
|
|
live = {'P1': ('s', 1, 0)}
|
|
stats = core.snapshot_stats(old, new, live)
|
|
assert stats['newly'] == []
|
|
assert stats['panels'] == []
|
|
assert stats['new_ids'] == [] and stats['known_ids'] == []
|
|
|
|
|
|
# ---------------------------------------------- schema 6: seed + round-trip
|
|
|
|
def test_new_cache_seeds_an_empty_registry():
|
|
cache = core.new_cache('m')
|
|
reg = core.Registry.deserialize(cache['registry'])
|
|
assert reg.serialize()['next_id'] == 1
|
|
assert core.feature_ids_of({'features': []}, reg) == []
|
|
|
|
|
|
def test_the_registry_survives_a_save_and_load_round_trip(tmp_path):
|
|
path = str(tmp_path / 'index.json')
|
|
a = {'kind': 'strip_x', 'strip_ft': 3.0, 'span_ft': 2.0,
|
|
'between': 'void-void'}
|
|
b = {'kind': 'opening', 'w': 3.0, 'h': 3.0}
|
|
cache = core.new_cache('m')
|
|
core.register_features(cache, [{'name': 'P1', 'features': [a, b]}])
|
|
core.save(path, cache)
|
|
back = core.load(path)
|
|
reg = core.Registry.deserialize(back['registry'])
|
|
assert reg.lookup(core.canonical_descriptor(a)) == 1
|
|
assert reg.lookup(core.canonical_descriptor(b)) == 2
|
|
assert reg.serialize()['next_id'] == 3 # ids preserved verbatim
|
|
|
|
|
|
def test_a_pre_registry_schema_cache_is_discarded_to_a_loud_empty(tmp_path):
|
|
"""An older-schema file is thrown away WHOLE and comes back a loud EMPTY:
|
|
the stale vocabulary is never silently adopted, and an empty registry is a
|
|
could-not-judge / NO_DONORS condition, not a success."""
|
|
path = str(tmp_path / 'index.json')
|
|
old = {'schema': core.SCHEMA - 1, 'doc_key': 'm', 'built_at': 'y',
|
|
'registry': {'next_id': 9, 'by_id': {'1': '[]'}, 'by_key': {'x': 1}},
|
|
'pieces': {'A': {'name': 'A', 'features': []}}}
|
|
with open(path, 'wb') as handle:
|
|
handle.write(json.dumps(old).encode('utf-8'))
|
|
back = core.load(path)
|
|
assert back['_load_error'] # loud
|
|
assert back['pieces'] == {} # empty
|
|
# the stale next_id=9 vocabulary is NOT adopted
|
|
assert core.Registry.deserialize(back['registry']).serialize()['next_id'] == 1
|
|
|
|
|
|
def test_a_current_schema_cache_missing_a_registry_defaults_to_empty(tmp_path):
|
|
"""A hand-built same-schema cache with no registry key at all still loads
|
|
with a valid empty vocabulary rather than leaving a caller to guess."""
|
|
path = str(tmp_path / 'index.json')
|
|
healthy = {'schema': core.SCHEMA, 'doc_key': 'm', 'built_at': 'x',
|
|
'scope': None, 'pieces': {}} # no 'registry' key
|
|
with open(path, 'wb') as handle:
|
|
handle.write(json.dumps(healthy).encode('utf-8'))
|
|
back = core.load(path)
|
|
assert core.Registry.deserialize(
|
|
back['registry']).serialize()['next_id'] == 1
|