335 lines
14 KiB
Python
335 lines
14 KiB
Python
"""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()
|