Files
C.E.L_Slide_test2/tests/test_image_id_stamper.py

401 lines
15 KiB
Python

"""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