feat(IMP-12): Step 16/17 retry refinement — multi-donor + 3-stage salvage cascade

Extend Step 17 deterministic action surface so donor_slack_insufficient no longer
abort-terminates at zone_ratio_retry. AI is NOT invoked on the normal salvage path.

Source changes (4 files, scope-locked):
- src/phase_z2_retry.py — plan_zone_ratio_retry: single-primary-donor → multi-donor
  greedy aggregation (donors_used / aggregate_slack_used / aggregate_slack_available);
  new plan/apply pairs: cross_zone_redistribute (wraps fit_verifier.redistribute,
  data-role scoped CSS), glue_compression (wraps space_allocator.compute_glue_css_overrides,
  data-zone-position scoped), font_step_compression (wraps find_fitting_font_size,
  zone-scoped, defensive feasible=False on missing text_metrics).
- src/phase_z2_failure_router.py — classifier inspects salvage_steps[-1] via
  SALVAGE_FAILURE_TYPE_BY_ACTION; NEXT_ACTION_BY_FAILURE rewired into
  donor_slack_insufficient/no_donor_candidates → cross_zone_redistribute → glue
  → font_step → layout_adjust; 3 IMPLEMENTED salvage status rows added.
- src/phase_z2_router.py — ACTION_IMPLEMENTATION_STATUS registers 3 new salvage
  actions as IMPLEMENTED; ACTION_BY_CATEGORY untouched (cascade-only labels).
- src/phase_z2_pipeline.py — new _attempt_salvage_chain() iterates router
  next_proposed_action with retry_budget=1 per action; honors IMP-09 dynamic_cols
  / fr_default gate; preserves (b)-revert on all-fail; wires Step 17 telemetry
  (salvage_steps / salvage_passed).

Tests (6 new pytest modules):
- test_phase_z2_retry_multi_donor.py — single sufficient (regression), 1st
  insufficient + 2nd sufficient (multi-donor PASS), aggregate insufficient FAIL.
- test_phase_z2_cross_zone_redistribute.py — multi-role zone feasible,
  single-role zone short-circuits infeasible.
- test_phase_z2_glue_compression.py — feasible asserts emitted CSS contains
  [data-zone-position=...] selector and NO global :root/body/.slide rule.
- test_phase_z2_font_step_compression.py — 15.2 → 13 closes excess; 8px floor;
  missing text_metrics → defensive infeasible reason.
- test_phase_z2_failure_router_cascade.py — donor_slack_insufficient → cross_zone
  (impl=IMPLEMENTED); 3 new failure types → expected next actions; rerender_still_fails
  preserves frame_reselect terminus.
- test_phase_z2_step17_salvage_chain.py — end-to-end (a) cross_zone PASS promotes
  final.html, (b) cross_zone FAIL + glue PASS promotes 2nd candidate, (c) all-3
  FAIL preserves original final.html (revert).

Guardrails preserved:
- AI calls: 0 on normal path (feedback_ai_isolation_contract)
- Spacing direction: no shrink-common-margin; resolve via donor/glue/font-step
  within frame envelope (feedback_phase_z_spacing_direction)
- All CSS overrides scoped to [data-role=...] or [data-zone-position=...]
- IMP-09 dynamic_cols / fr_default gate honored in cascade
- (b)-revert preserved if all 3 salvage actions fail

Refs: gitea#12 IMP-12

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 02:07:22 +09:00
parent a79bd8bc43
commit 56619a0239
10 changed files with 1246 additions and 39 deletions

View File

@@ -0,0 +1,89 @@
"""IMP-12 u11 — plan_cross_zone_redistribute tests.
Stage 2 contract (unit u11):
- multi-role zone feasible (deficit role + surplus role in the same zone)
- single-role zone infeasible reason (no peer to donate surplus)
u4 wraps fit_verifier.redistribute() in the Step-17 plan signature; feasibility
depends on whether deficit roles can be covered by surplus roles within the
same container.zone group (see src/fit_verifier.py:496-590). The plan exposes
role_heights_before / role_heights_after and surfaces the
'can_redistribute=False — single-role zone(s)' substring when redistribution
is impossible. The apply helper must scope output to [data-role=...] only
(feedback_phase_z_spacing_direction — no :root / body / .slide / .zone).
"""
from __future__ import annotations
from src.fit_verifier import FitAnalysis, RoleFit
from src.phase_z2_retry import (
apply_cross_zone_redistribute_css,
plan_cross_zone_redistribute,
)
def _fit(roles: dict[str, tuple[float, float]]) -> FitAnalysis:
"""roles dict = {role: (allocated_px, shortfall_px)}.
Sign convention matches fit_verifier.redistribute: shortfall_px > 0 = deficit,
shortfall_px < 0 = surplus (usable = abs(shortfall) - min_margin_px).
"""
return FitAnalysis(
roles={
name: RoleFit(role=name, allocated_px=alloc, shortfall_px=short)
for name, (alloc, short) in roles.items()
}
)
def test_multi_role_zone_feasible():
"""Two roles in the same zone — deficit covered by surplus → feasible."""
fit = _fit({"top": (200.0, 30.0), "bottom_l": (300.0, -50.0)})
containers = {
"top": {"zone": "slide_body", "height_px": 200},
"bottom_l": {"zone": "slide_body", "height_px": 300},
}
plan = plan_cross_zone_redistribute(
fit_analysis=fit, containers=containers, min_margin_px=10.0,
)
assert plan["action"] == "cross_zone_redistribute"
assert plan["feasible"] is True
assert plan["can_redistribute"] is True
assert plan["role_heights_before"] == {"top": 200.0, "bottom_l": 300.0}
after = plan["role_heights_after"]
# deficit (30) shifts top up, surplus (50-margin=40) shifts bottom_l down by 30.
assert after["top"] > 200.0
assert after["bottom_l"] < 300.0
assert abs((after["top"] - 200.0) - (300.0 - after["bottom_l"])) < 1.0
css = apply_cross_zone_redistribute_css(plan)
assert '[data-role="top"]' in css
assert '[data-role="bottom_l"]' in css
# Scope lock — no global rules emitted.
for forbidden in (":root", "body", ".slide", ".zone"):
assert forbidden not in css
def test_single_role_zone_infeasible_reason():
"""Lone role in a zone has no peer to donate surplus → infeasible."""
fit = _fit({"top": (200.0, 30.0)})
containers = {"top": {"zone": "slide_body", "height_px": 200}}
plan = plan_cross_zone_redistribute(
fit_analysis=fit, containers=containers, min_margin_px=10.0,
)
assert plan["feasible"] is False
assert plan["can_redistribute"] is False
reason = plan["failure_reason"]
assert "single-role zone" in reason
assert "can_redistribute=False" in reason
# apply emits nothing when infeasible.
assert apply_cross_zone_redistribute_css(plan) == ""
def test_empty_fit_analysis_infeasible():
"""No roles at all → defensive infeasible (cannot redistribute nothing)."""
plan = plan_cross_zone_redistribute(
fit_analysis=FitAnalysis(roles={}), containers={}, min_margin_px=10.0,
)
assert plan["feasible"] is False
assert plan["role_heights_before"] == {}
assert "no roles" in plan["failure_reason"]
assert apply_cross_zone_redistribute_css(plan) == ""

View File

@@ -0,0 +1,119 @@
"""IMP-12 u14 — failure_router cascade tests.
Stage 2 contract (unit u14):
- donor_slack_insufficient → cross_zone_redistribute (impl=IMPLEMENTED)
- 3 new failure types (cross_zone_redistribute_insufficient,
glue_absorption_insufficient, font_step_insufficient) all route to
expected next actions per the locked NEXT_ACTION_BY_FAILURE table
- rerender_still_fails preserved → frame_reselect
u2 (classifier) inspects retry_trace["salvage_steps"][-1] for the 3 new
salvage failure types via SALVAGE_FAILURE_TYPE_BY_ACTION; u3 wires those
failure types onto the deterministic cascade in NEXT_ACTION_BY_FAILURE.
u7 records the cascade actions as IMPLEMENTED in NEXT_ACTION_IMPLEMENTATION_STATUS.
"""
from __future__ import annotations
from src.phase_z2_failure_router import (
NEXT_ACTION_BY_FAILURE,
NEXT_ACTION_IMPLEMENTATION_STATUS,
classify_retry_failure,
enrich_retry_trace_with_failure_classification,
route_retry_failure,
)
def test_donor_slack_insufficient_routes_to_cross_zone_redistribute_implemented():
"""Stage 1 root cause — primary donor slack insufficient classifies as
donor_slack_insufficient and routes onto the deterministic salvage cascade
starting with cross_zone_redistribute (IMPLEMENTED per u7)."""
trace = {
"retry_attempted": True,
"retry_passed": False,
"plan": {
"feasible": False,
"failure_reason": (
"primary donor 'bottom' slack 15px (aggregate 25px from 2 donor(s)) "
"< target_added_px 70px"
),
},
}
fc = classify_retry_failure(trace)
assert fc is not None
assert fc["failure_type"] == "donor_slack_insufficient"
nr = route_retry_failure("donor_slack_insufficient")
assert nr["next_proposed_action"] == "cross_zone_redistribute"
assert nr["next_action_implementation_status"] == "IMPLEMENTED"
# enrichment composes both fields onto the trace
enrich_retry_trace_with_failure_classification(trace)
assert trace["failure_classification"]["failure_type"] == "donor_slack_insufficient"
assert trace["next_action_proposal"]["next_proposed_action"] == "cross_zone_redistribute"
def test_no_donor_candidates_routes_to_cross_zone_redistribute_implemented():
"""no_donor_candidates is the second cascade entry — also onto
cross_zone_redistribute per the locked mapping."""
trace = {
"retry_attempted": True,
"retry_passed": False,
"plan": {"feasible": False, "failure_reason": "no donor candidates"},
}
fc = classify_retry_failure(trace)
assert fc["failure_type"] == "no_donor_candidates"
nr = route_retry_failure("no_donor_candidates")
assert nr["next_proposed_action"] == "cross_zone_redistribute"
assert nr["next_action_implementation_status"] == "IMPLEMENTED"
def test_three_new_salvage_failure_types_route_to_expected_cascade_actions():
"""u2 classifier inspects salvage_steps[-1]. u3 routes the 3 new failure
types through the deterministic cascade: cross_zone → glue → font_step →
layout_adjust. Verifies the locked NEXT_ACTION_BY_FAILURE table directly
and via the classifier path."""
# Direct mapping (u3 lock)
assert NEXT_ACTION_BY_FAILURE["cross_zone_redistribute_insufficient"] == "glue_compression"
assert NEXT_ACTION_BY_FAILURE["glue_absorption_insufficient"] == "font_step_compression"
assert NEXT_ACTION_BY_FAILURE["font_step_insufficient"] == "layout_adjust"
# Implementation status (u7): 2 cascade entries IMPLEMENTED, layout_adjust MISSING
assert NEXT_ACTION_IMPLEMENTATION_STATUS["glue_compression"] == "IMPLEMENTED"
assert NEXT_ACTION_IMPLEMENTATION_STATUS["font_step_compression"] == "IMPLEMENTED"
assert NEXT_ACTION_IMPLEMENTATION_STATUS["layout_adjust"] == "MISSING"
# Classifier path via salvage_steps[-1].action → failure_type → next action
cases = [
("cross_zone_redistribute", "cross_zone_redistribute_insufficient", "glue_compression"),
("glue_compression", "glue_absorption_insufficient", "font_step_compression"),
("font_step_compression", "font_step_insufficient", "layout_adjust"),
]
for action, expected_ftype, expected_next in cases:
trace = {
"retry_attempted": True,
"retry_passed": False,
"salvage_passed": False,
"salvage_steps": [
{"action": action, "passed": False, "failure_reason": "salvage failed"}
],
}
fc = classify_retry_failure(trace)
assert fc is not None, f"classifier returned None for action={action}"
assert fc["failure_type"] == expected_ftype
nr = route_retry_failure(fc["failure_type"])
assert nr["next_proposed_action"] == expected_next
def test_rerender_still_fails_preserved_routes_to_frame_reselect():
"""Pre-cascade behavior preserved: when plan was feasible and rerender ran
but visual still failed, classifier emits rerender_still_fails → frame_reselect."""
trace = {
"retry_attempted": True,
"retry_passed": False,
"plan": {"feasible": True},
"rerender_attempted": True,
}
fc = classify_retry_failure(trace)
assert fc["failure_type"] == "rerender_still_fails"
nr = route_retry_failure("rerender_still_fails")
assert nr["next_proposed_action"] == "frame_reselect"

View File

@@ -0,0 +1,77 @@
"""IMP-12 u13 — plan_font_step_compression tests.
Stage 2 contract (unit u13):
- feasible case (15.2 → 13 closes excess)
- infeasible (8px floor — FONT_SIZE_STEPS exhausted)
- text_metrics missing → defensive infeasible reason
u6 wraps space_allocator.find_fitting_font_size in the Step-17 plan signature.
Height savings per candidate font_size (Korean 1.6 line-height):
height_saved = (current_font_px * 1.6 - font_size * 1.6) * available_lines
Scope lock per feedback_phase_z_spacing_direction:
- apply_font_step_compression_css emits ONLY [data-zone-position="<pos>"] rule.
- No :root / body / .slide / .zone selectors permitted.
"""
from src.phase_z2_retry import (
apply_font_step_compression_css,
plan_font_step_compression,
)
def test_feasible_15_2_to_13_closes_excess() -> None:
"""current=15.2, excess=20, lines=10 → 14.0 saves 19.2 (insufficient);
13.0 saves 35.2 (>=20) → target_font_px=13.0. Emitted CSS scope-locked."""
plan = plan_font_step_compression(
current_font_px=15.2, excess_after_glue_px=20.0,
available_lines=10, chars_per_line=40, zone_position="bottom_l",
)
assert plan["action"] == "font_step_compression"
assert plan["zone_position"] == "bottom_l"
assert plan["current_font_px"] == 15.2
assert plan["excess_after_glue_px"] == 20.0
assert plan["available_lines"] == 10
assert plan["chars_per_line"] == 40
assert plan["font_floor_px"] == 8.0
assert plan["feasible"] is True
assert plan["target_font_px"] == 13.0
assert "failure_reason" not in plan
css = apply_font_step_compression_css(plan)
assert '[data-zone-position="bottom_l"]' in css
assert "font-size: 13.0px" in css
for forbidden in (":root", "body ", ".slide", ".zone"):
assert forbidden not in css, f"scope-lock violation: {forbidden!r} in css"
def test_infeasible_font_floor_exhausted() -> None:
"""current=15.2, excess=200, lines=10 — even 8.0px floor saves only 115.2,
so FONT_SIZE_STEPS is exhausted → feasible=False, classifier-matching reason."""
plan = plan_font_step_compression(
current_font_px=15.2, excess_after_glue_px=200.0,
available_lines=10, chars_per_line=40, zone_position="top",
)
assert plan["feasible"] is False
assert plan["target_font_px"] is None
assert plan["font_floor_px"] == 8.0
reason = plan["failure_reason"]
assert "font_step floor" in reason
assert "8.0px" in reason
assert "200.0px" in reason
assert "FONT_SIZE_STEPS exhausted" in reason
assert apply_font_step_compression_css(plan) == ""
def test_text_metrics_missing_defensive_infeasible() -> None:
"""available_lines=0 → guard fires before find_fitting_font_size;
failure_reason carries the text_metrics missing substring (classifier-friendly)."""
plan = plan_font_step_compression(
current_font_px=15.2, excess_after_glue_px=40.0,
available_lines=0, chars_per_line=40, zone_position="bottom_r",
)
assert plan["feasible"] is False
assert plan["target_font_px"] is None
assert "text_metrics missing" in plan["failure_reason"]
assert "available_lines/chars_per_line required" in plan["failure_reason"]
assert apply_font_step_compression_css(plan) == ""

View File

@@ -0,0 +1,87 @@
"""IMP-12 u12 — plan_glue_compression tests.
Stage 2 contract (unit u12):
- feasible case asserts emitted CSS contains [data-zone-position=...]
selector and NO global :root / body / .slide rule (scope lock)
- insufficient case feasible=False with envelope reason
u5 wraps space_allocator.calculate_glue_absorption + compute_glue_css_overrides
in the Step-17 plan signature. Glue envelope per block_count (SPACING_GLUE):
absorption_max = block_gap.shrink * (block_count-1) # 12 * (n-1)
+ inner_gap.shrink * block_count # 8 * n
+ title_gap.shrink * block_count # 4 * n
+ container_padding.shrink * 2 # 8 * 2
block_count=3 → 12*2 + 8*3 + 4*3 + 8*2 = 24+24+12+16 = 76 px
block_count=1 → 12*0 + 8*1 + 4*1 + 8*2 = 0+8+4+16 = 28 px
CSS must be wrapped under [data-zone-position="<pos>"] only
(feedback_phase_z_spacing_direction — no :root/body/.slide/.zone mutation).
"""
from __future__ import annotations
from src.phase_z2_retry import (
apply_glue_compression_css,
plan_glue_compression,
)
def test_feasible_case_emits_zone_scoped_css():
"""excess (40px) <= absorption_max (76px @ block_count=3) → feasible.
Emitted CSS must wrap overrides in [data-zone-position=...] selector and
contain none of the global selectors banned by feedback_phase_z_spacing_direction.
"""
plan = plan_glue_compression(
excess_px=40.0, block_count=3, zone_position="bottom_l",
)
assert plan["action"] == "glue_compression"
assert plan["zone_position"] == "bottom_l"
assert plan["feasible"] is True
assert plan["excess_px"] == 40.0
assert plan["block_count"] == 3
assert plan["absorption_max_px"] == 76.0
overrides = plan["overrides"]
assert overrides, "feasible plan must return non-empty overrides"
for key in ("--spacing-block", "--spacing-inner", "--container-padding"):
assert key in overrides, f"missing override key {key}"
css = apply_glue_compression_css(plan)
assert '[data-zone-position="bottom_l"]' in css
assert "--spacing-block:" in css
assert "--spacing-inner:" in css
assert "--container-padding:" in css
# Scope lock — no global rules permitted.
for forbidden in (":root", "body ", ".slide", ".zone"):
assert forbidden not in css, f"forbidden selector {forbidden!r} leaked into glue CSS"
def test_insufficient_envelope_feasible_false_with_reason():
"""excess (80px) > absorption_max (28px @ block_count=1) → infeasible.
failure_reason must surface the envelope shortage so the cascade router
(NEXT_ACTION_BY_FAILURE) can route onward to font_step_compression.
"""
plan = plan_glue_compression(
excess_px=80.0, block_count=1, zone_position="top",
)
assert plan["feasible"] is False
assert plan["absorption_max_px"] == 28.0
reason = plan["failure_reason"]
assert "glue envelope insufficient" in reason
assert "excess_px 80" in reason
assert "max absorption 28" in reason
# apply emits nothing when infeasible — no accidental CSS mutation on revert path.
assert apply_glue_compression_css(plan) == ""
def test_excess_non_positive_no_compression_needed():
"""excess_px <= 0 → defensive infeasible (no compression required)."""
plan = plan_glue_compression(
excess_px=0.0, block_count=3, zone_position="bottom_r",
)
assert plan["feasible"] is False
assert plan["overrides"] == {}
assert plan["absorption_max_px"] == 0.0
assert "no compression needed" in plan["failure_reason"]
assert apply_glue_compression_css(plan) == ""

View File

@@ -0,0 +1,147 @@
"""IMP-12 u10 — plan_zone_ratio_retry multi-donor aggregation tests.
Stage 2 contract (unit u10):
- single-donor sufficient (regression — backward compat preserved)
- single insufficient + 2nd sufficient (multi-donor PASS path)
- aggregate insufficient (multi-donor FAIL path)
u1 extended plan_zone_ratio_retry from a single primary donor to greedy
slack-desc aggregation across all eligible sibling zones. The plan dict
now carries donors_used / aggregate_slack_used / aggregate_slack_available
while preserving donor_zone_position + donor_reduced_px for the failure
classifier substrings (router still keys off primary donor name).
"""
from __future__ import annotations
from src.phase_z2_retry import plan_zone_ratio_retry
_ROUTER_ACTIVE = {"router_active": True}
def _classification(target_pos: str, excess_y: float) -> dict:
return {
"classifications": [
{
"proposed_action": "zone_ratio_retry",
"zone_position": target_pos,
"inputs": {"excess_y": excess_y},
}
]
}
def _zone(position: str, height_px: int, min_height_px: int,
fit_status: str | None = "ok") -> dict:
return {
"position": position,
"height_px": height_px,
"min_height_px": min_height_px,
"composition_rationale": {
"capacity_fit": {"fit_status": fit_status},
},
}
def _overflow_clean(donor_positions: list[str]) -> dict:
return {
"zones": [
{"position": p, "overflowed": False, "clipped_inner": False}
for p in donor_positions
]
}
def test_single_donor_sufficient_regression():
"""One donor with abundant slack. Plan must remain feasible and the
legacy donor_zone_position / donor_reduced_px fields must reflect the
primary donor (router classifier substring stability)."""
debug_zones = [
_zone("top", height_px=200, min_height_px=180),
_zone("bottom", height_px=400, min_height_px=200), # slack=200
]
plan = plan_zone_ratio_retry(
debug_zones=debug_zones,
overflow=_overflow_clean(["bottom"]),
fit_classification=_classification("top", excess_y=20.0),
router_decision=_ROUTER_ACTIVE,
)
assert plan is not None
assert plan["feasible"] is True
# target_added_px = ceil(20) + DEFAULT_SAFETY_MARGIN_PX(4) = 24
assert plan["target_added_px"] == 24
assert plan["donor_zone_position"] == "bottom"
assert plan["donor_reduced_px"] == 24
assert plan["donors_used"] == [
{"position": "bottom", "reduced_px": 24,
"slack_before": 200, "slack_after": 176}
]
assert plan["aggregate_slack_used"] == 24
assert plan["aggregate_slack_available"] == 200
assert plan["zones_after"]["top"] == 224
assert plan["zones_after"]["bottom"] == 376
def test_multi_donor_pass_primary_insufficient_secondary_covers():
"""Primary donor alone has insufficient slack but primary + secondary
aggregate covers target_added_px. Multi-donor greedy aggregation must
split the deficit across both donors in slack-desc order."""
debug_zones = [
_zone("top", height_px=300, min_height_px=200),
_zone("middle", height_px=250, min_height_px=200), # slack=50
_zone("bottom", height_px=240, min_height_px=200), # slack=40
]
plan = plan_zone_ratio_retry(
debug_zones=debug_zones,
overflow=_overflow_clean(["middle", "bottom"]),
fit_classification=_classification("top", excess_y=66.0),
router_decision=_ROUTER_ACTIVE,
)
# target_added_px = ceil(66)+4 = 70. Primary (middle, slack=50) alone
# cannot cover, but middle(50)+bottom(40)=90 >= 70.
assert plan["feasible"] is True
assert plan["target_added_px"] == 70
assert plan["aggregate_slack_available"] == 90
assert plan["aggregate_slack_used"] == 70
assert plan["donor_zone_position"] == "middle" # primary
assert plan["donor_reduced_px"] == 50 # primary takes its full slack
assert [d["position"] for d in plan["donors_used"]] == ["middle", "bottom"]
assert plan["donors_used"][0]["reduced_px"] == 50
assert plan["donors_used"][1]["reduced_px"] == 20 # remainder
assert plan["zones_after"]["top"] == 370
assert plan["zones_after"]["middle"] == 200
assert plan["zones_after"]["bottom"] == 220
def test_multi_donor_fail_aggregate_insufficient():
"""All donors combined still cannot cover target_added_px. Plan must
be feasible=False with primary-donor substring preserved so the
failure_router classifier still routes through donor_slack_insufficient."""
debug_zones = [
_zone("top", height_px=300, min_height_px=200),
_zone("middle", height_px=210, min_height_px=200), # slack=10
_zone("bottom", height_px=215, min_height_px=200), # slack=15
]
plan = plan_zone_ratio_retry(
debug_zones=debug_zones,
overflow=_overflow_clean(["middle", "bottom"]),
fit_classification=_classification("top", excess_y=66.0),
router_decision=_ROUTER_ACTIVE,
)
# target_added_px=70, aggregate=25 → fail
assert plan["feasible"] is False
assert plan["aggregate_slack_available"] == 25
assert plan["aggregate_slack_used"] == 0
assert plan["donors_used"] == []
# Primary = highest-slack donor = bottom (15)
assert plan["donor_zone_position"] == "bottom"
assert plan["donor_max_slack"] == 15
# Classifier substring stability: "donor", "slack", and "<" still present
reason = plan["failure_reason"]
assert "donor" in reason
assert "slack" in reason
assert "<" in reason
# zones unchanged on fail (revert-friendly)
assert plan["zones_after"]["top"] == 300
assert plan["zones_after"]["middle"] == 210
assert plan["zones_after"]["bottom"] == 215

View File

@@ -0,0 +1,248 @@
"""IMP-12 u15 — End-to-end test of `_attempt_salvage_chain` (Step 17 deterministic salvage cascade).
Three Stage 2 cases against `src.phase_z2_pipeline._attempt_salvage_chain`:
(a) zone_ratio fail + cross_zone pass → final.html promoted, salvage_passed=True
(b) cross_zone fail + glue pass → 2nd cascade step promoted, salvage_passed=True
(c) all 3 fail → (b)-revert preserved, original final.html intact, salvage_passed=False
`render_slide` and `run_overflow_check` are monkey-patched so the test stays deterministic
(no Selenium / Jinja2 template files). The patches only stand in for the rendering / overflow
oracles — the planners (`plan_cross_zone_redistribute`, `plan_glue_compression`,
`plan_font_step_compression`) and the cascade router (`route_retry_failure` /
`SALVAGE_FAIL_BY_ACTION`) all run unmocked.
"""
from __future__ import annotations
import shutil
import tempfile
from pathlib import Path
import pytest
import src.phase_z2_pipeline as _pz_pipeline
from src.fit_verifier import FitAnalysis, RoleFit
from src.phase_z2_pipeline import _attempt_salvage_chain
_PROJECT_ROOT = _pz_pipeline.PROJECT_ROOT
@pytest.fixture
def project_tmp(tmp_path_factory):
"""Temp dir under PROJECT_ROOT so _attempt_salvage_chain can call
candidate_path.relative_to(PROJECT_ROOT) without ValueError on a
cross-drive system tmp path (pytest's default tmp_path is under
%LOCALAPPDATA% on Windows, which lives on a different drive from
the project root in this repo)."""
base = _PROJECT_ROOT / ".orchestrator" / "tmp"
base.mkdir(parents=True, exist_ok=True)
d = Path(tempfile.mkdtemp(prefix="u15_salvage_", dir=str(base)))
try:
yield d
finally:
shutil.rmtree(d, ignore_errors=True)
_LAYOUT_CSS_GATE_PASS = {
"areas": '"top" "bottom"',
"cols": "1fr",
"rows": "1fr 1fr",
"heights_px": [300, 290],
"widths_px": [1180],
"ratios": [0.508, 0.491],
"width_ratios": [1.0],
"dynamic_rows": True,
"dynamic_cols": False,
}
def _patch_render(monkeypatch):
"""Stub render_slide → deterministic HTML envelope so the cascade does not
need real Jinja2 templates. Returns a counter so tests can assert how many
times it was invoked (one per CSS-feasible cascade step)."""
counter = {"n": 0}
def _stub(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=14):
counter["n"] += 1
return (
f"<html><head><meta charset='utf-8'></head>"
f"<body><div data-slide-title='{slide_title}'></div></body></html>"
)
monkeypatch.setattr(_pz_pipeline, "render_slide", _stub)
return counter
def _kwargs(*, run_dir: Path, out_path: Path, cascade_inputs: dict,
initial_failure_type: str = "donor_slack_insufficient") -> dict:
return {
"run_dir": run_dir,
"out_path": out_path,
"slide_title": "u15-test",
"slide_footer": None,
"zones_data": [],
"layout_preset": "horizontal-2",
"layout_css": _LAYOUT_CSS_GATE_PASS,
"cascade_inputs": cascade_inputs,
"initial_failure_type": initial_failure_type,
"gap_px": 14,
}
def test_case_a_cross_zone_passes_final_html_promoted(project_tmp, monkeypatch):
"""(a) cross_zone_redistribute is feasible + run_overflow_check returns
passed=True → out_path overwritten with the cross_zone candidate HTML and
salvage_passed=True after the very first cascade iteration."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
# Multi-role same-zone FitAnalysis: top +30 deficit, bottom_l -50 surplus.
fit_analysis = FitAnalysis(roles={
"top": RoleFit(role="top", allocated_px=200, shortfall_px=30.0),
"bottom_l": RoleFit(role="bottom_l", allocated_px=300, shortfall_px=-50.0),
})
containers = {
"top": {"zone": "slide_body", "height_px": 200},
"bottom_l": {"zone": "slide_body", "height_px": 300},
}
cascade_inputs = {
"fit_analysis": fit_analysis,
"containers": containers,
"min_margin_px": 10,
"excess_px": 30.0, "excess_after_glue_px": 30.0,
"block_count": 3, "zone_position": "top",
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
}
_patch_render(monkeypatch)
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: {"passed": True, "fail_reasons": []},
)
trace = _attempt_salvage_chain(
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
)
assert trace["salvage_attempted"] is True
assert trace["salvage_passed"] is True
assert len(trace["salvage_steps"]) == 1
step0 = trace["salvage_steps"][0]
assert step0["action"] == "cross_zone_redistribute"
assert step0["passed"] is True
assert step0["plan"]["feasible"] is True
assert step0["css_override"] and '[data-role=' in step0["css_override"]
# out_path was overwritten with the salvage candidate.
promoted = out_path.read_text(encoding="utf-8")
assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
assert "u15-test" in promoted
def test_case_b_cross_zone_fails_glue_passes_second_promoted(project_tmp, monkeypatch):
"""(b) cross_zone is infeasible (single-role zone) → glue_compression CSS
emitted + run_overflow_check passes → out_path overwritten with the glue
candidate (2nd cascade step). salvage_passed=True; salvage_steps[0]
records the infeasible cross_zone attempt."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
# Single-role zone → fit_verifier.redistribute returns can_redistribute=False
# (peer required, none present).
fit_analysis = FitAnalysis(roles={
"top": RoleFit(role="top", allocated_px=200, shortfall_px=30.0),
})
containers = {"top": {"zone": "slide_body", "height_px": 200}}
# Glue envelope at block_count=3 = 12*(3-1)+8*3+4*3+8*2 = 76 px → 40 px is feasible.
cascade_inputs = {
"fit_analysis": fit_analysis,
"containers": containers,
"min_margin_px": 10,
"excess_px": 40.0, "excess_after_glue_px": 40.0,
"block_count": 3, "zone_position": "bottom_l",
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
}
render_counter = _patch_render(monkeypatch)
# cross_zone is infeasible → no CSS → no rerender / no overflow call. Glue is
# feasible → exactly one rerender + overflow call → return passed=True.
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: {"passed": True, "fail_reasons": []},
)
trace = _attempt_salvage_chain(
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
)
assert trace["salvage_attempted"] is True
assert trace["salvage_passed"] is True
assert len(trace["salvage_steps"]) == 2
s0 = trace["salvage_steps"][0]
assert s0["action"] == "cross_zone_redistribute"
assert s0["passed"] is False
assert s0["plan"]["feasible"] is False
assert s0["css_override"] is None
assert "single-role zone" in (s0["plan"].get("failure_reason") or "")
s1 = trace["salvage_steps"][1]
assert s1["action"] == "glue_compression"
assert s1["passed"] is True
assert s1["plan"]["feasible"] is True
assert s1["css_override"] and '[data-zone-position="bottom_l"]' in s1["css_override"]
# render_slide was invoked exactly once (only the glue branch emitted CSS).
assert render_counter["n"] == 1
# out_path was overwritten with the glue candidate.
promoted = out_path.read_text(encoding="utf-8")
assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
def test_case_c_all_three_fail_revert_preserved(project_tmp, monkeypatch):
"""(c) All three cascade actions are infeasible (no CSS emitted by any
planner) → run_overflow_check is never invoked, salvage_passed=False,
salvage_steps has three failed entries, and out_path is unchanged
(original final.html intact — (b)-revert preserved)."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
cascade_inputs = {
# cross_zone: fit_analysis missing → plan returns feasible=False with reason
# `cascade_inputs.fit_analysis missing` (see _attempt_salvage_chain branch).
"fit_analysis": None,
"containers": {},
"min_margin_px": 10,
# glue: excess_px (200) > envelope max at block_count=1 (28) → infeasible.
"excess_px": 200.0, "excess_after_glue_px": 200.0,
"block_count": 1, "zone_position": "top",
# font_step: current_font_px=15.2 cannot absorb 200px even at 8px floor
# → find_fitting_font_size returns None → feasible=False.
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
}
render_counter = _patch_render(monkeypatch)
# Guard: if run_overflow_check is ever called, the test fails loudly.
def _must_not_call(_p): # pragma: no cover — intentional sentinel
raise AssertionError("run_overflow_check must not run when no CSS is emitted")
monkeypatch.setattr(_pz_pipeline, "run_overflow_check", _must_not_call)
trace = _attempt_salvage_chain(
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
)
assert trace["salvage_attempted"] is True
assert trace["salvage_passed"] is False
assert len(trace["salvage_steps"]) == 3
actions = [s["action"] for s in trace["salvage_steps"]]
assert actions == [
"cross_zone_redistribute",
"glue_compression",
"font_step_compression",
]
for step in trace["salvage_steps"]:
assert step["passed"] is False
assert step["css_override"] is None
assert step["failure_reason"]
# No CSS emitted anywhere → no render_slide calls either.
assert render_counter["n"] == 0
# (b) revert: out_path is untouched.
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"