zcbot/tests/test_svg_alignment_check.py

379 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""svg_quality_checker 对齐/网格/单调检查(check 14)的 focused tests。
合成 SVG 三类病灶:兄弟卡片差几 px 不齐、内容块偏离 layout_grid 锁、
同一卡网格原型重复 —— 对应 d1285247 陶瓷 deck 复盘出的真实缺陷。
纯几何逻辑,不依赖渲染器。
"""
import contextlib
import io
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
SCRIPTS = Path(__file__).parent.parent / "skills" / "ppt" / "scripts"
sys.path.insert(0, str(SCRIPTS))
from svg_quality_checker import SVGQualityChecker # noqa: E402
def _svg(body: str) -> str:
return ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" '
'width="1280" height="720">' + body + '</svg>')
def _card(x, y, w=200, h=120):
return (f'<rect x="{x}" y="{y}" width="{w}" height="{h}" '
f'fill="#FFFFFF" stroke="#333333"/>')
def _icon(x, y, name="home"):
return (f'<use data-icon="tabler-outline/{name}" x="{x}" y="{y}" '
f'width="32" height="32" fill="#C00000"/>')
def _text(x, y, s="标签"):
return f'<text x="{x}" y="{y}" font-size="16" fill="#333333">{s}</text>'
def _write_page(project: Path, name: str, body: str) -> Path:
out = project / "svg_output"
out.mkdir(parents=True, exist_ok=True)
p = out / name
p.write_text(_svg(body), encoding="utf-8")
return p
def _alignment_errors(result):
return [e for e in result["errors"] if e.startswith("Alignment:")]
def _alignment_warnings(result):
return [w for w in result["warnings"] if w.startswith("Alignment:")]
class SiblingAlignmentTests(unittest.TestCase):
def _check(self, body: str):
with TemporaryDirectory() as tmp:
page = _write_page(Path(tmp), "03_content.svg", body)
return SVGQualityChecker().check_file(str(page))
def test_row_mates_offset_6px_is_error(self):
r = self._check(_card(100, 200) + _card(340, 206))
errs = _alignment_errors(r)
self.assertTrue(any("row-mate" in e for e in errs), errs)
def test_row_mates_exact_align_passes(self):
r = self._check(_card(100, 200) + _card(340, 200))
self.assertEqual(_alignment_errors(r), [])
self.assertEqual(_alignment_warnings(r), [])
def test_deliberate_stagger_24px_passes(self):
r = self._check(_card(100, 200) + _card(340, 224))
self.assertEqual(_alignment_errors(r), [])
self.assertEqual(_alignment_warnings(r), [])
def test_column_mates_offset_5px_is_error(self):
r = self._check(_card(100, 200) + _card(105, 360))
errs = _alignment_errors(r)
self.assertTrue(any("column-mate" in e for e in errs), errs)
def test_row_mates_height_mismatch_warns(self):
r = self._check(_card(100, 200, h=120) + _card(340, 200, h=125))
warns = _alignment_warnings(r)
self.assertTrue(any("height" in w for w in warns), warns)
def test_center_aligned_pair_exempt(self):
# 树节点宽度不同但中心同轴(640):左缘差 10px 是居中方案,不是事故
r = self._check(_card(540, 100, w=200, h=90) + _card(550, 300, w=180, h=90))
self.assertEqual(_alignment_errors(r), [])
def test_baseline_anchored_pair_exempt(self):
# 底对齐、顶差 5px(数据柱形态):存在对齐方案,不报
r = self._check(_card(100, 200, h=120) + _card(340, 205, h=115))
self.assertEqual(_alignment_errors(r), [])
def test_plot_area_bars_excluded(self):
# 绘图区标记内的"柱子"错位不报(值编码,非版式)
body = ('<!-- chart-plot-area: 50,100,1200,600 -->'
+ _card(100, 200) + _card(340, 206))
r = self._check(body)
self.assertEqual(_alignment_errors(r), [])
def test_uneven_row_gaps_warn(self):
# gaps 30 / 36 / 30 —— 近等不等,该报;2+1 分组的大差距不报
body = (_card(60, 300) + _card(290, 300)
+ _card(526, 300) + _card(756, 300))
r = self._check(body)
warns = _alignment_warnings(r)
self.assertTrue(any("uneven gaps" in w for w in warns), warns)
def test_grouped_row_large_gap_spread_passes(self):
# gaps 30 / 30 / 120 —— 明显是分组设计,不报
body = (_card(60, 300) + _card(290, 300)
+ _card(520, 300) + _card(840, 300))
r = self._check(body)
self.assertFalse(
any("uneven gaps" in w for w in _alignment_warnings(r)))
class LayoutGridLockTests(unittest.TestCase):
def _check(self, body: str, lock: str, name="03_content.svg"):
with TemporaryDirectory() as tmp:
project = Path(tmp)
(project / "spec_lock.md").write_text(lock, encoding="utf-8")
page = _write_page(project, name, body)
return SVGQualityChecker().check_file(str(page))
LOCK = "## layout_grid\n- margin_x: 60\n- content_top: 150\n"
def test_card_6px_off_margin_is_error(self):
r = self._check(_card(66, 150), self.LOCK)
errs = _alignment_errors(r)
self.assertTrue(any("margin_x" in e for e in errs), errs)
def test_card_on_grid_passes(self):
r = self._check(_card(60, 150), self.LOCK)
self.assertEqual(_alignment_errors(r), [])
def test_clean_break_over_16px_passes(self):
r = self._check(_card(100, 200), self.LOCK)
self.assertEqual(_alignment_errors(r), [])
def test_icon_8px_off_content_top_is_error(self):
r = self._check(_icon(60, 158), self.LOCK)
errs = _alignment_errors(r)
self.assertTrue(any("content_top" in e for e in errs), errs)
def test_anchor_rhythm_page_exempt(self):
lock = self.LOCK + "\n## page_rhythm\n- P01: anchor\n"
r = self._check(_card(66, 150), lock, name="01_cover.svg")
self.assertEqual(_alignment_errors(r), [])
class DeckAggregationTests(unittest.TestCase):
def _run_deck(self, pages: dict, lock: str = None):
"""pages: {filename: body}; 返回 (checker, print_summary 输出)。"""
with TemporaryDirectory() as tmp:
project = Path(tmp)
if lock is not None:
(project / "spec_lock.md").write_text(lock, encoding="utf-8")
checker = SVGQualityChecker()
for name, body in sorted(pages.items()):
page = _write_page(project, name, body)
checker.check_file(str(page))
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
checker.print_summary()
return checker, buf.getvalue()
@staticmethod
def _content_page(margin: float) -> str:
return "".join(_text(margin, 100 + 40 * i, f"{i}") for i in range(4))
def test_margin_drift_across_pages_warns(self):
# 边距间距须 > _ALIGN_TOL(3px)才算漂移;用 4px 步长明确越过容差
pages = {
"01_a.svg": self._content_page(60),
"02_b.svg": self._content_page(64),
"03_c.svg": self._content_page(68),
"04_d.svg": self._content_page(60),
}
_, out = self._run_deck(pages)
self.assertIn("Margin drift", out)
def test_consistent_margin_no_drift_warning(self):
pages = {f"{i:02d}_p.svg": self._content_page(60) for i in range(1, 5)}
_, out = self._run_deck(pages)
self.assertNotIn("Margin drift", out)
def test_locked_grid_disables_drift_fallback(self):
pages = {
"01_a.svg": self._content_page(60),
"02_b.svg": self._content_page(64),
"03_c.svg": self._content_page(68),
"04_d.svg": self._content_page(60),
}
# margin 值刻意与页面无关:fallback 该关闭,页级 error 才是出口
_, out = self._run_deck(pages, lock="## layout_grid\n- margin_x: 60\n")
self.assertNotIn("Margin drift", out)
@staticmethod
def _icon_grid_page() -> str:
body = ""
for r in range(2):
for c in range(3):
body += _icon(100 + 420 * c, 200 + 240 * r)
return body
@staticmethod
def _diagram_page(seed: int) -> str:
return (f'<path d="M 100 {300 + seed} L 500 200 L 900 400" '
f'stroke="#C00000" fill="none"/>' + _text(60, 100))
def test_four_same_icon_grids_is_error(self):
pages = {f"{i:02d}_g.svg": self._icon_grid_page() for i in range(1, 5)}
pages["05_d.svg"] = self._diagram_page(1)
pages["06_e.svg"] = self._diagram_page(2)
checker, out = self._run_deck(pages)
self.assertIn("[ERROR] Layout monotony", out)
self.assertGreaterEqual(checker.summary["errors"], 1)
def test_three_same_icon_grids_warns(self):
pages = {f"{i:02d}_g.svg": self._icon_grid_page() for i in range(1, 4)}
pages["04_d.svg"] = self._diagram_page(1)
pages["05_e.svg"] = self._diagram_page(2)
pages["06_f.svg"] = self._diagram_page(3)
pages["07_h.svg"] = self._diagram_page(4)
_, out = self._run_deck(pages)
self.assertIn("[WARN] Layout monotony", out)
def test_varied_deck_no_monotony(self):
pages = {"01_g.svg": self._icon_grid_page(),
"02_g.svg": self._icon_grid_page()}
for i in range(3, 8):
pages[f"{i:02d}_d.svg"] = self._diagram_page(i)
_, out = self._run_deck(pages)
self.assertNotIn("Layout monotony", out)
@staticmethod
def _text_columns_page() -> str:
# "图标小标题+文字列表 ×2 栏"零图形页(d1285247 二代产物 S8/S9/S16/S21 形态)
body = ""
for col_x in (66, 690):
for i in range(5):
body += _text(col_x, 300 + 40 * i, f"要点第{i}行文字")
return body
def test_four_bare_text_column_pages_is_error(self):
pages = {f"{i:02d}_t.svg": self._text_columns_page() for i in range(1, 5)}
pages["05_d.svg"] = self._diagram_page(1)
pages["06_e.svg"] = self._diagram_page(2)
_, out = self._run_deck(pages)
self.assertIn("[ERROR] Layout monotony", out)
self.assertIn("bare-text-list", out)
def test_text_columns_with_diagram_not_fingerprinted(self):
# 同样的文字栏但页上有真图形(≥3 基元,如时间轴线+节点)→ 不算裸文字页
timeline = ('<path d="M 100 250 L 1100 250" stroke="#C00000" fill="none"/>'
'<circle cx="300" cy="250" r="6" fill="#C00000"/>'
'<circle cx="700" cy="250" r="6" fill="#C00000"/>')
pages = {f"{i:02d}_t.svg": self._text_columns_page() + timeline
for i in range(1, 5)}
pages["05_d.svg"] = self._diagram_page(5)
pages["06_e.svg"] = self._diagram_page(6)
_, out = self._run_deck(pages)
self.assertNotIn("bare-text-list", out)
class SpecContractTests(unittest.TestCase):
"""spec 指派图表落空 + content_bottom 越界 + CJK 叠压升级。"""
def _check(self, body: str, lock: str, name="02_content.svg"):
with TemporaryDirectory() as tmp:
project = Path(tmp)
(project / "spec_lock.md").write_text(lock, encoding="utf-8")
page = _write_page(project, name, body)
return SVGQualityChecker().check_file(str(page))
def test_assigned_chart_degraded_to_text_is_error(self):
lock = "## page_charts\n- P02: bar_chart\n"
body = "".join(_text(66, 200 + 40 * i, f"目标{i}") for i in range(6))
r = self._check(body, lock)
errs = _alignment_errors(r)
self.assertTrue(any("page_charts" in e and "bar_chart" in e for e in errs),
errs)
def test_assigned_chart_with_figure_passes(self):
lock = "## page_charts\n- P02: line_chart\n"
body = ('<path d="M 100 400 L 400 300 L 700 350" stroke="#C00000" fill="none"/>'
'<circle cx="400" cy="300" r="5" fill="#C00000"/>'
'<circle cx="700" cy="350" r="5" fill="#C00000"/>')
r = self._check(body, lock)
self.assertFalse(any("page_charts" in e for e in _alignment_errors(r)))
def test_unassigned_text_page_no_chart_error(self):
lock = "## page_charts\n- P05: bar_chart\n"
body = "".join(_text(66, 200 + 40 * i, f"要点{i}") for i in range(6))
r = self._check(body, lock)
self.assertFalse(any("page_charts" in e for e in _alignment_errors(r)))
def test_content_spills_past_content_bottom_is_error(self):
lock = "## layout_grid\n- content_bottom: 650\n- footer_y: 686\n"
body = _text(66, 676, "被裁掉的描述文字") + _text(66, 686, "页脚")
r = self._check(body, lock)
errs = _alignment_errors(r)
self.assertTrue(any("content_bottom" in e for e in errs), errs)
def test_footer_text_near_footer_y_exempt(self):
lock = "## layout_grid\n- content_bottom: 650\n- footer_y: 686\n"
body = _text(66, 686, "页脚文字") + _text(1150, 690, "12 / 25")
r = self._check(body, lock)
self.assertFalse(any("content_bottom" in e for e in _alignment_errors(r)))
def test_cjk_deep_overlap_is_error(self):
with TemporaryDirectory() as tmp:
page = _write_page(
Path(tmp), "03_content.svg",
'<text x="400" y="500" font-size="22" fill="#C00000">中国陶瓷碳指数之都</text>'
'<text x="405" y="503" font-size="14" fill="#666666">不仅是千年瓷都更是权威发布地</text>')
r = SVGQualityChecker().check_file(str(page))
self.assertTrue(any("Geometry:" in e and "CJK" in e for e in r["errors"]),
r["errors"])
def test_latin_overlap_stays_warning(self):
with TemporaryDirectory() as tmp:
page = _write_page(
Path(tmp), "03_content.svg",
'<text x="400" y="500" font-size="22" fill="#C00000">Total Revenue 2027</text>'
'<text x="405" y="503" font-size="14" fill="#666666">annual growth rate</text>')
r = SVGQualityChecker().check_file(str(page))
self.assertFalse(any("CJK" in e for e in r["errors"]))
class GateVerdictLineTests(unittest.TestCase):
"""gate_verdict_line() 给 core/toolfail 一条稳定诚实的失败签名,
替掉「[exit] 前最后一行永远是通用 tip → 全塌成 Font issues」的误标。"""
def _checker(self, body: str):
with TemporaryDirectory() as tmp:
page = _write_page(Path(tmp), "01_cover.svg", body)
checker = SVGQualityChecker()
checker.check_file(str(page))
return checker
def test_none_when_no_errors(self):
# 干净页面(无 error)不出 gate 行 —— exit 0 本就不进失败面板
c = self._checker(_text(60, 100, "干净标题"))
self.assertEqual(c.summary["errors"], 0)
self.assertIsNone(c.gate_verdict_line())
def test_gate_line_on_error(self):
# foreignObject 是 error;gate 行须以 [GATE FAIL] svg_quality_checker 打头,
# 带文件计数与 top issues —— toolfail 归一后是诚实签名而非 "Font issues"
c = self._checker('<foreignObject x="0" y="0" width="9" height="9"><b/></foreignObject>')
self.assertGreater(c.summary["errors"], 0)
line = c.gate_verdict_line()
self.assertIsNotNone(line)
self.assertTrue(line.startswith("[GATE FAIL] svg_quality_checker:"))
self.assertIn("top issues:", line)
self.assertIn("foreignObject", line)
def test_gate_line_is_last_stdout_line(self):
# 契约:gate 行是 print_summary 之后的最后一行 stdout(toolfail 取尾行)
import contextlib
import io
c = self._checker('<foreignObject x="0" y="0" width="9" height="9"><b/></foreignObject>')
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
c.print_summary()
line = c.gate_verdict_line()
if line:
print(line)
last = [ln for ln in buf.getvalue().splitlines() if ln.strip()][-1]
self.assertTrue(last.startswith("[GATE FAIL] svg_quality_checker:"))
if __name__ == "__main__":
unittest.main()