"""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 ('' + body + '') def _card(x, y, w=200, h=120): return (f'') def _icon(x, y, name="home"): return (f'') def _text(x, y, s="标签"): return f'{s}' 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 = ('' + _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'' + _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 = ('' '' '') 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 = ('' '' '') 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", '中国陶瓷碳指数之都' '不仅是千年瓷都更是权威发布地') 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", 'Total Revenue 2027' 'annual growth rate') 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('') 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('') 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()