zcbot/tests/test_svg_alignment_check.py

335 lines
14 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):
pages = {
"01_a.svg": self._content_page(60),
"02_b.svg": self._content_page(63),
"03_c.svg": self._content_page(66),
"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(63),
"03_c.svg": self._content_page(66),
"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"]))
if __name__ == "__main__":
unittest.main()