"""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 ```` is preserved. 9. ```` 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 = '
' out, ids = stamp_user_content_images(html, sources=()) assert out == html assert ids == [] def test_stamp_all_non_string_sources_is_noop(): html = '' 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 = '
photo
' 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 = ( '
' '' '' '
' ) out, ids = stamp_user_content_images(html, sources=["/u/photo.png"]) # decorative img untouched (no data-image-role injected on it) assert '' 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('', 1)[0] assert IMAGE_ROLE_ATTR not in decorative_segment def test_stamp_is_idempotent_on_second_invocation(): html = '
' 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 = ( '
' '' '' '' '
' ) _, 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 = "
" 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 = '
' 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' 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 : ") < e # -- end-to-end stamp → build → inject (u4 + u7 chained) ------------------ def test_stamp_then_build_then_inject_round_trip(): html = ( "t" '
' "" ) 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 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 "