feat(#79): IMP-51 image_overrides axis (u1~u11 backend stamp+CLI+CSS inject + frontend drag/resize+persistence + tests)

This commit is contained in:
2026-05-22 21:54:38 +09:00
parent bd8bcf748b
commit 6f1c7367e0
18 changed files with 2311 additions and 32 deletions

View File

@@ -0,0 +1,400 @@
"""IMP-51 (#79) u4 — tests for ``src.image_id_stamper``.
Covers the stamping contract called out in the Stage 2 plan :
1. ``USER_CONTENT_IMAGE_SELECTOR`` constant matches the canonical string
shared with the frontend (u3 typed client, u8 SlideCanvas).
2. ``stable_image_id`` is deterministic across calls and across runs
(same ``src`` → same id), and the ordinal suffix only appears for
occurrences > 0.
3. ``stamp_user_content_images`` is a pure no-op when ``sources`` is
empty / all-non-string (forward-compat invariant — current Phase Z
final.html has zero user-content imgs).
4. Allowlisted srcs are stamped; non-allowlisted (decorative) srcs are
left byte-for-byte unchanged.
5. Idempotent under re-stamping (the role-attr probe short-circuits).
6. Duplicate srcs in DOM order get an ordinal suffix.
7. Single-quoted ``src`` is recognized.
8. Self-closing XHTML ``<img />`` is preserved.
9. ``<img>`` tags without a ``src`` attribute are skipped (no crash).
All tests are pure-Python — no filesystem, no Selenium, no fixtures.
"""
from __future__ import annotations
import pytest
from src.image_id_stamper import (
IMAGE_ID_ATTR,
IMAGE_ROLE_ATTR,
IMAGE_ROLE_VALUE,
USER_CONTENT_IMAGE_SELECTOR,
build_image_overrides_style,
inject_image_overrides_style,
stable_image_id,
stamp_user_content_images,
)
# -- selector contract ------------------------------------------------------
def test_selector_matches_canonical_string():
# MUST stay verbatim in sync with the frontend mirror in
# Front/client/src/services/userOverridesApi.ts and the SlideCanvas
# query target in u8. Drift here breaks the persisted-override loop.
assert USER_CONTENT_IMAGE_SELECTOR == '.slide img[data-image-role="user-content"]'
def test_attribute_constants_match_selector_components():
assert IMAGE_ROLE_ATTR == "data-image-role"
assert IMAGE_ROLE_VALUE == "user-content"
assert IMAGE_ID_ATTR == "data-image-id"
assert IMAGE_ROLE_ATTR in USER_CONTENT_IMAGE_SELECTOR
assert f'"{IMAGE_ROLE_VALUE}"' in USER_CONTENT_IMAGE_SELECTOR
# -- stable_image_id --------------------------------------------------------
def test_stable_image_id_deterministic_same_src():
a = stable_image_id("/uploads/photo.png")
b = stable_image_id("/uploads/photo.png")
assert a == b
assert a.startswith("img-")
# sha1[:10] + "img-" prefix → fixed length.
assert len(a) == len("img-") + 10
def test_stable_image_id_differs_for_different_src():
assert stable_image_id("/a.png") != stable_image_id("/b.png")
def test_stable_image_id_ordinal_zero_has_no_suffix():
assert "-" not in stable_image_id("/x.png", ordinal=0)[4:]
@pytest.mark.parametrize("ordinal", [1, 2, 7])
def test_stable_image_id_ordinal_suffix(ordinal):
base = stable_image_id("/x.png", ordinal=0)
suffixed = stable_image_id("/x.png", ordinal=ordinal)
assert suffixed == f"{base}-{ordinal}"
def test_stable_image_id_rejects_non_string_src():
with pytest.raises(TypeError):
stable_image_id(None) # type: ignore[arg-type]
def test_stable_image_id_rejects_negative_ordinal():
with pytest.raises(ValueError):
stable_image_id("/x.png", ordinal=-1)
# -- stamp_user_content_images : forward-compat no-op -----------------------
def test_stamp_no_sources_is_pure_noop():
html = '<div class="slide"><img src="/decorative.png"></div>'
out, ids = stamp_user_content_images(html, sources=())
assert out == html
assert ids == []
def test_stamp_all_non_string_sources_is_noop():
html = '<img src="/x.png">'
out, ids = stamp_user_content_images(html, sources=[None, 123, ""]) # type: ignore[list-item]
assert out == html
assert ids == []
def test_stamp_empty_html_is_safe():
out, ids = stamp_user_content_images("", sources=["/x.png"])
assert out == ""
assert ids == []
# -- stamp_user_content_images : allowlist semantics ------------------------
def test_stamp_user_content_src_stamps_role_and_id():
html = '<div class="slide"><img src="/u/p.png" alt="photo"></div>'
out, ids = stamp_user_content_images(html, sources=["/u/p.png"])
expected_id = stable_image_id("/u/p.png")
assert ids == [expected_id]
assert IMAGE_ROLE_ATTR in out
assert f'{IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"' in out
assert f'{IMAGE_ID_ATTR}="{expected_id}"' in out
# Original attribute (alt) preserved.
assert 'alt="photo"' in out
def test_stamp_decorative_src_left_unchanged():
html = (
'<div class="slide">'
'<img src="/figma/bg.png">'
'<img src="/u/photo.png">'
'</div>'
)
out, ids = stamp_user_content_images(html, sources=["/u/photo.png"])
# decorative img untouched (no data-image-role injected on it)
assert '<img src="/figma/bg.png">' in out
# user-content img stamped
assert ids == [stable_image_id("/u/photo.png")]
# decorative bg.png must not appear with the role attr
assert '/figma/bg.png' in out
decorative_segment = out.split('<img', 1)[1].split('>', 1)[0]
assert IMAGE_ROLE_ATTR not in decorative_segment
def test_stamp_is_idempotent_on_second_invocation():
html = '<div class="slide"><img src="/u/p.png"></div>'
once, ids1 = stamp_user_content_images(html, sources=["/u/p.png"])
twice, ids2 = stamp_user_content_images(once, sources=["/u/p.png"])
assert twice == once
assert ids1 == [stable_image_id("/u/p.png")]
# second invocation finds the role attr already present → no new id
assert ids2 == []
def test_stamp_duplicate_src_gets_ordinal_suffix_in_dom_order():
html = (
'<div class="slide">'
'<img src="/u/dup.png">'
'<img src="/u/dup.png">'
'<img src="/u/dup.png">'
'</div>'
)
_, ids = stamp_user_content_images(html, sources=["/u/dup.png"])
base = stable_image_id("/u/dup.png", ordinal=0)
assert ids == [base, f"{base}-1", f"{base}-2"]
def test_stamp_recognizes_single_quoted_src():
html = "<div class=\"slide\"><img src='/u/p.png'></div>"
out, ids = stamp_user_content_images(html, sources=["/u/p.png"])
assert ids == [stable_image_id("/u/p.png")]
assert IMAGE_ROLE_ATTR in out
def test_stamp_preserves_self_closing_xhtml_form():
html = '<div class="slide"><img src="/u/p.png" /></div>'
out, ids = stamp_user_content_images(html, sources=["/u/p.png"])
assert ids == [stable_image_id("/u/p.png")]
# self-close slash retained
assert "/>" in out
# role injected before existing attrs
assert f'<img {IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"' in out
def test_stamp_img_without_src_is_left_unchanged():
html = '<div class="slide"><img alt="no src"></div>'
out, ids = stamp_user_content_images(html, sources=["/u/p.png"])
assert out == html
assert ids == []
def test_stamp_returned_ids_persist_across_renders():
# Same allowlist + same DOM order → same id sequence on a fresh
# render. This is the invariant that lets user_overrides.json keys
# re-apply on the next pipeline run without re-clicking.
html = '<div class="slide"><img src="/u/a.png"><img src="/u/b.png"></div>'
_, ids_first = stamp_user_content_images(html, sources=["/u/a.png", "/u/b.png"])
_, ids_second = stamp_user_content_images(html, sources=["/u/a.png", "/u/b.png"])
assert ids_first == ids_second
assert ids_first == [stable_image_id("/u/a.png"), stable_image_id("/u/b.png")]
# -- build_image_overrides_style : CSS builder (u7) -----------------------
def test_build_style_empty_overrides_returns_empty_string():
# Forward-compat invariant — None / {} produces "" so the caller
# can short-circuit the <style> injection without DOM mutation.
assert build_image_overrides_style({}, []) == ""
assert build_image_overrides_style({}, ["img-abc"]) == ""
def test_build_style_no_stamped_ids_returns_empty_string():
# Override present but no stamped imgs in the DOM (Q1 = A current
# Phase Z state) — no rules emitted.
out = build_image_overrides_style({"img-abc": {"x": 1, "y": 2, "w": 3, "h": 4}}, [])
assert out == ""
def test_build_style_emits_rule_for_stamped_id_present_in_overrides():
iid = stable_image_id("/u/p.png")
out = build_image_overrides_style(
{iid: {"x": 10, "y": 20, "w": 30.5, "h": 25}},
[iid],
)
assert f'[{IMAGE_ID_ATTR}="{iid}"]' in out
assert f'[{IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"]' in out
assert "position: absolute" in out
assert "left: 10" in out and "top: 20" in out
assert "width: 30.5" in out and "height: 25" in out
def test_build_style_drops_overrides_for_unstamped_ids():
# Override exists for an id that was not stamped on this render
# → silently dropped (the SlideCanvas pathway cannot produce
# such keys; persisted-but-stale entries must NOT inject CSS).
iid_stamped = stable_image_id("/u/here.png")
iid_stale = stable_image_id("/u/removed.png")
out = build_image_overrides_style(
{
iid_stamped: {"x": 10, "y": 10, "w": 20, "h": 20},
iid_stale: {"x": 90, "y": 90, "w": 5, "h": 5},
},
[iid_stamped],
)
assert iid_stamped in out
assert iid_stale not in out
def test_build_style_emits_rules_in_stamped_id_order():
# Deterministic CSS output across renders — rules sorted by DOM
# order (stamped_ids), not by dict insertion order.
iid_a = stable_image_id("/u/a.png")
iid_b = stable_image_id("/u/b.png")
out = build_image_overrides_style(
# dict insertion order: b then a
{
iid_b: {"x": 0, "y": 0, "w": 50, "h": 50},
iid_a: {"x": 50, "y": 50, "w": 50, "h": 50},
},
# stamped order: a then b
[iid_a, iid_b],
)
assert out.index(iid_a) < out.index(iid_b)
def test_build_style_drops_malformed_geometry_entries():
iid_valid = stable_image_id("/u/ok.png")
iid_missing_axis = stable_image_id("/u/missing.png")
iid_non_numeric = stable_image_id("/u/bad.png")
iid_non_dict = stable_image_id("/u/list.png")
out = build_image_overrides_style(
{
iid_valid: {"x": 1, "y": 2, "w": 3, "h": 4},
iid_missing_axis: {"x": 1, "y": 2, "w": 3}, # no h
iid_non_numeric: {"x": "abc", "y": 2, "w": 3, "h": 4},
iid_non_dict: [1, 2, 3, 4],
},
[iid_valid, iid_missing_axis, iid_non_numeric, iid_non_dict],
)
assert iid_valid in out
assert iid_missing_axis not in out
assert iid_non_numeric not in out
assert iid_non_dict not in out
def test_build_style_coerces_int_geometry_to_float_rules():
# JSON-loaded int values round-trip through float(...) so the
# emitted CSS uses numeric values acceptable to the browser parser.
iid = stable_image_id("/u/p.png")
out = build_image_overrides_style({iid: {"x": 1, "y": 2, "w": 3, "h": 4}}, [iid])
assert "left: 1.0%" in out
assert "top: 2.0%" in out
assert "width: 3.0%" in out
assert "height: 4.0%" in out
# -- inject_image_overrides_style : <style> block injector (u7) -----------
def test_inject_style_empty_css_returns_html_unchanged():
html = "<html><head></head><body>x</body></html>"
assert inject_image_overrides_style(html, "") == html
def test_inject_style_inserts_before_head_close():
html = "<html><head><title>t</title></head><body>x</body></html>"
out = inject_image_overrides_style(html, ".x { color: red; }")
assert "<style>" in out
# injected block sits before </head>, NOT after <body>
head_idx = out.lower().index("</head>")
body_idx = out.lower().index("<body")
style_idx = out.index("<style>")
assert style_idx < head_idx < body_idx
def test_inject_style_case_insensitive_head_close():
html = "<HTML><HEAD></HEAD><BODY>x</BODY></HTML>"
out = inject_image_overrides_style(html, ".x { color: red; }")
assert "<style>" in out
# injection before </HEAD>
assert out.index("<style>") < out.upper().index("</HEAD>")
def test_inject_style_falls_back_to_body_open_when_no_head():
html = "<body>x</body>"
out = inject_image_overrides_style(html, ".x { color: red; }")
assert "<style>" in out
# injected right after the <body> open tag
body_open_end = out.index(">", out.index("<body")) + 1
assert out[body_open_end:].lstrip().startswith("<!-- IMP-51")
def test_inject_style_falls_back_to_document_start_when_no_head_or_body():
html = "<div>fragment</div>"
out = inject_image_overrides_style(html, ".x { color: red; }")
assert out.startswith("<!-- IMP-51 image_overrides start -->")
assert "<style>" in out
assert out.rstrip().endswith("</div>")
def test_inject_style_is_idempotent_on_second_call():
html = "<html><head></head><body>x</body></html>"
css = ".x { color: red; }"
once = inject_image_overrides_style(html, css)
twice = inject_image_overrides_style(once, css)
assert twice == once
# Marker block appears exactly once after both invocations.
assert once.count("<!-- IMP-51 image_overrides start -->") == 1
assert twice.count("<!-- IMP-51 image_overrides start -->") == 1
def test_inject_style_replaces_existing_block_with_new_css():
# Re-injection with different CSS replaces the previous block in
# place — the marker pair is found and its body is swapped out.
html = "<html><head></head><body>x</body></html>"
first = inject_image_overrides_style(html, ".old { color: red; }")
second = inject_image_overrides_style(first, ".new { color: blue; }")
assert ".old" not in second
assert ".new" in second
assert second.count("<!-- IMP-51 image_overrides start -->") == 1
def test_inject_style_wraps_block_with_marker_comments():
html = "<html><head></head><body>x</body></html>"
out = inject_image_overrides_style(html, ".x { color: red; }")
assert "<!-- IMP-51 image_overrides start -->" in out
assert "<!-- IMP-51 image_overrides end -->" in out
# Open marker precedes close marker, with the <style> tag between.
s = out.index("<!-- IMP-51 image_overrides start -->")
e = out.index("<!-- IMP-51 image_overrides end -->")
assert s < out.index("<style>") < out.index("</style>") < e
# -- end-to-end stamp → build → inject (u4 + u7 chained) ------------------
def test_stamp_then_build_then_inject_round_trip():
html = (
"<html><head><title>t</title></head>"
'<body><div class="slide"><img src="/u/p.png"></div></body>'
"</html>"
)
stamped_html, ids = stamp_user_content_images(html, sources=["/u/p.png"])
assert ids == [stable_image_id("/u/p.png")]
css = build_image_overrides_style(
{ids[0]: {"x": 12.5, "y": 25, "w": 40, "h": 30}}, ids,
)
out = inject_image_overrides_style(stamped_html, css)
# The stamped attribute is still present on the <img> AND the
# injected rule targets that same id.
assert f'{IMAGE_ID_ATTR}="{ids[0]}"' in out
assert f'[{IMAGE_ID_ATTR}="{ids[0]}"]' in out
assert "left: 12.5%" in out
assert "<style>" in out

View File

@@ -0,0 +1,348 @@
"""IMP-51 (#79) u5 — focused tests for the ``--override-image`` CLI surface.
Stage 2 u5 scope (per the Exit Report):
- Successful parse: single flag + multiple flags accumulate.
- Forwarding: parsed mapping reaches ``run_phase_z2_mvp1`` as
``override_image_overrides={image_id: {"x", "y", "w", "h"}}``.
- Empty payload: omitting ``--override-image`` forwards ``None``
(CLI ``or None`` collapse, sibling pattern to other axes).
- Hard-error cases (each must ``sys.exit(2)`` with a stderr message):
* missing ``=``
* empty ``IMAGE_ID``
* duplicate ``IMAGE_ID``
* wrong float count (not 4)
* non-numeric float component
The harness mirrors ``tests/test_user_overrides_pipeline_fallback.py`` —
the ``if __name__ == "__main__"`` block of ``src.phase_z2_pipeline`` is
exec'd inside the module's namespace after monkeypatching
``run_phase_z2_mvp1`` with a recording stub. This exercises the actual
production parser without invoking the real pipeline.
The persistence fallback is silenced by redirecting
``src.user_overrides_io.DEFAULT_OVERRIDES_ROOT`` to a clean tmp directory
so persisted state from prior runs cannot bleed into the parser-only
assertions here.
"""
from __future__ import annotations
import ast
import sys
from pathlib import Path
from typing import Any
import pytest
import src.phase_z2_pipeline as _pz2
import src.user_overrides_io as _io
# -- harness ---------------------------------------------------------------
def _exec_main_block(
captured: dict[str, Any], argv: list[str], monkeypatch
) -> None:
"""Run the ``__main__`` body of phase_z2_pipeline.py with a fake
``run_phase_z2_mvp1`` so its kwargs are observable."""
def _fake_run(
mdx_path,
run_id,
*,
override_layout=None,
override_frames=None,
override_zone_geometries=None,
override_section_assignments=None,
override_image_overrides=None,
):
captured["mdx_path"] = mdx_path
captured["run_id"] = run_id
captured["override_layout"] = override_layout
captured["override_frames"] = override_frames
captured["override_zone_geometries"] = override_zone_geometries
captured["override_section_assignments"] = override_section_assignments
captured["override_image_overrides"] = override_image_overrides
monkeypatch.setattr(_pz2, "run_phase_z2_mvp1", _fake_run)
monkeypatch.setattr(sys, "argv", argv)
src_path = Path(_pz2.__file__)
source = src_path.read_text(encoding="utf-8")
tree = ast.parse(source)
for node in tree.body:
if (
isinstance(node, ast.If)
and isinstance(node.test, ast.Compare)
and isinstance(node.test.left, ast.Name)
and node.test.left.id == "__name__"
):
block = ast.Module(body=node.body, type_ignores=[])
exec(compile(block, str(src_path), "exec"), _pz2.__dict__)
return
raise AssertionError("no `if __name__ == '__main__'` block found")
def _redirect_overrides_root(tmp_path: Path, monkeypatch) -> None:
"""Isolate the persistence fallback so file state never leaks in."""
monkeypatch.setattr(_io, "DEFAULT_OVERRIDES_ROOT", tmp_path)
# -- success paths --------------------------------------------------------
def test_no_image_override_forwards_none(tmp_path, monkeypatch):
"""When ``--override-image`` is omitted, the kwarg must be ``None``
(the parser's accumulator stays empty → ``overrides_images or None``)."""
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
assert captured["override_image_overrides"] is None
def test_single_image_override_parses_and_forwards(tmp_path, monkeypatch):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,15,30.5,25",
],
monkeypatch,
)
assert captured["override_image_overrides"] == {
"img-abc": {"x": 10.0, "y": 15.0, "w": 30.5, "h": 25.0},
}
def test_multiple_image_overrides_accumulate(tmp_path, monkeypatch):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,15,30,25",
"--override-image",
"img-def=50,15,40,40",
],
monkeypatch,
)
assert captured["override_image_overrides"] == {
"img-abc": {"x": 10.0, "y": 15.0, "w": 30.0, "h": 25.0},
"img-def": {"x": 50.0, "y": 15.0, "w": 40.0, "h": 40.0},
}
def test_image_override_strips_whitespace_in_image_id(tmp_path, monkeypatch):
"""``iid.strip()`` is intentional — match sibling --override-frame and
--override-zone-geometry leniency on surrounding whitespace."""
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
" img-pad =5,5,10,10",
],
monkeypatch,
)
assert captured["override_image_overrides"] == {
"img-pad": {"x": 5.0, "y": 5.0, "w": 10.0, "h": 10.0},
}
# -- hard-error paths -----------------------------------------------------
def test_image_override_missing_equals_exits(tmp_path, monkeypatch, capsys):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc10,15,30,25",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "--override-image must be IMAGE_ID=X,Y,W,H" in err
def test_image_override_empty_image_id_exits(tmp_path, monkeypatch, capsys):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"=10,15,30,25",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "IMAGE_ID must be non-empty" in err
def test_image_override_whitespace_only_image_id_exits(
tmp_path, monkeypatch, capsys
):
"""``iid.strip()`` must collapse whitespace-only IDs into the empty-ID
error path (otherwise a spurious key would land in the mapping)."""
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
" =10,15,30,25",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "IMAGE_ID must be non-empty" in err
def test_image_override_duplicate_image_id_exits(
tmp_path, monkeypatch, capsys
):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,15,30,25",
"--override-image",
"img-abc=20,25,30,35",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "duplicate IMAGE_ID 'img-abc'" in err
def test_image_override_wrong_float_count_exits(
tmp_path, monkeypatch, capsys
):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,15,30",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "expects 4 floats X,Y,W,H" in err
def test_image_override_too_many_floats_exits(
tmp_path, monkeypatch, capsys
):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,15,30,25,99",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "expects 4 floats X,Y,W,H" in err
def test_image_override_non_numeric_value_exits(
tmp_path, monkeypatch, capsys
):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,abc,30,25",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "floats parse fail" in err
# -- isolation guard ------------------------------------------------------
def test_image_override_does_not_leak_into_sibling_axes(tmp_path, monkeypatch):
"""A populated image override must not perturb the other four axes."""
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,15,30,25",
],
monkeypatch,
)
assert captured["override_image_overrides"] == {
"img-abc": {"x": 10.0, "y": 15.0, "w": 30.0, "h": 25.0},
}
assert captured["override_layout"] is None
assert captured["override_frames"] is None
assert captured["override_zone_geometries"] is None
assert captured["override_section_assignments"] is None

View File

@@ -1,8 +1,9 @@
"""IMP-52 (#80) u8 — backend tests for ``src.user_overrides_io``.
Covers the four axes called out in the Stage 2 plan:
Covers the persisted axes called out in the Stage 2 plan
(IMP-51 #79 u1 extended this to 5 axes by adding ``image_overrides``):
1. Round-trip ``save`` → ``load`` (4 KNOWN_AXES + foreign top-level keys).
1. Round-trip ``save`` → ``load`` (5 KNOWN_AXES + foreign top-level keys).
2. Unknown-key passthrough (foreign axes preserved across partial merges).
3. Missing / corrupt / non-object behavior (graceful ``{}`` + stderr warning).
4. Invalid keys (``InvalidOverrideKey`` raised on traversal / separators /
@@ -115,9 +116,18 @@ def _full_payload() -> dict:
},
"zone_sections": {"zone-top": ["03-1", "03-2"]},
"frames": {"03-1+03-2": "frame_two_way_compare"},
"image_overrides": {
"img-1": {"x": 10.0, "y": 20.0, "w": 30.0, "h": 25.0},
},
}
def test_known_axes_includes_image_overrides():
"""IMP-51 #79 u1 — ``image_overrides`` is a known axis (5 total)."""
assert "image_overrides" in KNOWN_AXES
assert len(KNOWN_AXES) == 5
def test_save_then_load_round_trip(tmp_path):
key = "03"
payload = _full_payload()
@@ -141,6 +151,28 @@ def test_save_partial_payload_preserves_other_axes(tmp_path):
assert loaded["zone_geometries"] == _full_payload()["zone_geometries"]
assert loaded["zone_sections"] == _full_payload()["zone_sections"]
assert loaded["frames"] == _full_payload()["frames"]
assert loaded["image_overrides"] == _full_payload()["image_overrides"]
def test_save_partial_image_overrides_preserves_other_axes(tmp_path):
"""IMP-51 #79 u1 — partial ``image_overrides`` write preserves siblings."""
key = "03"
save(key, _full_payload(), root=tmp_path)
save(
key,
{"image_overrides": {"img-9": {"x": 5.0, "y": 5.0, "w": 50.0, "h": 50.0}}},
root=tmp_path,
)
loaded = load(key, root=tmp_path)
assert loaded["image_overrides"] == {
"img-9": {"x": 5.0, "y": 5.0, "w": 50.0, "h": 50.0}
}
assert loaded["layout"] == _full_payload()["layout"]
assert loaded["zone_geometries"] == _full_payload()["zone_geometries"]
assert loaded["zone_sections"] == _full_payload()["zone_sections"]
assert loaded["frames"] == _full_payload()["frames"]
def test_save_axis_replaces_not_deep_merges(tmp_path):
@@ -162,14 +194,15 @@ def test_save_none_clears_axis(tmp_path):
def test_save_preserves_foreign_top_level_keys(tmp_path):
"""Forward-compat: axes outside KNOWN_AXES (zone_sizes, image_overrides,
schema_version, ...) must survive a partial merge on a known axis."""
"""Forward-compat: axes outside KNOWN_AXES (zone_sizes, schema_version,
...) must survive a partial merge on a known axis. (IMP-51 #79 u1
promoted ``image_overrides`` to a known axis, so it is no longer
exercised here as a foreign key.)"""
key = "03"
path = override_path(key, root=tmp_path)
path.parent.mkdir(parents=True, exist_ok=True)
pre_seed = {
"layout": "single-column",
"image_overrides": {"img-1": {"position": "right", "size": "small"}},
"zone_sizes": {"zone-top": "tall"},
"schema_version": "experimental-1",
}
@@ -179,7 +212,6 @@ def test_save_preserves_foreign_top_level_keys(tmp_path):
loaded = load(key, root=tmp_path)
assert loaded["layout"] == "sidebar-right"
assert loaded["image_overrides"] == pre_seed["image_overrides"]
assert loaded["zone_sizes"] == pre_seed["zone_sizes"]
assert loaded["schema_version"] == pre_seed["schema_version"]
@@ -197,10 +229,11 @@ def test_save_writes_pretty_sorted_json_for_diffability(tmp_path):
raw = (tmp_path / "03.json").read_text(encoding="utf-8")
# sort_keys=True → KNOWN_AXES come out alphabetically
pos_frames = raw.index('"frames"')
pos_image_overrides = raw.index('"image_overrides"')
pos_layout = raw.index('"layout"')
pos_zg = raw.index('"zone_geometries"')
pos_zs = raw.index('"zone_sections"')
assert pos_frames < pos_layout < pos_zg < pos_zs
assert pos_frames < pos_image_overrides < pos_layout < pos_zg < pos_zs
def test_save_leaves_no_tmp_file_on_success(tmp_path):

View File

@@ -47,6 +47,7 @@ def _exec_main_block(
override_frames=None,
override_zone_geometries=None,
override_section_assignments=None,
override_image_overrides=None,
):
captured["mdx_path"] = mdx_path
captured["run_id"] = run_id
@@ -54,6 +55,7 @@ def _exec_main_block(
captured["override_frames"] = override_frames
captured["override_zone_geometries"] = override_zone_geometries
captured["override_section_assignments"] = override_section_assignments
captured["override_image_overrides"] = override_image_overrides
monkeypatch.setattr(_pz2, "run_phase_z2_mvp1", _fake_run)
monkeypatch.setattr(sys, "argv", argv)
@@ -95,6 +97,10 @@ def _write_full_payload(tmp_path: Path, stem: str = "03") -> Path:
"top": ["03-1"],
"bottom": ["03-2", "03-3"],
},
"image_overrides": {
"img-file-a": {"x": 10.0, "y": 15.0, "w": 30.0, "h": 25.0},
"img-file-b": {"x": 50.0, "y": 50.0, "w": 40.0, "h": 40.0},
},
}
),
encoding="utf-8",
@@ -114,6 +120,7 @@ def test_no_overrides_file_passes_none_overrides(tmp_path, monkeypatch):
assert captured["override_frames"] is None
assert captured["override_zone_geometries"] is None
assert captured["override_section_assignments"] is None
assert captured["override_image_overrides"] is None
# MDX path / run_id propagate untouched.
assert captured["mdx_path"] == Path("03.mdx")
assert captured["run_id"] is None
@@ -122,7 +129,7 @@ def test_no_overrides_file_passes_none_overrides(tmp_path, monkeypatch):
# -- 2. file fills every axis when CLI is empty ----------------------------
def test_file_only_fills_all_four_axes_when_cli_empty(tmp_path, monkeypatch):
def test_file_only_fills_all_five_axes_when_cli_empty(tmp_path, monkeypatch):
_redirect_overrides_root(tmp_path, monkeypatch)
_write_full_payload(tmp_path, "03")
@@ -142,6 +149,10 @@ def test_file_only_fills_all_four_axes_when_cli_empty(tmp_path, monkeypatch):
"top": ["03-1"],
"bottom": ["03-2", "03-3"],
}
assert captured["override_image_overrides"] == {
"img-file-a": {"x": 10.0, "y": 15.0, "w": 30.0, "h": 25.0},
"img-file-b": {"x": 50.0, "y": 50.0, "w": 40.0, "h": 40.0},
}
# -- 3. CLI beats file on the same axis -----------------------------------
@@ -190,6 +201,37 @@ def test_cli_frames_overrides_file_frames(tmp_path, monkeypatch):
assert captured["override_layout"] == "sidebar-right"
assert captured["override_zone_geometries"] is not None
assert captured["override_section_assignments"] is not None
assert captured["override_image_overrides"] is not None
# -- 3b. CLI image override beats file image override (IMP-51 #79 u6) -----
def test_cli_image_override_overrides_file_image_overrides(tmp_path, monkeypatch):
_redirect_overrides_root(tmp_path, monkeypatch)
_write_full_payload(tmp_path, "03")
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-cli=70,80,20,15",
],
monkeypatch,
)
# CLI ``image_overrides`` payload wholly replaces file payload (per-axis).
assert captured["override_image_overrides"] == {
"img-cli": {"x": 70.0, "y": 80.0, "w": 20.0, "h": 15.0},
}
# Other axes still come from the file.
assert captured["override_layout"] == "sidebar-right"
assert captured["override_frames"] is not None
assert captured["override_zone_geometries"] is not None
assert captured["override_section_assignments"] is not None
# -- 4. corrupt / non-object file warns and skips fallback ----------------
@@ -209,6 +251,7 @@ def test_corrupt_json_warns_and_skips_fallback(tmp_path, monkeypatch, capsys):
assert captured["override_frames"] is None
assert captured["override_zone_geometries"] is None
assert captured["override_section_assignments"] is None
assert captured["override_image_overrides"] is None
def test_non_object_top_level_warns_and_skips_fallback(
@@ -226,6 +269,7 @@ def test_non_object_top_level_warns_and_skips_fallback(
assert captured["override_frames"] is None
assert captured["override_zone_geometries"] is None
assert captured["override_section_assignments"] is None
assert captured["override_image_overrides"] is None
# -- 5. invalid MDX stem warns and skips fallback wholesale ---------------
@@ -251,6 +295,7 @@ def test_invalid_mdx_stem_warns_and_skips_fallback(
assert captured["override_frames"] is None
assert captured["override_zone_geometries"] is None
assert captured["override_section_assignments"] is None
assert captured["override_image_overrides"] is None
# -- 6. per-axis partial fill (file fills only what CLI omits) ------------
@@ -294,3 +339,72 @@ def test_per_axis_partial_fill_mixes_cli_and_file(tmp_path, monkeypatch):
"top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.5},
}
assert captured["override_section_assignments"] is None
assert captured["override_image_overrides"] is None
# -- 7. image_overrides fallback edge cases (IMP-51 #79 u6) ---------------
def test_image_overrides_fallback_drops_malformed_entries(tmp_path, monkeypatch):
"""File carries a mix of valid + malformed image_overrides entries.
Expected: valid entry survives; malformed entries (non-string id,
empty id, non-dict value, missing key, non-numeric value) are silently
dropped — no exception propagates.
"""
_redirect_overrides_root(tmp_path, monkeypatch)
(tmp_path / "03.json").write_text(
json.dumps(
{
"image_overrides": {
"img-valid": {"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0},
"": {"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0},
"img-not-dict": "oops",
"img-missing-h": {"x": 1.0, "y": 2.0, "w": 3.0},
"img-bad-value": {"x": "abc", "y": 2.0, "w": 3.0, "h": 4.0},
}
}
),
encoding="utf-8",
)
captured: dict[str, Any] = {}
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
assert captured["override_image_overrides"] == {
"img-valid": {"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0},
}
def test_image_overrides_fallback_non_dict_axis_is_ignored(tmp_path, monkeypatch):
"""File ``image_overrides`` is a non-dict (list); fallback silently skips."""
_redirect_overrides_root(tmp_path, monkeypatch)
(tmp_path / "03.json").write_text(
json.dumps({"image_overrides": ["not", "a", "dict"]}),
encoding="utf-8",
)
captured: dict[str, Any] = {}
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
# ``overrides_images`` stays empty; ``or None`` collapses on call site.
assert captured["override_image_overrides"] is None
def test_image_overrides_fallback_coerces_int_values_to_float(tmp_path, monkeypatch):
"""JSON-loaded ints (e.g. ``10`` not ``10.0``) must coerce to float."""
_redirect_overrides_root(tmp_path, monkeypatch)
(tmp_path / "03.json").write_text(
json.dumps(
{"image_overrides": {"img-int": {"x": 10, "y": 20, "w": 30, "h": 40}}}
),
encoding="utf-8",
)
captured: dict[str, Any] = {}
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
coerced = captured["override_image_overrides"]
assert coerced == {"img-int": {"x": 10.0, "y": 20.0, "w": 30.0, "h": 40.0}}
for axis_value in coerced["img-int"].values():
assert isinstance(axis_value, float)