mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: junction limits are honest at the boundaries (#331)
Six normative cuts, both mirrors symmetric (spec revision 3): - §2.1 node keys quantise to 1e-7 with the repository's canonicalisation formula (sign·floor(|v|·1e7+0.5)/1e7, -0 normalised) — toFixed(6) keys split one node into two on floating debris and produced two false П4 refusals on a legitimate resize (reproduced: -1e-8 vs 0). Node pairs within 2e-7 of each other (raw coordinates) are ONE node, and the node-to-wall incidence uses the same quantum. - §2.2 a ~0° wedge IS a violation: two rays leaving a node the same way are a duplicated or overlaid wall (a butt joint yields 180°, never 0°) — the worst degenerate case was invisible while 0.5° was refused. - §2.3/§2.4 the wall run is an iterative edge walk over the collinear component: no recursion (10 000 atoms answered, not RangeError), no silently dropped fork (the old .find lost every branch but the first), O(E) by construction, and collinearity is measured against the BASE segment's axis so an arc of 0.9°-per-atom pieces cannot pose as one wall. - §2.5 an exception while judging the CANDIDATE refuses the write with the junction.limit_check_failed toast (fail-closed, as the #278 guard); the baseline branch stays fail-open by design and the smoke proves the asymmetry by breaking only the second call of the deterministic pair. - §2.6 the python mirror narrows its except on the candidate side only: a genuine migration bug (TypeError) surfaces as an honest WS error, while a previous-side bug keeps the wide "no baseline" fallback — the two AC6 cases pin the asymmetry so swapped sides turn a unit red. Parity fixtures gain the new boundary classes (debris node, duplicate wall, collinear fork); four new mutants pin the filter, the key precision, the dropped branch and the fail-open hole. Issue: #331 User-Visible: yes
This commit is contained in:
@@ -236,6 +236,14 @@ def test_parity_with_the_frontend_checks():
|
||||
([cm(354), 0.0], [cm(554), 0.0], 20)],
|
||||
"distance-5-exact": [([0.0, 0.0], [cm(300), 0.0], 15),
|
||||
([0.0, cm(5)], [cm(300), cm(5)], 15)],
|
||||
# #331: пограничные классы точности — вердикт зеркал обязан совпасть.
|
||||
"debris-node": [([-1e-8, 0.0], [cm(300), 0.0], 15),
|
||||
([0.0, 0.0], [0.0, cm(300)], 15)],
|
||||
"duplicate-wall": [([0.0, 0.0], [cm(300), 0.0], 15),
|
||||
([0.0, 0.0], [cm(300), 0.0], 15)],
|
||||
"collinear-fork": [([0.0, 0.0], [cm(100), 0.0], 15),
|
||||
([cm(100), 0.0], [cm(160), 0.0], 15),
|
||||
([cm(100), 0.0], [cm(220), 1e-9], 15)],
|
||||
}
|
||||
payload = {name: space(segments, space_id=name)
|
||||
for name, segments in fixtures.items()}
|
||||
@@ -334,9 +342,9 @@ def test_330_baseline_counts_replace_the_previous_document():
|
||||
# baseline_counts действительно замещает previous: считаем обращения.
|
||||
calls = []
|
||||
original = jl._migrated_spaces
|
||||
def spy(config):
|
||||
def spy(config, **kwargs):
|
||||
calls.append(config)
|
||||
return original(config)
|
||||
return original(config, **kwargs)
|
||||
jl._migrated_spaces = spy
|
||||
try:
|
||||
jl.validate_junction_limits(
|
||||
@@ -439,3 +447,63 @@ def test_330_as_is_equals_migrated_on_boundary_fixtures():
|
||||
forced, _ = jl.commit_wall_segment_model(json.loads(json.dumps(migrated)))
|
||||
through = jl.space_violation_counts(jl._migrated_spaces(forced))
|
||||
assert as_is == through, f"{name}: as-is != migrated"
|
||||
|
||||
|
||||
def test_331_debris_is_one_node_and_duplicate_is_visible():
|
||||
"""AC1 + AC2 на python-зеркале."""
|
||||
debris = space([(([-1e-8, 0.0]), [cm(300), 0.0], 15),
|
||||
([0.0, 0.0], [0.0, cm(300)], 15)])
|
||||
assert "distance" not in rules(debris)
|
||||
dup = space([([0.0, 0.0], [cm(300), 0.0], 15),
|
||||
([0.0, 0.0], [cm(300), 0.0], 15)])
|
||||
segs = jl.limit_segments(dup)
|
||||
angle = [v for v in jl.check_nodes(segs) if v[0] == "angle"]
|
||||
assert angle and angle[0][2] < 0.001
|
||||
butt = space([([0.0, 0.0], [cm(150), 0.0], 15),
|
||||
([cm(150), 0.0], [cm(300), 0.0], 15)])
|
||||
assert "angle" not in rules(butt)
|
||||
|
||||
|
||||
def test_331_iterative_run_and_forks():
|
||||
"""AC3: 10 000 атомов без переполнения, развилка не теряется."""
|
||||
chain = space([
|
||||
([cm(i * 25), 0.0], [cm((i + 1) * 25), 0.0], 15) for i in range(10000)
|
||||
])
|
||||
segs = jl.limit_segments(chain)
|
||||
total = jl.collinear_run_length_units(segs[0], segs)
|
||||
assert abs(total - jl.cm_to_units(250000, CELL)) < 1e-6
|
||||
fork = space([([0.0, 0.0], [cm(100), 0.0], 15),
|
||||
([cm(100), 0.0], [cm(160), 0.0], 15),
|
||||
([cm(100), 0.0], [cm(220), 1e-9], 15)])
|
||||
fsegs = jl.limit_segments(fork)
|
||||
run = jl.collinear_run_length_units(fsegs[0], fsegs)
|
||||
assert run >= jl.cm_to_units(160, CELL) - 1e-9
|
||||
|
||||
|
||||
def test_331_ac6_candidate_bug_raises_previous_bug_falls_back():
|
||||
"""AC6, два случая (spec r2 M-r2-1): стороны различимы."""
|
||||
box = square()
|
||||
good = {"spaces": [room_space(
|
||||
[{"id": "r1", "name": "box", "area": None, "poly": box}],
|
||||
walls_of(box, "b"),
|
||||
)]}
|
||||
class Boom(TypeError):
|
||||
pass
|
||||
original = jl.commit_wall_segment_model
|
||||
def bomb(cfg):
|
||||
raise Boom("migration bug")
|
||||
jl.commit_wall_segment_model = bomb
|
||||
try:
|
||||
# (а) баг на стороне КАНДИДАТА — честная ошибка, не «нарушений нет».
|
||||
with pytest.raises(Boom):
|
||||
jl.validate_junction_limits(
|
||||
json.loads(json.dumps(good)), None, baseline_counts={},
|
||||
)
|
||||
# (б) баг на стороне PREVIOUS — запись проходит фолбэком «нет базы»:
|
||||
# кандидат уже v9 (мигрирован заранее настоящей функцией) и чист.
|
||||
migrated, _ = original(json.loads(json.dumps(good)))
|
||||
jl.validate_junction_limits(
|
||||
json.loads(json.dumps(migrated)), json.loads(json.dumps(good)),
|
||||
)
|
||||
finally:
|
||||
jl.commit_wall_segment_model = original
|
||||
|
||||
Reference in New Issue
Block a user