401 lines
15 KiB
Python
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
|