Files
diagram-design/scripts/test-verify-treemap.py
T
Cathryn LaveryandGitHub bf49fb408a fix(treemap): fail closed for CJK label widths (#121)
* Fix CJK treemap label width verification

* Strengthen CJK treemap width regressions
2026-08-19 19:35:06 -05:00

277 lines
13 KiB
Python

#!/usr/bin/env python3
"""Adversarial tests for verify-treemap.py — both polarities.
Per ADR 0005, a geometric contract in this repo is a checker plus fixtures that
prove it fires when it should and stays quiet when it shouldn't. The mutations
below are the two defects that actually shipped in review: a sliver cell drawn a
third too small, and a label longer than the cell holding it.
Usage: python3 scripts/test-verify-treemap.py
Exit: 0 all pass, 1 a case failed.
"""
from __future__ import annotations
import runpy
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CHECKER = ROOT / "scripts/verify-treemap.py"
GOOD = ROOT / "skills/diagram-design/assets/example-treemap.html"
SHIPPED = sorted(GOOD.parent.glob("example-treemap*.html"))
ESTIMATED_ADVANCE = runpy.run_path(str(CHECKER), run_name="verify_treemap_test")[
"estimated_advance"
]
KOREAN_LABEL = "남아메리카 인구 구성 비율 요약"
JAPANESE_LABEL = "南アメリカ人口構成比率の概要"
def run(path: Path) -> tuple[int, str]:
result = subprocess.run(
[sys.executable, str(CHECKER), str(path)],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
return result.returncode, (result.stdout or "") + (result.stderr or "")
def write(directory: Path, name: str, source: str) -> Path:
path = directory / name
path.write_text(source, encoding="utf-8")
return path
def main() -> int:
source = GOOD.read_text(encoding="utf-8")
failures: list[str] = []
# Pin the estimator independently from CLI diagnostics. The Korean fixture
# has 13 wide glyphs and four narrow spaces; the Japanese fixture has 14
# wide glyphs. This catches a width-contract regression without coupling
# the overflow fixture to its human-readable pixel message.
for label, expected, language in (
(KOREAN_LABEL, 15.4, "Korean"),
(JAPANESE_LABEL, 14.0, "Japanese"),
):
actual = ESTIMATED_ADVANCE(label, False)
if abs(actual - expected) > 1e-9:
failures.append(
f"{language} estimator contract moved: expected {expected:g}em, got {actual:g}em"
)
else:
print(f"OK: {language} wide-glyph estimator contract is {expected:g}em")
with tempfile.TemporaryDirectory() as raw:
directory = Path(raw)
# 1. Every shipped Latin example must pass untouched, proving the
# Unicode estimator did not move the calibrated Latin baseline.
shipped_clean = True
for path in SHIPPED:
code, output = run(path)
if code != 0:
shipped_clean = False
failures.append(f"clean example {path.name} was rejected: {output.strip()}")
if shipped_clean:
print(f"OK: all {len(SHIPPED)} shipped Latin treemaps pass")
# 2. Shrink a labelled cell below the share it prints. This is the
# defect that shipped in review — a cell drawn smaller than it claims
# — caught here on Africa, which carries a label to contradict.
shrunk = source.replace(
'<rect x="576" y="40" width="252" height="252"',
'<rect x="576" y="40" width="200" height="252"',
)
if shrunk == source:
failures.append("could not build the undersized-cell fixture (anchor moved)")
else:
code, output = run(write(directory, "undersized.html", shrunk))
if code == 0:
failures.append("cell drawn under its stated share was accepted")
elif "relative" not in output:
failures.append(f"undersized cell reported without an area finding: {output.strip()}")
else:
print("OK: a cell drawn smaller than its label is rejected")
# 3. Inflate Asia instead — the opposite direction of the same defect.
# Both rects move: a cell is painted as a paper mask then a body, and
# mutating only one leaves the original size still present, which the
# smallest-host rule would quietly measure instead.
inflated = source.replace(
'<rect x="40" y="40" width="532" height="380"',
'<rect x="40" y="40" width="700" height="380"',
)
if inflated == source:
failures.append("could not build the oversized-cell fixture (anchor moved)")
else:
code, _ = run(write(directory, "oversized.html", inflated))
if code == 0:
failures.append("oversized focal cell was accepted")
else:
print("OK: a cell drawn larger than its label is rejected")
# 4. Overrun a label past its own cell.
overflowing = source.replace(
">South America</text>",
">South America and the Caribbean Basin</text>",
)
if overflowing == source:
failures.append("could not build the overflowing-label fixture (anchor moved)")
else:
code, output = run(write(directory, "overflow.html", overflowing))
if code == 0:
failures.append("label overflowing its cell was accepted")
elif "overflows" not in output:
failures.append(f"overflow reported without a fit finding: {output.strip()}")
else:
print("OK: a label wider than its cell is rejected")
# 4a. Reproduce #115: most glyphs in this Korean translation are
# full-width. The old 0.60em character-count estimate called it
# 122.4px wide and accepted it in 132px of available space, while
# Chromium measured 146.2px. A conservative 1em wide-glyph advance
# must fail closed without changing the Latin estimate.
korean = source.replace(
'<text x="804" y="324" fill="#2d3142" font-size="12" font-weight="600" '
'font-family="\'Geist\', sans-serif">South America</text>',
'<text x="804" y="324" fill="#2d3142" font-size="12" font-weight="600" '
'font-family="\'Geist\', \'Apple SD Gothic Neo\', \'Noto Sans KR\', '
f'\'Malgun Gothic\', sans-serif">{KOREAN_LABEL}</text>',
)
if korean == source:
failures.append("could not build the Korean-overflow fixture (anchor moved)")
else:
code, output = run(write(directory, "korean-overflow.html", korean))
if code == 0:
failures.append("full-width Korean label overflowing its cell was accepted")
elif "label" not in output or "overflows" not in output:
failures.append(f"Korean fixture lacked a label-overflow finding: {output.strip()}")
else:
print("OK: a Korean label in the old-estimator boundary gap is rejected")
# 4b. Japanese Han and kana are also Unicode-wide. Cover the documented
# CJK contract beyond Hangul with another label that fits under the
# old 0.60em estimate but overflows under the conservative contract.
japanese = source.replace(
'<text x="804" y="324" fill="#2d3142" font-size="12" font-weight="600" '
'font-family="\'Geist\', sans-serif">South America</text>',
'<text x="804" y="324" fill="#2d3142" font-size="12" font-weight="600" '
'font-family="\'Geist\', \'Hiragino Sans\', \'Noto Sans JP\', '
f'\'Yu Gothic\', sans-serif">{JAPANESE_LABEL}</text>',
)
if japanese == source:
failures.append("could not build the Japanese-overflow fixture (anchor moved)")
else:
code, output = run(write(directory, "japanese-overflow.html", japanese))
if code == 0:
failures.append("full-width Japanese label overflowing its cell was accepted")
elif "label" not in output or "overflows" not in output:
failures.append(f"Japanese fixture lacked a label-overflow finding: {output.strip()}")
else:
print("OK: a Japanese full-width label is rejected when it overflows")
# 4c. Overflow off the LEFT edge, via an end-anchored label. Checking
# only the right and bottom edges would pass this silently, and the
# text would read as belonging to the cell next door.
left_over = source.replace(
'<text x="804" y="324" fill="#2d3142" font-size="12" font-weight="600"',
'<text x="804" y="324" text-anchor="end" fill="#2d3142" font-size="12" font-weight="600"',
)
if left_over == source:
failures.append("could not build the left-overflow fixture (anchor moved)")
else:
code, output = run(write(directory, "leftoverflow.html", left_over))
if code == 0:
failures.append("label hanging off the left of its cell was accepted")
elif "left" not in output:
failures.append(f"left overflow not named in the finding: {output.strip()}")
else:
print("OK: a label overflowing the left edge is rejected")
# 4d. Overflow off the TOP edge — the label's ascent clears the cell.
top_over = source.replace(
'<text x="592" y="68" fill="#2d3142" font-size="13"',
'<text x="592" y="44" fill="#2d3142" font-size="13"',
)
if top_over == source:
failures.append("could not build the top-overflow fixture (anchor moved)")
else:
code, output = run(write(directory, "topoverflow.html", top_over))
if code == 0:
failures.append("label overflowing the top of its cell was accepted")
elif "top" not in output:
failures.append(f"top overflow not named in the finding: {output.strip()}")
else:
print("OK: a label overflowing the top edge is rejected")
# 4e. A sliver can be narrower than the fixed 10px information-mark
# disc. The marker must never cross into the neighbouring cell.
marker_over = source.replace(
'<rect x="940" y="296" width="16" height="124"',
'<rect x="940" y="296" width="8" height="124"',
).replace('cx="948" cy="358"', 'cx="944" cy="358"').replace(
'<text x="948" y="361"', '<text x="944" y="361"'
)
if marker_over == source:
failures.append("could not build the overflowing-marker fixture (anchor moved)")
else:
code, output = run(write(directory, "markeroverflow.html", marker_over))
if code == 0:
failures.append("information mark overflowing a sliver cell was accepted")
elif "information marker" not in output:
failures.append(f"marker overflow not named in the finding: {output.strip()}")
else:
print("OK: an information mark overflowing its cell is rejected")
# 4f. Combining marks do not advance independently. Eighteen decomposed
# accented glyphs fit by the unchanged Latin baseline (129.6px in
# 132px), but counting every acute accent as a character would
# incorrectly double the estimate and reject the label.
decomposed = ("e\N{COMBINING ACUTE ACCENT}" * 18)
combining = source.replace(">South America</text>", f">{decomposed}</text>")
if combining == source:
failures.append("could not build the combining-mark fixture (anchor moved)")
else:
code, output = run(write(directory, "combining-marks.html", combining))
if code != 0:
failures.append(f"non-advancing combining marks were overcounted: {output.strip()}")
else:
print("OK: combining marks do not create phantom label overflow")
# 5. The shipped example leaves its smallest cell deliberately
# unlabelled. That must not switch the area check off for the cells
# that ARE labelled — the failure mode where a checker reports clean
# because it found nothing to compare.
unlabelled_shrunk = source.replace(
'<rect x="576" y="296" width="208" height="124"',
'<rect x="576" y="296" width="140" height="124"',
)
if unlabelled_shrunk == source:
failures.append("could not build the still-checks-labelled-cells fixture")
else:
code, _ = run(write(directory, "unlabelled.html", unlabelled_shrunk))
if code == 0:
failures.append(
"area check went silent when a cell was unlabelled — a labelled "
"cell was resized and no finding was reported"
)
else:
print("OK: an unlabelled sliver does not disable the area check")
for failure in failures:
print(f"FAIL: {failure}")
if failures:
print(f"\n{len(failures)} case(s) failed.")
return 1
print("\nOK verify-treemap: both polarities behave")
return 0
if __name__ == "__main__":
raise SystemExit(main())