feat(#79): IMP-51 image_overrides axis (u1~u11 backend stamp+CLI+CSS inject + frontend drag/resize+persistence + tests)
This commit is contained in:
400
tests/test_image_id_stamper.py
Normal file
400
tests/test_image_id_stamper.py
Normal 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
|
||||
348
tests/test_phase_z2_cli_overrides.py
Normal file
348
tests/test_phase_z2_cli_overrides.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user