import importlib.util import json import os import shutil import subprocess import sys import tempfile import unittest from pathlib import Path from unittest import mock try: import yaml except ImportError: yaml = None SCRIPT = Path(__file__).parents[1] / "scripts" / "generate.py" REPO_ROOT = Path(__file__).resolve().parents[3] def load_script(): spec = importlib.util.spec_from_file_location("tsl_codegen_generate", SCRIPT) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module class DocGenCliTest(unittest.TestCase): def setUp(self): self.temp_dir = tempfile.TemporaryDirectory() self.root = Path(self.temp_dir.name) self.input = self.root / "entry.json" self.input.write_text( json.dumps( { "module": "项目 / 示例", "path": "base/my_functions", "declarations": [ { "kind": "function", "name": "demo", "signature": "demo()", "desc": "示例函数。", "returns": "nil", } ], }, ensure_ascii=False, ), encoding="utf-8", ) def tearDown(self): self.temp_dir.cleanup() def run_cli(self, *args, env=None): return subprocess.run( [sys.executable, str(SCRIPT), "--file", str(self.input), *args], capture_output=True, text=True, encoding="utf-8", cwd=self.root, env=env, ) def run_raw_cli(self, *args, env=None): return subprocess.run( [sys.executable, str(SCRIPT), *map(str, args)], capture_output=True, text=True, encoding="utf-8", cwd=self.root, env=env, ) def write_input(self, data): self.input.write_text( json.dumps(data, ensure_ascii=False), encoding="utf-8", ) def write_recording(self, path, relative, module="项目 / 批量"): path.parent.mkdir(parents=True, exist_ok=True) path.write_text( json.dumps( { "module": module, "path": relative, "declarations": [ { "kind": "function", "name": "demo", "signature": "demo()", "desc": "示例函数。", "returns": "nil", } ], }, ensure_ascii=False, ), encoding="utf-8", ) return path def generated(self, relative, scope="project"): return ( self.root / "skills" / "tsl-api-reference" / "references" / "codegen" / scope / f"{relative}.md" ) def write_class_member_input(self, member, relative): self.write_input( { "module": "示例 / 类", "path": relative, "declarations": [ { "kind": "class", "name": "StrictClass", "desc": "严格类。", "members": [member], } ], } ) def assert_rejected_without_overwrite(self, relative, expected): output = self.generated(relative) output.parent.mkdir(parents=True, exist_ok=True) output.write_text("原内容\n", encoding="utf-8") result = self.run_cli() self.assertEqual(1, result.returncode) self.assertIn(expected, result.stderr) self.assertEqual("原内容\n", output.read_text(encoding="utf-8")) def test_default_scope_writes_configured_path_under_project(self): result = self.run_cli() output = ( self.root / "skills" / "tsl-api-reference" / "references" / "codegen" / "project" / "base" / "my_functions.md" ) self.assertEqual(result.returncode, 0, result.stderr) self.assertTrue(output.is_file()) self.assertTrue( output.read_text(encoding="utf-8").startswith("# 项目 / 示例\n") ) def test_file_option_generates_one_recording(self): result = self.run_raw_cli("--file", self.input) self.assertEqual(0, result.returncode, result.stderr) self.assertTrue(self.generated("base/my_functions").is_file()) def test_legacy_positional_input_remains_supported(self): result = self.run_raw_cli(self.input) self.assertEqual(0, result.returncode, result.stderr) self.assertTrue(self.generated("base/my_functions").is_file()) def test_help_marks_legacy_input_as_deprecated(self): result = self.run_raw_cli("--help") self.assertEqual(0, result.returncode, result.stderr) self.assertIn("--file INPUT_FILE", result.stdout) self.assertIn("--dir INPUT_DIR", result.stdout) self.assertIn("已废弃,请使用 --file", result.stdout) def test_invalid_format_error_is_fully_chinese(self): result = self.run_raw_cli("--file", self.input, "--format", "xml") self.assertEqual(2, result.returncode) self.assertIn( "参数 --format: 取值无效:'xml'(可选值:'json', 'yaml')", result.stderr, ) self.assertNotIn("choose from", result.stderr) def test_exactly_one_input_mode_is_required(self): cases = ( (), ("--file", self.input, "--dir", self.root), (self.input, "--file", self.input), ) for args in cases: with self.subTest(args=args): result = self.run_raw_cli(*args) self.assertEqual(2, result.returncode) self.assertIn( "必须且只能指定一种输入方式:INPUT_FILE、--file 或 --dir", result.stderr, ) def test_dir_processes_only_direct_json_files_in_name_order(self): input_dir = self.root / "recordings" second = self.write_recording(input_dir / "b.json", "base/b") first = self.write_recording(input_dir / "a.json", "base/a") self.write_recording(input_dir / "nested" / "c.json", "base/c") (input_dir / "notes.txt").write_text("ignore", encoding="utf-8") result = self.run_raw_cli("--dir", input_dir, "--format", "json") self.assertEqual(0, result.returncode, result.stderr) self.assertTrue(self.generated("base/a").is_file()) self.assertTrue(self.generated("base/b").is_file()) self.assertFalse(self.generated("base/c").exists()) self.assertLess( result.stderr.index(str(first)), result.stderr.index(str(second)) ) def test_dir_format_filters_out_other_supported_extensions(self): input_dir = self.root / "recordings" self.write_recording(input_dir / "only.json", "base/only") (input_dir / "ignored.yaml").write_text(": invalid", encoding="utf-8") result = self.run_raw_cli("--dir", input_dir, "--format", "json") self.assertEqual(0, result.returncode, result.stderr) self.assertTrue(self.generated("base/only").is_file()) self.assertNotIn("ignored.yaml", result.stderr) @unittest.skipUnless(yaml is not None, "pyyaml is not installed") def test_dir_without_format_processes_json_yaml_and_yml(self): input_dir = self.root / "recordings" self.write_recording(input_dir / "first.json", "base/first") for name, relative in ( ("second.yaml", "base/second"), ("third.yml", "base/third"), ): data = { "module": "项目 / 批量", "path": relative, "declarations": [ { "kind": "function", "name": "demo", "signature": "demo()", "desc": "示例函数。", "returns": "nil", } ], } (input_dir / name).write_text( yaml.safe_dump(data, allow_unicode=True, sort_keys=False), encoding="utf-8", ) result = self.run_raw_cli("--dir", input_dir) self.assertEqual(0, result.returncode, result.stderr) for relative in ("base/first", "base/second", "base/third"): self.assertTrue(self.generated(relative).is_file()) def test_dir_without_matching_files_reports_directory_and_extensions(self): input_dir = self.root / "recordings" input_dir.mkdir() (input_dir / "notes.txt").write_text("ignore", encoding="utf-8") self.write_recording(input_dir / "nested" / "hidden.json", "base/hidden") result = self.run_raw_cli("--dir", input_dir) self.assertEqual(1, result.returncode) self.assertIn(str(input_dir), result.stderr) self.assertIn("未找到符合条件的直属录入文件", result.stderr) self.assertIn(".json、.yaml、.yml", result.stderr) def test_dir_scan_error_reports_directory_and_specific_reason(self): module = load_script() input_dir = self.root / "recordings" input_dir.mkdir() with mock.patch.object( module.Path, "iterdir", side_effect=PermissionError("拒绝访问") ): with self.assertRaises(module.GenerationError) as caught: module.gather_directory_inputs(input_dir, None) message = str(caught.exception) self.assertIn(f"读取输入目录失败:{input_dir}", message) self.assertIn("拒绝访问", message) def test_dir_collects_all_file_errors_before_writing_any_markdown(self): input_dir = self.root / "recordings" self.write_recording(input_dir / "a-valid.json", "base/valid") (input_dir / "b-invalid-json.json").write_text("{", encoding="utf-8") (input_dir / "c-invalid-data.json").write_text( json.dumps( { "module": "项目 / 错误", "path": "base/invalid_data", "declarations": [], }, ensure_ascii=False, ), encoding="utf-8", ) existing = self.generated("base/valid") existing.parent.mkdir(parents=True, exist_ok=True) existing.write_text("原内容\n", encoding="utf-8") result = self.run_raw_cli("--dir", input_dir, "--format", "json") self.assertEqual(1, result.returncode) self.assertIn(str(input_dir / "b-invalid-json.json"), result.stderr) self.assertIn("JSON 格式错误", result.stderr) self.assertIn(str(input_dir / "c-invalid-data.json"), result.stderr) self.assertIn("录入数据校验失败", result.stderr) self.assertIn("declarations 必须是非空列表", result.stderr) self.assertIn("批量生成已中止", result.stderr) self.assertIn("未写入任何 Markdown 文件", result.stderr) self.assertEqual("原内容\n", existing.read_text(encoding="utf-8")) def test_dir_rejects_output_collisions_before_writing(self): input_dir = self.root / "recordings" first = self.write_recording(input_dir / "a.json", "base/collision") second = self.write_recording(input_dir / "b.json", "base/collision") output = self.generated("base/collision") result = self.run_raw_cli("--dir", input_dir, "--format", "json") self.assertEqual(1, result.returncode) self.assertIn("输出目标冲突", result.stderr) self.assertIn(str(first), result.stderr) self.assertIn(str(second), result.stderr) self.assertIn(str(output), result.stderr) self.assertIn("未写入任何 Markdown 文件", result.stderr) self.assertFalse(output.exists()) def test_single_file_json_error_reports_file_and_position_in_chinese(self): self.input.write_text("{", encoding="utf-8") result = self.run_raw_cli("--file", self.input) self.assertEqual(1, result.returncode) self.assertIn(f"错误:{self.input}:JSON 格式错误", result.stderr) self.assertIn("第 1 行", result.stderr) self.assertIn("第 2 列", result.stderr) def test_windows_path_separator_writes_nested_markdown(self): data = json.loads(self.input.read_text(encoding="utf-8")) data["path"] = r"base\windows_example" self.write_input(data) result = self.run_raw_cli("--file", self.input) self.assertEqual(0, result.returncode, result.stderr) self.assertTrue(self.generated("base/windows_example").is_file()) def test_invalid_paths_report_input_file_and_specific_reason(self): cases = ( (r"C:\base\example", "不允许 Windows 盘符路径"), (r"\\server\share\example", "不允许 UNC 路径"), (r"\base\example", "不能以斜杠或反斜杠开头"), (r"base\..\example", "不允许跳转到父目录"), (r"base\example.md", "不能包含 .md 等文件扩展名"), (".", "必须指向具体文档"), ) for path, reason in cases: with self.subTest(path=path): data = json.loads(self.input.read_text(encoding="utf-8")) data["path"] = path self.write_input(data) result = self.run_raw_cli("--file", self.input) self.assertEqual(1, result.returncode, result.stderr) self.assertIn( f"错误:{self.input}:输出路径无效:", result.stderr, ) self.assertIn(reason, result.stderr) self.assertNotIn("Traceback", result.stderr) def test_custom_scope_changes_first_destination_directory(self): result = self.run_cli("--scope", "my-project") output = ( self.root / "skills" / "tsl-api-reference" / "references" / "codegen" / "my-project" / "base" / "my_functions.md" ) self.assertEqual(result.returncode, 0, result.stderr) self.assertTrue(output.is_file()) def test_output_option_is_rejected(self): result = self.run_cli("--output", str(self.root / "out.md")) self.assertNotEqual(result.returncode, 0) self.assertIn("无法识别的参数: --output", result.stderr) def test_generated_markdown_is_formatted_by_repo_prettier(self): self.input.write_text( json.dumps( { "module": "项目 / 示例", "path": "base/formatted", "declarations": [ { "kind": "function", "name": "demo", "signature": "demo(short, long_name)", "desc": "示例函数。", "params": [ { "name": "short", "type": "integer", "desc": "短说明", }, { "name": "long_name", "type": "very_long_type", "desc": "这是较长的说明", }, ], "returns": "integer", } ], }, ensure_ascii=False, ), encoding="utf-8", ) output = ( self.root / "skills" / "tsl-api-reference" / "references" / "codegen" / "project" / "base" / "formatted.md" ) result = self.run_cli() self.assertEqual(0, result.returncode, result.stderr) generated = output.read_text(encoding="utf-8") prettier = subprocess.run( [ shutil.which("npx"), "--no-install", "prettier", "--config", str(REPO_ROOT / ".prettierrc.json"), "--parser", "markdown", ], input=generated, capture_output=True, text=True, encoding="utf-8", cwd=REPO_ROOT, ) self.assertEqual(0, prettier.returncode, prettier.stderr) self.assertEqual(prettier.stdout, generated) def test_missing_prettier_fails_without_writing_markdown(self): output = ( self.root / "skills" / "tsl-api-reference" / "references" / "codegen" / "project" / "base" / "my_functions.md" ) output.parent.mkdir(parents=True, exist_ok=True) output.write_text("原内容\n", encoding="utf-8") env = os.environ.copy() env["PATH"] = "" result = self.run_cli(env=env) self.assertEqual(1, result.returncode) self.assertIn("未找到 Prettier", result.stderr) self.assertEqual("原内容\n", output.read_text(encoding="utf-8")) def test_prettier_process_failure_preserves_existing_markdown(self): output = self.generated("base/my_functions") output.parent.mkdir(parents=True, exist_ok=True) output.write_text("原内容\n", encoding="utf-8") bin_dir = self.root / "bin" bin_dir.mkdir() fake_npx = bin_dir / "npx" fake_npx.write_text( "#!/bin/sh\necho formatter-failed >&2\nexit 9\n", encoding="utf-8", ) fake_npx.chmod(0o755) env = os.environ.copy() env["PATH"] = str(bin_dir) result = self.run_cli(env=env) self.assertEqual(1, result.returncode) self.assertIn("Prettier 格式化失败", result.stderr) self.assertIn("formatter-failed", result.stderr) self.assertEqual("原内容\n", output.read_text(encoding="utf-8")) def test_mixed_declarations_render_typed_h2_in_input_order(self): self.write_input( { "module": "示例 / 混合", "path": "base/mixed", "declarations": [ { "kind": "class", "name": "Widget", "desc": "组件。", "members": [], }, { "kind": "function", "name": "OpenWidget", "signature": "OpenWidget()", "desc": "打开组件。", "returns": "Widget", }, { "kind": "unit", "name": "WidgetRuntime", "desc": "运行时接口。", "members": [], }, ], } ) result = self.run_cli() self.assertEqual(0, result.returncode, result.stderr) text = self.generated("base/mixed").read_text(encoding="utf-8") markers = [ "## `Widget`\n\n声明:class\n\n组件。", "## `OpenWidget()`\n\n声明:function\n\n打开组件。", "## `WidgetRuntime`\n\n声明:unit\n\n运行时接口。", ] positions = [text.index(marker) for marker in markers] self.assertEqual(sorted(positions), positions) def test_class_page_renders_binding_visibility_and_member_order(self): self.write_input( { "module": "示例 / 类", "path": "base/widget", "declarations": [ { "kind": "class", "name": "Widget", "desc": "表示组件。", "tags": ["组件", "示例"], "bases": ["BaseWidget"], "members": [ { "kind": "method", "name": "Close", "visibility": "public", "binding": "instance", "signature": "Close()", "desc": "关闭组件。", }, { "kind": "method", "name": "Create", "visibility": "protected", "binding": "class", "signature": "Create(name)", "desc": "创建组件。", "params": [ { "name": "name", "type": "string", "desc": "组件名称", } ], "returns": "Widget", "modifiers": ["overload"], }, { "kind": "property", "name": "Title", "visibility": "public", "desc": "组件标题。", "type": "string", "access": "readwrite", }, { "kind": "field", "name": "Count", "visibility": "protected", "desc": "组件数量。", "type": "integer", "static": True, }, { "kind": "constant", "name": "DefaultName", "visibility": "public", "desc": "默认名称。", "value": "'widget'", }, ], } ], } ) result = self.run_cli() self.assertEqual(0, result.returncode, result.stderr) text = self.generated("base/widget").read_text(encoding="utf-8") expected = [ "## `Widget`", "声明:class", "父类:`BaseWidget`", "### `Close()`\n\n声明:function", "### `Create(name)`\n\n声明:class function", "### `Title`\n\n声明:property", "### `Count`\n\n声明:static field", "### `DefaultName`\n\n声明:const", ] positions = [text.index(fragment) for fragment in expected] self.assertEqual(sorted(positions), positions) self.assertIn("可见性:`protected`", text) self.assertIn("修饰符:`overload`", text) self.assertIn("访问:read / write", text) self.assertNotIn("static function", text) self.assertEqual( "# 示例 / 类\n\n" "## `Widget`\n\n" "声明:class\n\n" "表示组件。\n\n" "\n\n" "父类:`BaseWidget`\n\n" "### `Close()`\n\n" "声明:function\n\n" "关闭组件。\n\n" "可见性:`public`\n\n" "### `Create(name)`\n\n" "声明:class function\n\n" "创建组件。\n\n" "可见性:`protected`\n\n" "修饰符:`overload`\n\n" "| 参数 | 类型 | 说明 |\n" "| ------ | ------ | -------- |\n" "| `name` | string | 组件名称 |\n\n" "返回:Widget\n\n" "### `Title`\n\n" "声明:property\n\n" "组件标题。\n\n" "可见性:`public`\n\n" "类型:string\n\n" "访问:read / write\n\n" "### `Count`\n\n" "声明:static field\n\n" "组件数量。\n\n" "可见性:`protected`\n\n" "类型:integer\n\n" "### `DefaultName`\n\n" "声明:const\n\n" "默认名称。\n\n" "可见性:`public`\n\n" "值:`'widget'`\n", text, ) def test_unit_page_renders_interface_class_members_at_h4(self): self.write_input( { "module": "示例 / Unit", "path": "base/demo_unit", "declarations": [ { "kind": "unit", "name": "DemoUnit", "desc": "提供文档能力。", "members": [ { "kind": "constant", "name": "DefaultSize", "desc": "默认大小。", "value": 100, }, { "kind": "variable", "name": "CurrentDocument", "desc": "当前文档。", "type": "Document", }, { "kind": "function", "name": "OpenDocument", "signature": "OpenDocument(path)", "desc": "打开文档。", "params": [ { "name": "path", "type": "string", "desc": "文档路径", } ], "returns": "Document", }, { "kind": "class", "name": "Document", "desc": "文档对象。", "bases": ["BaseDocument"], "members": [ { "kind": "method", "name": "Save", "visibility": "public", "binding": "instance", "signature": "Save()", "desc": "保存文档。", "returns": "boolean", } ], }, ], } ], } ) result = self.run_cli() self.assertEqual(0, result.returncode, result.stderr) text = self.generated("base/demo_unit").read_text(encoding="utf-8") self.assertIn("## `DemoUnit`", text) self.assertIn("声明:unit", text) self.assertIn("### `DefaultSize`\n\n声明:const", text) self.assertIn("值:`100`", text) self.assertIn("### `CurrentDocument`\n\n声明:var", text) self.assertIn("### `OpenDocument(path)`\n\n声明:function", text) self.assertIn("### `Document`\n\n声明:class", text) self.assertIn("父类:`BaseDocument`", text) self.assertIn("#### `Save()`\n\n声明:function", text) self.assertNotIn("可见性:`public`\n\n### `Document`", text) self.assertEqual( "# 示例 / Unit\n\n" "## `DemoUnit`\n\n" "声明:unit\n\n" "提供文档能力。\n\n" "### `DefaultSize`\n\n" "声明:const\n\n" "默认大小。\n\n" "值:`100`\n\n" "### `CurrentDocument`\n\n" "声明:var\n\n" "当前文档。\n\n" "类型:Document\n\n" "### `OpenDocument(path)`\n\n" "声明:function\n\n" "打开文档。\n\n" "| 参数 | 类型 | 说明 |\n" "| ------ | ------ | -------- |\n" "| `path` | string | 文档路径 |\n\n" "返回:Document\n\n" "### `Document`\n\n" "声明:class\n\n" "文档对象。\n\n" "父类:`BaseDocument`\n\n" "#### `Save()`\n\n" "声明:function\n\n" "保存文档。\n\n" "可见性:`public`\n\n" "返回:boolean\n", text, ) def test_class_method_empty_returns_is_treated_as_omitted(self): self.write_input( { "module": "示例 / 类", "path": "base/no_return", "declarations": [ { "kind": "class", "name": "NoReturn", "desc": "无返回类。", "members": [ { "kind": "method", "name": "Close", "visibility": "public", "binding": "instance", "signature": "Close()", "desc": "关闭。", "returns": "", } ], } ], } ) class_result = self.run_cli() self.assertEqual(0, class_result.returncode, class_result.stderr) class_text = self.generated("base/no_return").read_text(encoding="utf-8") self.assertNotIn("返回:", class_text) def test_unit_function_requires_returns(self): self.write_input( { "module": "示例 / Unit", "path": "base/missing_return", "declarations": [ { "kind": "unit", "name": "MissingReturn", "desc": "缺少返回。", "members": [ { "kind": "function", "name": "Open", "signature": "Open()", "desc": "打开。", } ], } ], } ) result = self.run_cli() self.assertEqual(1, result.returncode) self.assertIn("缺少 returns", result.stderr) def test_examples_list_renders_independent_fences_and_output_comments(self): self.write_input( { "module": "项目 / 示例", "path": "base/examples", "declarations": [ { "kind": "function", "name": "demo", "signature": "demo()", "desc": "示例函数。", "returns": "string", "examples": [ { "desc": "单行输出", "code": "return demo();", "output": "ok", }, { "desc": "多行输出", "code": "return demo();", "output": "first\nsecond", }, ], } ], } ) result = self.run_cli() self.assertEqual(0, result.returncode, result.stderr) text = self.generated("base/examples").read_text(encoding="utf-8") self.assertEqual(2, text.count("```tsl")) self.assertIn("范例01:单行输出", text) self.assertIn("// 输出:ok", text) self.assertIn("范例02:多行输出", text) self.assertIn("// 输出:\n// first\n// second", text) def test_atomic_write_failure_preserves_existing_output_and_cleans_temp(self): module = load_script() output = self.root / "existing.md" output.write_text("原内容\n", encoding="utf-8") with mock.patch.object( module.os, "replace", side_effect=OSError("replace failed") ): with self.assertRaisesRegex(OSError, "replace failed"): module.atomic_write(output, "新内容\n") self.assertEqual("原内容\n", output.read_text(encoding="utf-8")) self.assertFalse(any(path.suffix == ".tmp" for path in self.root.iterdir())) def test_legacy_root_keys_are_rejected_without_overwrite(self): legacy_values = { "functions": [{"signature": "Old()", "desc": "旧函数。", "returns": "nil"}], "class": {"name": "Old", "desc": "旧类。", "members": []}, "unit": {"name": "Old", "desc": "旧接口。", "members": []}, } for key, value in legacy_values.items(): with self.subTest(key=key): relative = f"base/legacy_{key}" self.write_input( { "module": "示例 / 旧录入", "path": relative, key: value, } ) self.assert_rejected_without_overwrite(relative, f"存在未知字段:{key}") def test_unknown_class_member_kind_is_rejected_without_overwrite(self): self.write_class_member_input( { "kind": "event", "name": "Changed", "visibility": "public", "desc": "发生变化。", }, "base/unknown_kind", ) self.assert_rejected_without_overwrite("base/unknown_kind", "未知 kind:event") def test_method_static_field_is_rejected_without_overwrite(self): self.write_class_member_input( { "kind": "method", "name": "Bad", "visibility": "public", "binding": "instance", "signature": "Bad()", "desc": "非法方法。", "static": True, }, "base/method_static", ) self.assert_rejected_without_overwrite( "base/method_static", "存在未知字段:static" ) def test_private_class_member_is_rejected_without_overwrite(self): self.write_class_member_input( { "kind": "field", "name": "hidden_", "visibility": "private", "desc": "私有字段。", "type": "string", }, "base/private_member", ) self.assert_rejected_without_overwrite( "base/private_member", "visibility 只能是 public 或 protected" ) def test_property_empty_type_is_treated_as_omitted(self): self.write_class_member_input( { "kind": "property", "name": "Title", "visibility": "public", "desc": "标题。", "type": "", "access": "read", }, "base/property_type", ) result = self.run_cli() self.assertEqual(0, result.returncode, result.stderr) text = self.generated("base/property_type").read_text(encoding="utf-8") self.assertIn("### `Title`\n\n声明:property", text) self.assertIn("访问:read", text) self.assertNotIn("类型:", text) def test_constant_empty_type_is_treated_as_omitted(self): self.write_class_member_input( { "kind": "constant", "name": "DefaultSize", "visibility": "public", "desc": "默认大小。", "type": "", "value": 100, }, "base/constant_type", ) result = self.run_cli() self.assertEqual(0, result.returncode, result.stderr) text = self.generated("base/constant_type").read_text(encoding="utf-8") self.assertIn("值:`100`", text) self.assertNotIn("类型:", text) def test_field_missing_type_is_rejected_without_overwrite(self): self.write_class_member_input( { "kind": "field", "name": "Count", "visibility": "public", "desc": "数量。", }, "base/field_type", ) self.assert_rejected_without_overwrite( "base/field_type", "members[0]: type:不能为空" ) def test_unit_variable_missing_type_is_rejected_without_overwrite(self): self.write_input( { "module": "示例 / Unit", "path": "base/variable_type", "declarations": [ { "kind": "unit", "name": "StrictUnit", "desc": "严格接口。", "members": [ { "kind": "variable", "name": "Current", "desc": "当前值。", } ], } ], } ) self.assert_rejected_without_overwrite( "base/variable_type", "members[0]: type:不能为空" ) def test_invalid_method_binding_is_rejected_without_overwrite(self): self.write_class_member_input( { "kind": "method", "name": "Bad", "visibility": "public", "binding": "static", "signature": "Bad()", "desc": "非法绑定。", }, "base/invalid_binding", ) self.assert_rejected_without_overwrite("base/invalid_binding", "binding 无效") def test_invalid_property_access_is_rejected_without_overwrite(self): self.write_class_member_input( { "kind": "property", "name": "Title", "visibility": "public", "desc": "标题。", "type": "string", "access": "readonly", }, "base/invalid_access", ) self.assert_rejected_without_overwrite("base/invalid_access", "access 无效") def test_invalid_method_modifier_is_rejected_without_overwrite(self): self.write_class_member_input( { "kind": "method", "name": "Bad", "visibility": "public", "binding": "instance", "signature": "Bad()", "desc": "非法修饰符。", "modifiers": ["final"], }, "base/invalid_modifier", ) self.assert_rejected_without_overwrite("base/invalid_modifier", "无效 modifier") def test_property_examples_are_rejected_without_overwrite(self): self.write_class_member_input( { "kind": "property", "name": "Title", "visibility": "public", "desc": "标题。", "type": "string", "access": "read", "examples": [ { "desc": "读取标题", "code": "return Widget.Title;", } ], }, "base/property_examples", ) self.assert_rejected_without_overwrite( "base/property_examples", "存在未知字段:examples" ) def test_unit_rejects_implementation_data_without_overwrite(self): self.write_input( { "module": "示例 / Unit", "path": "base/unit_implementation", "declarations": [ { "kind": "unit", "name": "StrictUnit", "desc": "严格接口。", "members": [], "implementation": ["hidden_"], } ], } ) self.assert_rejected_without_overwrite( "base/unit_implementation", "存在未知字段:implementation" ) def test_empty_declarations_are_rejected_without_overwrite(self): self.write_input( { "module": "示例 / 空页", "path": "base/missing_branch", "declarations": [], } ) self.assert_rejected_without_overwrite( "base/missing_branch", "declarations 必须是非空列表" ) def test_unknown_declaration_kind_is_rejected_without_overwrite(self): self.write_input( { "module": "示例 / 未知声明", "path": "base/unknown_declaration", "declarations": [{"kind": "procedure"}], } ) self.assert_rejected_without_overwrite( "base/unknown_declaration", "未知 kind:procedure" ) def test_top_level_function_requires_name(self): self.write_input( { "module": "示例 / 函数", "path": "base/missing_function_name", "declarations": [ { "kind": "function", "signature": "Open()", "desc": "打开。", "returns": "nil", } ], } ) self.assert_rejected_without_overwrite( "base/missing_function_name", "declarations[0]: name" ) def test_top_level_function_name_must_match_signature(self): self.write_input( { "module": "示例 / 函数", "path": "base/function_name_mismatch", "declarations": [ { "kind": "function", "name": "Open", "signature": "Close()", "desc": "关闭。", "returns": "nil", } ], } ) self.assert_rejected_without_overwrite( "base/function_name_mismatch", "name 与 signature 中的名称不一致" ) def test_duplicate_function_signature_is_rejected(self): self.write_input( { "module": "示例 / 重复函数", "path": "base/duplicate_function", "declarations": [ { "kind": "function", "name": "Open", "signature": "Open()", "desc": "打开。", "returns": "nil", }, { "kind": "function", "name": "open", "signature": "open()", "desc": "再次打开。", "returns": "nil", }, ], } ) self.assert_rejected_without_overwrite( "base/duplicate_function", "function signature 重复" ) def test_duplicate_class_and_unit_names_are_rejected(self): cases = { "class": [ { "kind": "class", "name": "Widget", "desc": "组件。", "members": [], }, { "kind": "class", "name": "widget", "desc": "另一组件。", "members": [], }, ], "unit": [ { "kind": "unit", "name": "Runtime", "desc": "运行时。", "members": [], }, { "kind": "unit", "name": "runtime", "desc": "另一运行时。", "members": [], }, ], } for kind, declarations in cases.items(): with self.subTest(kind=kind): relative = f"base/duplicate_{kind}" self.write_input( { "module": "示例 / 重复声明", "path": relative, "declarations": declarations, } ) self.assert_rejected_without_overwrite(relative, f"{kind} 名称重复") def test_function_overloads_and_cross_kind_same_name_are_allowed(self): self.write_input( { "module": "示例 / 重载", "path": "base/overloads", "declarations": [ { "kind": "function", "name": "Open", "signature": "Open(path)", "desc": "按路径打开。", "params": [{"name": "path", "type": "string", "desc": "路径"}], "returns": "nil", }, { "kind": "function", "name": "Open", "signature": "Open(mode)", "desc": "按模式打开。", "params": [{"name": "mode", "type": "integer", "desc": "模式"}], "returns": "nil", }, { "kind": "class", "name": "Open", "desc": "打开器。", "members": [], }, { "kind": "unit", "name": "Open", "desc": "打开接口。", "members": [], }, ], } ) result = self.run_cli() self.assertEqual(0, result.returncode, result.stderr) text = self.generated("base/overloads").read_text(encoding="utf-8") markers = [ "## `Open(path)`", "## `Open(mode)`", "## `Open`", "## `Open`", ] positions = [] start = 0 for marker in markers: position = text.index(marker, start) positions.append(position) start = position + len(marker) self.assertEqual(sorted(positions), positions) self.assertEqual(2, text.count("声明:function")) self.assertEqual(1, text.count("声明:class")) self.assertEqual(1, text.count("声明:unit")) def test_constant_missing_value_is_rejected_without_overwrite(self): self.write_class_member_input( { "kind": "constant", "name": "Missing", "visibility": "public", "desc": "缺少值。", }, "base/missing_value", ) self.assert_rejected_without_overwrite("base/missing_value", "缺少 value") def test_class_missing_description_is_rejected_without_overwrite(self): self.write_input( { "module": "示例 / 类", "path": "base/missing_class_desc", "declarations": [ { "kind": "class", "name": "MissingDescription", "desc": "", "members": [], } ], } ) self.assert_rejected_without_overwrite( "base/missing_class_desc", "declarations[0]: desc:不能为空", ) def test_method_name_and_parameter_order_must_match_signature(self): cases = { "name": { "kind": "method", "name": "Expected", "visibility": "public", "binding": "instance", "signature": "Actual()", "desc": "名称不一致。", }, "parameter_order": { "kind": "method", "name": "Open", "visibility": "public", "binding": "instance", "signature": "Open(first, second)", "desc": "参数顺序不一致。", "params": [ {"name": "second", "type": "integer", "desc": "第二项"}, {"name": "first", "type": "integer", "desc": "第一项"}, ], }, } for name, member in cases.items(): with self.subTest(name=name): relative = f"base/mismatch_{name}" self.write_class_member_input(member, relative) expected = "名称不一致" if name == "name" else "参数顺序一致" self.assert_rejected_without_overwrite(relative, expected) def test_instance_field_and_static_constant_render_distinct_headings(self): self.write_input( { "module": "示例 / 类", "path": "base/bindings", "declarations": [ { "kind": "class", "name": "Bindings", "desc": "绑定示例。", "members": [ { "kind": "field", "name": "Name", "visibility": "public", "desc": "名称。", "type": "string", }, { "kind": "constant", "name": "Maximum", "visibility": "protected", "desc": "最大值。", "value": 10, "static": True, }, ], } ], } ) result = self.run_cli() self.assertEqual(0, result.returncode, result.stderr) text = self.generated("base/bindings").read_text(encoding="utf-8") self.assertIn("### `Name`\n\n声明:field", text) self.assertIn("### `Maximum`\n\n声明:static const", text) def test_constants_accept_zero_and_false_values(self): self.write_input( { "module": "示例 / Unit", "path": "base/constants", "declarations": [ { "kind": "unit", "name": "Constants", "desc": "常量接口。", "members": [ { "kind": "constant", "name": "Zero", "desc": "零。", "value": 0, }, { "kind": "constant", "name": "Disabled", "desc": "关闭。", "value": False, }, ], } ], } ) result = self.run_cli() self.assertEqual(0, result.returncode, result.stderr) text = self.generated("base/constants").read_text(encoding="utf-8") self.assertIn("值:`0`", text) self.assertIn("值:`false`", text) if __name__ == "__main__": unittest.main()