import io import json import sqlite3 import subprocess import tempfile import unittest from contextlib import redirect_stderr from pathlib import Path from unittest import mock from scripts import check_public_text from scripts.check_public_text import Library, insider_terms, problems, publish_text def ledger(path: Path) -> Path: """只有判据读到的那几列;内容全是虚构的。""" connection = sqlite3.connect(path) connection.executescript(""" CREATE TABLE asset(code TEXT, catalog_title TEXT, original_title TEXT, name TEXT); CREATE TABLE feed_item(code TEXT); CREATE TABLE entity(kind TEXT, canonical_name TEXT); CREATE TABLE follow_source(label TEXT); INSERT INTO asset VALUES ('ZZQ-101', '架空作品のための長い題名です', '', 'ZZQ-101.mp4'), ('zzq102', '', '', ''), ('ZZQ-103', '', '', ''), ('', '', '', ''); INSERT INTO feed_item VALUES ('YYW-007'); INSERT INTO entity VALUES ('performer', '虚构女优甲'), ('performer', '虚构女优乙'), ('tag', '虚构标签名'), ('creator', 'Fictional Maker'); INSERT INTO follow_source VALUES ('fictional_account'), (NULL); """) connection.commit() connection.close() return path class LibraryTests(unittest.TestCase): def setUp(self): directory = tempfile.TemporaryDirectory() self.addCleanup(directory.cleanup) self.library = Library.load(ledger(Path(directory.name) / "ledger.db")) def test_the_ledger_is_read_into_codes_names_and_title_fragments(self): self.assertEqual(self.library.codes, {"ZZQ-101", "ZZQ-102", "ZZQ-103", "YYW-007"}) self.assertEqual(self.library.names, {"虚构女优甲", "虚构女优乙", "Fictional Maker", "fictional_account"}) self.assertIn("架空作品のため", self.library.grams) def test_a_list_of_owned_codes_names_or_titles_is_a_problem(self): cases = {"ZZQ-101、zzq-102、ZZQ103 都要人判": "番号 3 个", "虚构女优甲 对 虚构女优乙": "人物 2 个", "系列名:架空作品のための長い題名 对 題名です長い架空作品のため": "标题片段 2 个"} for text, expected in cases.items(): with self.subTest(text=text): self.assertTrue(problems(text, self.library)[0].startswith(expected)) def test_the_example_a_fix_needs_and_unrelated_text_pass(self): for text in ("只修 ZZQ-101 的前缀,YYW-007 是反例。", "虚构女优甲 的别名修好了。", "ABC-123、DEF-456、GHI-789 都不在账本里。", "Fictional 与 Maker 分开写不算。"): with self.subTest(text=text): self.assertEqual(problems(text, self.library), []) def test_a_stated_reason_or_a_machine_without_a_ledger_passes(self): listed = "ZZQ-101、ZZQ-102、ZZQ-103" self.assertEqual(problems(listed + "\n", self.library), []) self.assertNotEqual(problems(listed + "\npeach-public-text:", self.library), []) self.assertEqual(problems(listed, None), []) class InsiderTermsTests(unittest.TestCase): """PR、issue 与评论写给外部读者:派单的编号、批次与本机交付流程的说法他们看不懂。""" def test_feedback_codes_batches_and_delivery_steps_are_named_by_kind(self): found = insider_terms("修复反馈 N01,本轮 R0–R2;全库排查第 3 批,合批后第一轮 auto 在集成工作树里跑," "ready:ok;门槛已改。") self.assertEqual([line.split(":", 1)[0] for line in found], ["反馈条目编号", "批次与轮次", "本机交付流程", "自造用语"]) self.assertIn("N01", found[0]) self.assertIn("合批", found[1]) def test_code_spans_ordinary_words_and_a_stated_reason_pass(self): for text in ("Windows 11:`& .\\scripts\\test.ps1`(按改动自动选范围)通过。", "命令是 `agent_worktree.py ready`,H264 与 N1 芯片不受影响。", "分批派发补全任务;这批文件已核对。", "第一轮反馈\npeach-public-text: 讨论的就是批次命名本身"): with self.subTest(text=text): self.assertEqual(insider_terms(text), []) class PublishCommandTests(unittest.TestCase): def setUp(self): directory = tempfile.TemporaryDirectory() self.addCleanup(directory.cleanup) self.cwd = Path(directory.name) (self.cwd / "body.md").write_text("正文文件里的内容", encoding="utf-8") def test_gh_commands_that_publish_carry_their_text_and_body_files(self): for command in ("gh pr create --title t --body-file body.md", "gh pr edit 3 -F body.md", "cd x && gh issue comment 5 --body-file=body.md", f"gh api -X PATCH repos/o/r/issues/comments/9 -F body=@{self.cwd / 'body.md'}"): with self.subTest(command=command): text = publish_text(command, self.cwd) self.assertIn(command, text) self.assertIn("正文文件里的内容", text) heredoc = "gh pr create --body \"$(cat <<'EOF'\n正文直接写在命令里\nEOF\n)\"" self.assertIn("正文直接写在命令里", publish_text(heredoc, self.cwd)) def test_commands_that_only_read_are_not_publishing(self): for command in ("gh pr view 3 --json body", "gh pr list", "gh api repos/o/r/pulls/3", "echo hello"): with self.subTest(command=command): self.assertIsNone(publish_text(command, self.cwd)) def test_the_hook_blocks_with_a_reason_and_lets_everything_else_through(self): library = Library(codes={"ZZQ-101", "ZZQ-102", "ZZQ-103"}) listed = {"cwd": str(self.cwd), "tool_input": {"command": "gh pr create --body 'ZZQ-101 ZZQ-102 ZZQ-103'"}} with mock.patch.object(check_public_text, "load_library", return_value=library) as load: error = io.StringIO() with redirect_stderr(error): self.assertEqual(check_public_text.hook(listed), 2) self.assertIn("番号 3 个", error.getvalue()) self.assertIn("peach-public-text", error.getvalue()) self.assertEqual(check_public_text.hook({"tool_input": {"command": "gh pr create --body 'ZZQ-101'"}}), 0) load.reset_mock() self.assertEqual(check_public_text.hook({"tool_input": {"command": "git status"}}), 0) load.assert_not_called() def test_a_delivery_script_chained_after_the_publish_command_is_not_its_text(self): command = ("gh pr create --title t --body-file body.md && " "python scripts/agent_worktree.py integrate --branch agent/claude/x") with mock.patch.object(check_public_text, "load_library", return_value=None): self.assertEqual(check_public_text.hook({"cwd": str(self.cwd), "tool_input": {"command": command}}), 0) def test_the_hook_blocks_insider_terms_even_without_a_ledger(self): (self.cwd / "notes.md").write_text("合批验证,见批次 0570。", encoding="utf-8") for command in ("gh pr edit 3 --body '修复 N01'", "gh release edit v1.0.0 --notes-file notes.md"): with self.subTest(command=command), mock.patch.object(check_public_text, "load_library", return_value=None): error = io.StringIO() with redirect_stderr(error): self.assertEqual(check_public_text.hook({"cwd": str(self.cwd), "tool_input": {"command": command}}), 2) self.assertIn("内部用语", error.getvalue()) self.assertIn("peach-public-text", error.getvalue()) def test_the_hook_reads_a_missing_cwd_as_this_checkout(self): payload = {"cwd": "/no/such/place", "tool_input": {"command": "gh pr create --body x"}} with mock.patch.object(check_public_text, "load_library", return_value=None) as load: self.assertEqual(check_public_text.hook(payload), 0) load.assert_called_once_with(check_public_text.ROOT) def test_the_configured_claude_hook_runs_as_written(self): """配置里的命令原样跑一遍:PATH 上没有 uv 就是测试红,不是钩子静默;`--no-project` 不建 venv。""" settings = json.loads((check_public_text.ROOT / ".claude" / "settings.json").read_text(encoding="utf-8")) [matcher] = settings["hooks"]["PreToolUse"] self.assertEqual(matcher["matcher"], "Bash|PowerShell") [hook] = matcher["hooks"] argv = [hook["command"], *(arg.replace("${CLAUDE_PROJECT_DIR}", str(check_public_text.ROOT)) for arg in hook["args"])] venv = (self.cwd / ".venv").exists() result = subprocess.run(argv, input=json.dumps({"cwd": str(self.cwd), "tool_input": {"command": "git status"}}), capture_output=True, text=True, encoding="utf-8", cwd=self.cwd, timeout=120) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn(result.stderr, ("", "WARN `--no-project` was provided, but no project was found\n")) self.assertEqual((self.cwd / ".venv").exists(), venv) def test_unreadable_hook_input_never_blocks(self): with mock.patch("sys.stdin", io.StringIO("not json")): self.assertEqual(check_public_text.main(["--hook-event"]), 0) with mock.patch("sys.stdin", io.StringIO(json.dumps([1]))): self.assertEqual(check_public_text.main(["--hook-event"]), 0) if __name__ == "__main__": unittest.main()