#!/usr/bin/env python3 """Black-box regression tests for the shared book checker.""" from __future__ import annotations import json import os import shutil import subprocess import tempfile import textwrap import unittest from pathlib import Path INFRA_DIR = Path(__file__).resolve().parents[2] REPOSITORY = INFRA_DIR.parents[1] FIXTURE_BOOK = REPOSITORY / "books" / "workflow-tuts" CHECKER = INFRA_DIR / "scripts" / "check.py" STRUCTURE_FILTER = INFRA_DIR / "scripts" / "book-structure.lua" SLOTS = ( "目标", "边界", "提示词", "流程", "配置", "异常", "审批", "外部系统", "验收", "媒体槽位", ) VALID_PLAN = """\ apiVersion: loop.plan/v1 kind: PlanVersion input: channel: {type: channel, required: true} graph: entry: nodeId: notify port: in input: channel: {ref: plan.input.channel} nodes: - id: notify type: send_message input: channel: {type: channel, required: true} sendMessage: target: "{{ input.channel }}" text: hello completion: sent - id: success type: end end: {outcome: succeeded} edges: - id: completed source: {nodeId: notify, port: completed} target: {nodeId: success, port: in} """ def chapter_with_plan(plan: str = VALID_PLAN) -> str: return ( "# Workflow 指南\n" "\n" "这是可发布的中文正文。\n" "\n" "```yaml\n" f"{plan.rstrip()}\n" "```\n" ) class CheckerTests(unittest.TestCase): def setUp(self) -> None: self._temporary_directory = tempfile.TemporaryDirectory( prefix="shared-book-check-" ) self.addCleanup(self._temporary_directory.cleanup) self.root = Path(self._temporary_directory.name) self.book_dir = self.root / "book" shutil.copytree( FIXTURE_BOOK, self.book_dir, ignore=shutil.ignore_patterns( "build", "dist", "__pycache__", "tests", "templates" ), ) shutil.rmtree(self.book_dir / "manuscript") shutil.rmtree(self.book_dir / "media") (self.book_dir / "manuscript").mkdir() (self.book_dir / "media" / "shared").mkdir(parents=True) self.summary = self.book_dir / "SUMMARY.md" self.chapter = self.book_dir / "manuscript" / "chapter.md" self.terminology = self.book_dir / "TERMINOLOGY.md" self.summary.write_text( "# 目录\n\n- [Workflow 指南](manuscript/chapter.md)\n", encoding="utf-8", ) self.terminology.write_text("# 术语\n", encoding="utf-8") self.chapter.write_text(chapter_with_plan(), encoding="utf-8") self._link_repository_dependencies() def _link_repository_dependencies(self) -> None: """Give copied fixtures only the Go files required by plancheck.""" for filename in ("go.mod", "go.sum"): (self.root / filename).symlink_to(REPOSITORY / filename) (self.root / "internal").symlink_to( REPOSITORY / "internal", target_is_directory=True ) copied_plancheck = self.book_dir / "scripts" / "plancheck" copied_plancheck.parent.mkdir(parents=True, exist_ok=True) if copied_plancheck.exists(): if copied_plancheck.is_dir(): shutil.rmtree(copied_plancheck) else: copied_plancheck.unlink() copied_plancheck.symlink_to( INFRA_DIR / "scripts" / "plancheck", target_is_directory=True ) def run_checker( self, *, env: dict[str, str] | None = None ) -> subprocess.CompletedProcess[str]: return subprocess.run( ["python3", str(CHECKER), "--book-dir", str(self.book_dir)], cwd=self.root, env=env or {**os.environ, "GOWORK": "off"}, check=False, capture_output=True, text=True, timeout=120, ) def assert_checker_rejects(self, message: str) -> subprocess.CompletedProcess[str]: result = self.run_checker() self.assertNotEqual(0, result.returncode, result.stdout + result.stderr) self.assertIn(message, result.stderr) return result def assert_checker_accepts(self) -> subprocess.CompletedProcess[str]: result = self.run_checker() self.assertEqual(0, result.returncode, result.stdout + result.stderr) self.assertEqual("", result.stderr) return result def forbid(self, *phrases: str) -> None: markers = "\n".join( f"" for phrase in phrases ) self.terminology.write_text( f"# 术语\n\n{markers}\n", encoding="utf-8", ) def test_valid_book_is_accepted(self) -> None: result = self.assert_checker_accepts() self.assertIn("Validated 1 manuscript files", result.stdout) def test_part_and_chapter_headings_have_distinct_semantics(self) -> None: source = "# 第一篇 入门\n\n## Alpha\n\n## Beta\n" result = subprocess.run( [ "pandoc", "--from=markdown", "--to=plain", f"--lua-filter={STRUCTURE_FILTER}", ], input=source, check=False, capture_output=True, text=True, ) self.assertEqual(0, result.returncode, result.stdout + result.stderr) headings = [line for line in result.stdout.splitlines() if line] self.assertEqual( ["第一篇 入门", "第 1 章 Alpha", "第 2 章 Beta"], headings, ) def test_case_headings_are_nested_without_consuming_chapter_numbers(self) -> None: source = textwrap.dedent( """\ # 第五篇 企业案例 ## 研发 ## Bug 自动分诊 {.case data-case-id="001"} ### 复用与变体 ## 产品与项目管理 """ ) result = subprocess.run( [ "pandoc", "--from=markdown+link_attributes", "--to=json", f"--lua-filter={STRUCTURE_FILTER}", ], input=source, check=False, capture_output=True, text=True, ) self.assertEqual(0, result.returncode, result.stdout + result.stderr) document = json.loads(result.stdout) headings = [ ( block["c"][0], block["c"][1], "".join( str(inline.get("c", "")) if inline["t"] == "Str" else " " if inline["t"] in ("Space", "SoftBreak", "LineBreak") else "" for inline in block["c"][2] ), ) for block in document["blocks"] if block.get("t") == "Header" ] self.assertEqual( [ (1, ["第五篇-企业案例", [], []], "第五篇 企业案例"), (2, ["研发", [], []], "第 1 章 研发"), ( 3, ["bug-自动分诊", ["case"], [["data-case-id", "001"]]], "案例 001|Bug 自动分诊", ), (4, ["复用与变体", [], []], "复用与变体"), (2, ["产品与项目管理", [], []], "第 2 章 产品与项目管理"), ], headings, ) def test_real_summary_heading_sequence_skips_unnumbered_frontmatter(self) -> None: def inline_text(inlines: list[dict[str, object]]) -> str: text = [] for inline in inlines: if inline["t"] == "Str": text.append(str(inline["c"])) elif inline["t"] == "Code": text.append(str(inline["c"][1])) elif inline["t"] in ("Space", "SoftBreak", "LineBreak"): text.append(" ") return "".join(text) summary_result = subprocess.run( ["python3", str(INFRA_DIR / "scripts" / "summary.py"), "--book-dir", str(FIXTURE_BOOK)], check=True, capture_output=True, text=True, ) manuscript_paths = [ Path(line) for line in summary_result.stdout.splitlines() if line ] expected: list[str] = [] chapter = 0 for path in manuscript_paths: source_result = subprocess.run( ["pandoc", "--from=markdown", "--to=json", str(path)], check=True, capture_output=True, text=True, ) document = json.loads(source_result.stdout) unnumbered_frontmatter = False for block in document["blocks"]: if block.get("t") != "Header" or block["c"][0] not in (1, 2): continue level, attributes, inlines = block["c"] text = inline_text(inlines) if level == 1: unnumbered_frontmatter = "unnumbered" in attributes[1] expected.append(text) elif "case" in attributes[1]: continue elif unnumbered_frontmatter: expected.append(text) else: chapter += 1 expected.append(f"第 {chapter} 章 {text}") rendered_result = subprocess.run( [ "pandoc", "--from=markdown", "--to=json", f"--lua-filter={STRUCTURE_FILTER}", *(str(path) for path in manuscript_paths), ], check=True, capture_output=True, text=True, ) rendered = json.loads(rendered_result.stdout) actual = [] case_ids = [] case_titles = [] case_subheadings = [] for block in rendered["blocks"]: if block.get("t") != "Header": continue level, attributes, inlines = block["c"] text = inline_text(inlines) if level in (1, 2): actual.append(text) if "case" in attributes[1]: case_titles.append(text) case_ids.append(dict(attributes[2]).get("data-case-id", "")) self.assertEqual(3, level) elif level == 4 and text == "复用与变体": case_subheadings.append(text) self.assertGreater(chapter, 0) self.assertEqual(34, chapter) self.assertEqual(expected, actual) self.assertEqual("第一篇 认识 Workflow", actual[6]) self.assertEqual("第 1 章 Workflow 能解决什么问题", actual[7]) self.assertIn("第五篇 企业案例", actual) self.assertIn("第 22 章 研发", actual) self.assertIn("第 31 章 管理与跨部门协作", actual) self.assertIn("第六篇 运营、度量与治理", actual) self.assertIn("第 32 章 把成熟做法沉淀为团队 Workflow", actual) self.assertIn("第 33 章 用证据衡量 Workflow 价值", actual) self.assertIn("第 34 章 建立权限、审计与运营节奏", actual) self.assertIn("附录 A Workflow 上线检查清单", actual) self.assertIn("附录 B Workflow 运营例会议程", actual) self.assertIn("附录 C 故障证据与安全处置卡", actual) self.assertNotIn("第 35 章", actual) self.assertEqual( [f"{number:03d}" for number in range(1, 81)], sorted(case_ids), ) self.assertEqual(80, len(set(case_ids))) self.assertEqual(80, len(case_titles)) self.assertEqual( [f"案例 {case_id}|" for case_id in case_ids], [title[:7] for title in case_titles], ) self.assertTrue(all(not title.startswith("第 ") for title in case_titles)) self.assertEqual(80, len(case_subheadings)) self.assertNotIn("第 102 章", actual) def test_todo_and_chinese_placeholder_are_rejected(self) -> None: for placeholder in ("TODO", "待补充"): with self.subTest(placeholder=placeholder): self.chapter.write_text( f"# Workflow 指南\n\n这里{placeholder}。\n", encoding="utf-8", ) self.assert_checker_rejects("unfinished placeholder in prose") def test_fenced_code_without_language_is_rejected(self) -> None: self.chapter.write_text( "# Workflow 指南\n\n```\necho hello\n```\n", encoding="utf-8", ) self.assert_checker_rejects("fenced code block 1 has no language") def test_heading_level_jump_is_rejected(self) -> None: self.chapter.write_text( "# Workflow 指南\n\n### 跳过二级标题\n", encoding="utf-8", ) self.assert_checker_rejects("heading jumps from H1 to H3") def test_duplicate_heading_is_rejected_case_insensitively(self) -> None: self.chapter.write_text( "# Workflow 指南\n\n## API\n\n内容。\n\n## api\n", encoding="utf-8", ) self.assert_checker_rejects("duplicate heading: api") def test_unsupported_fence_language_is_rejected(self) -> None: self.chapter.write_text( "# Workflow 指南\n\n```python\nprint('hello')\n```\n", encoding="utf-8", ) self.assert_checker_rejects( "fenced code block 1 uses unsupported language: python" ) def test_unsupported_callout_is_rejected(self) -> None: self.chapter.write_text( "# Workflow 指南\n\n> **🚨 警告:** 这不是合法 Callout。\n", encoding="utf-8", ) self.assert_checker_rejects("unsupported callout: 🚨 警告:") def test_five_canonical_callouts_are_accepted(self) -> None: callouts = "\n\n".join( f"> **{label}** 合法。" for label in sorted( ("💡 提示:", "⚠️ 注意:", "❌ 反例:", "✅ 正例:", "🔧 进阶:") ) ) self.chapter.write_text( f"# Workflow 指南\n\n{callouts}\n", encoding="utf-8" ) self.assert_checker_accepts() def test_internal_missing_and_escaping_links_are_rejected(self) -> None: for target, diagnostic in ( ("missing.md", "missing link target: missing.md"), ("../../outside.md", "link leaves book directory: ../../outside.md"), ): with self.subTest(target=target): self.chapter.write_text( f"# Workflow 指南\n\n[链接]({target})\n", encoding="utf-8", ) self.assert_checker_rejects(diagnostic) def test_relative_anchor_and_external_links_are_accepted(self) -> None: (self.book_dir / "README.md").write_text("# 书稿说明\n", encoding="utf-8") self.chapter.write_text( "# Workflow 指南\n\n" "[相对](../README.md) [锚点](#workflow-指南) " "[外链](https://example.com) [邮件](mailto:docs@example.com)\n", encoding="utf-8", ) self.assert_checker_accepts() def test_cjk_ascii_spacing_is_enforced(self) -> None: for prose, diagnostic in ( ("这里使用Workflow 编排。", "missing space at a CJK/ASCII boundary"), ("这里使用 Workflow 编排。", "multiple spaces at a CJK/ASCII boundary"), ): with self.subTest(prose=prose): self.chapter.write_text( f"# Workflow 指南\n\n{prose}\n", encoding="utf-8" ) self.assert_checker_rejects(diagnostic) def test_file_ending_and_trailing_whitespace_are_enforced(self) -> None: for content, diagnostic in ( ("# Workflow 指南 \n", "trailing whitespace"), ("# Workflow 指南", "file must end with one newline"), ("# Workflow 指南\n\n", "file must end with exactly one newline"), ): with self.subTest(diagnostic=diagnostic): self.chapter.write_text(content, encoding="utf-8") self.assert_checker_rejects(diagnostic) def test_missing_media_is_rejected(self) -> None: self.chapter.write_text( "# Workflow 指南\n\n![缺失图片](../media/shared/missing.svg)\n", encoding="utf-8", ) self.assert_checker_rejects("missing image") def test_canonical_slots_require_raw_and_ast_unique_markers(self) -> None: templates = self.book_dir / "templates" templates.mkdir() for name in ("chapter.md", "case.md"): (templates / name).write_text("# 模板\n", encoding="utf-8") markers = "\n".join(f"" for slot in SLOTS) self.chapter.write_text( f"# Workflow 指南\n\n{markers}\n", encoding="utf-8" ) self.assert_checker_accepts() self.chapter.write_text( f"# Workflow 指南\n\n{markers.replace('', '')}\n", encoding="utf-8", ) self.assert_checker_rejects( "expected each canonical slot exactly once in Pandoc AST and raw Markdown" ) def test_invalid_plan_in_template_is_rejected(self) -> None: templates = self.book_dir / "templates" templates.mkdir() (templates / "chapter.md").write_text("# 模板\n", encoding="utf-8") invalid = VALID_PLAN.replace( "{ref: plan.input.channel}", "{ref: plan.input.missing}", 1 ) (templates / "case.md").write_text( chapter_with_plan(invalid), encoding="utf-8" ) result = self.assert_checker_rejects("expression.unknown_reference") self.assertIn("templates/case.md", result.stderr) def test_invalid_yaml_is_rejected(self) -> None: self.chapter.write_text( chapter_with_plan("apiVersion: loop.plan/v1\nkind: ["), encoding="utf-8", ) self.assert_checker_rejects("yaml.invalid") def test_unlisted_manuscript_is_rejected(self) -> None: (self.book_dir / "manuscript" / "orphan.md").write_text( "# 未列章节\n", encoding="utf-8" ) self.assert_checker_rejects("unlisted manuscript: manuscript/orphan.md") def test_invalid_plan_reference_is_rejected(self) -> None: invalid = VALID_PLAN.replace( "{ref: plan.input.channel}", "{ref: plan.input.missing}", 1 ) self.chapter.write_text(chapter_with_plan(invalid), encoding="utf-8") result = self.assert_checker_rejects("expression.unknown_reference") self.assertIn('reference "plan.input.missing" is not declared', result.stderr) def test_invalid_plan_expression_is_rejected(self) -> None: invalid = VALID_PLAN.replace( "text: hello", 'text: "{{ length(input.channel) }}"', 1 ).replace("length(input.channel)", "length(input.channel", 1) self.chapter.write_text(chapter_with_plan(invalid), encoding="utf-8") self.assert_checker_rejects("expression.invalid") def test_missing_go_reports_actionable_error_without_traceback(self) -> None: fake_bin = self.root / "no-go-bin" fake_bin.mkdir() for command in ("pandoc", "python3"): resolved = shutil.which(command) self.assertIsNotNone(resolved) (fake_bin / command).symlink_to(resolved) result = self.run_checker( env={ **os.environ, "GOWORK": "off", "PATH": str(fake_bin), } ) self.assertNotEqual(0, result.returncode, result.stdout + result.stderr) self.assertIn("Go 1.25+ is required", result.stderr) self.assertNotIn("Traceback", result.stderr) def test_checker_forbid_phrase_as_substring_in_prose_is_rejected(self) -> None: self.forbid("timed_out", "waiting_approval") self.chapter.write_text( "# Workflow 指南\n\n不得把 timed_out_state 当成公开状态名。\n", encoding="utf-8", ) self.assert_checker_rejects( "forbidden terminology phrase: timed_out" ) def test_checker_forbid_adjacent_nonmatching_prose_is_accepted(self) -> None: self.forbid("timed_out", "waiting_approval") self.chapter.write_text( "# Workflow 指南\n\n这里分别说明 waiting 和 approval 两个概念。\n", encoding="utf-8", ) self.assert_checker_accepts() def test_checker_forbid_ignores_code_and_html_comments(self) -> None: self.forbid("timed_out", "waiting_approval") self.chapter.write_text( textwrap.dedent( """\ # Workflow 指南 正文使用推荐术语。 ```text waiting_approval ``` 行内示例是 `timed_out`。 """ ), encoding="utf-8", ) self.assert_checker_accepts() def test_checker_forbid_malformed_marker_is_rejected(self) -> None: malformed_markers = ( ( "", "malformed checker-forbid marker", ), ( "", "malformed checker-forbid marker", ), ( "前缀 ", "malformed checker-forbid marker", ), ("", "checker-forbid phrase is empty"), ) for marker, expected in malformed_markers: with self.subTest(marker=marker): self.terminology.write_text( f"# 术语\n\n{marker}\n", encoding="utf-8" ) self.assert_checker_rejects(f"TERMINOLOGY.md:3: {expected}") def test_checker_forbid_duplicate_marker_is_rejected(self) -> None: self.terminology.write_text( "# 术语\n\n" "\n" "\n" "\n", encoding="utf-8", ) self.assert_checker_rejects( "TERMINOLOGY.md:5: duplicate checker-forbid phrase: waiting_approval" ) class PandocThemeTests(unittest.TestCase): def test_skylighting_blocks_are_wrapped_as_full_width_cards(self) -> None: source = "```yaml\nkey: value\n```\n" result = subprocess.run( [ "pandoc", "--from=markdown+yaml_metadata_block+fenced_divs+link_attributes", "--to=typst", "--standalone", "--syntax-highlighting=tango", f"--template={INFRA_DIR / 'theme' / 'pandoc.typst'}", "-V", "template=book.typ", ], input=source, cwd=INFRA_DIR / "theme", check=False, capture_output=True, text=True, ) self.assertEqual(0, result.returncode, result.stdout + result.stderr) self.assertIn("#let PandocSkylighting = Skylighting", result.stdout) self.assertIn("width: 100%", result.stdout) self.assertIn("inset:", result.stdout) self.assertIn("breakable: true", result.stdout) if __name__ == "__main__": unittest.main()