import importlib.util import subprocess import sys import tempfile import unittest from pathlib import Path SCRIPT = Path(__file__).parents[1] / "scripts" / "lint.py" API_MARKDOWN = Path(__file__).parents[1] / "scripts" / "api_markdown.py" VALID_PAGE = ( "# 项目 / 示例\n\n" "## `demo()`\n\n" "声明:function\n\n" "示例函数\n\n" "返回:nil\n" ) def load_api_markdown(): spec = importlib.util.spec_from_file_location("tsl_api_markdown", API_MARKDOWN) module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) return module class DocLintCliTest(unittest.TestCase): def setUp(self): self.temp_dir = tempfile.TemporaryDirectory() self.root = Path(self.temp_dir.name) self.page = self.root / "base" / "demo.md" self.page.parent.mkdir() self.page.write_text(VALID_PAGE, encoding="utf-8") def tearDown(self): self.temp_dir.cleanup() def run_cli(self, *args): return subprocess.run( [sys.executable, str(SCRIPT), *map(str, args)], capture_output=True, text=True, encoding="utf-8", ) def test_accepts_one_markdown_file(self): result = self.run_cli("--file", self.page) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("1 files", result.stderr) def test_accepts_one_directory(self): result = self.run_cli("--dir", self.root) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("1 files", result.stderr) def test_accepts_mixed_typed_h2_declarations(self): self.page.write_text( "# 示例 / 混合\n\n" "## `Widget`\n\n" "声明:class\n\n" "组件。\n\n" "### `Open()`\n\n" "声明:function\n\n" "打开。\n\n" "可见性:`public`\n\n" "## `Parse()`\n\n" "声明:function\n\n" "解析。\n\n" "返回:Widget\n\n" "### 示例\n\n" "```tsl\nreturn Parse();\n```\n\n" "## `Runtime`\n\n" "声明:unit\n\n" "运行时接口。\n\n" "### `Document`\n\n" "声明:class\n\n" "文档。\n\n" "#### `Save()`\n\n" "声明:function\n\n" "保存。\n\n" "可见性:`public`\n", encoding="utf-8", ) result = self.run_cli("--file", self.page, "--strict") self.assertEqual(0, result.returncode, result.stdout + result.stderr) def test_declaration_must_use_exact_canonical_line(self): self.page.write_text( "# 示例 / 函数\n\n" "## `Open()`\n\n" "声明:Function\n\n" "打开。\n\n" "返回:nil\n", encoding="utf-8", ) result = self.run_cli("--file", self.page) self.assertEqual(1, result.returncode) self.assertIn("[heading]", result.stdout) self.assertIn("声明:function", result.stdout) def test_business_type_metadata_is_not_a_declaration_marker(self): module = load_api_markdown() lines = [ "## `Account`", "", "声明:function", "", "远程服务器账户。", "", "类型:读写", "", "返回:nil", ] entries = list(module.iter_api_entries(lines)) self.assertEqual(1, len(entries)) self.assertTrue(entries[0].heading.valid) self.assertEqual("function", entries[0].heading.root_kind) self.page.write_text( "# 示例 / 遗留属性\n\n" + "\n".join(lines) + "\n", encoding="utf-8", ) result = self.run_cli("--file", self.page, "--strict") self.assertEqual(0, result.returncode, result.stdout + result.stderr) def test_declaration_must_be_first_non_empty_line_after_heading(self): self.page.write_text( "# 示例 / 函数\n\n" "## `Open()`\n\n" "打开。\n\n" "声明:function\n\n" "返回:nil\n", encoding="utf-8", ) result = self.run_cli("--file", self.page) self.assertEqual(1, result.returncode) self.assertIn("[heading]", result.stdout) self.assertIn("第一条非空正文", result.stdout) def test_tags_must_follow_description(self): self.page.write_text( "# 示例 / 函数\n\n" "## `Open()`\n\n" "声明:function\n\n" "\n\n" "打开。\n\n" "返回:nil\n", encoding="utf-8", ) result = self.run_cli("--file", self.page) self.assertEqual(1, result.returncode) self.assertIn("[description]", result.stdout) def test_property_type_is_optional_but_access_is_required(self): page_without_type = ( "# 示例 / 类\n\n" "## `Widget`\n\n" "声明:class\n\n" "表示组件。\n\n" "### `Title`\n\n" "声明:property\n\n" "组件标题。\n\n" "可见性:`public`\n\n" "访问:read\n" ) self.page.write_text(page_without_type, encoding="utf-8") valid = self.run_cli("--file", self.page, "--strict") self.assertEqual(0, valid.returncode, valid.stdout + valid.stderr) self.page.write_text( page_without_type.replace("\n访问:read\n", "\n"), encoding="utf-8", ) invalid = self.run_cli("--file", self.page) self.assertEqual(1, invalid.returncode) self.assertIn("[access]", invalid.stdout) def test_accepts_class_page_with_optional_method_return(self): self.page.write_text( "# 示例 / 类\n\n" "## `Widget`\n\n" "声明:class\n\n" "表示组件。\n\n" "### `Close()`\n\n" "声明:function\n\n" "关闭组件。\n\n" "可见性:`public`\n", encoding="utf-8", ) result = self.run_cli("--file", self.page, "--strict") self.assertEqual(0, result.returncode, result.stdout + result.stderr) def test_class_member_requires_visibility(self): self.page.write_text( "# 示例 / 类\n\n" "## `Widget`\n\n" "声明:class\n\n" "表示组件。\n\n" "### `Count`\n\n" "声明:field\n\n" "组件数量。\n\n" "类型:integer\n", encoding="utf-8", ) result = self.run_cli("--file", self.page) self.assertEqual(1, result.returncode) self.assertIn("[visibility]", result.stdout) def test_parameterized_property_requires_the_fixed_parameter_table(self): self.page.write_text( "# 示例 / 类\n\n" "## `Widget`\n\n" "声明:class\n\n" "表示组件。\n\n" "### `Items(index)`\n\n" "声明:property\n\n" "按索引读取组件。\n\n" "可见性:`public`\n\n" "类型:Widget\n\n" "访问:read\n\n" "| 名称 | 类型 | 说明 |\n" "| --- | --- | --- |\n" "| `index` | integer | 索引 |\n", encoding="utf-8", ) result = self.run_cli("--file", self.page) self.assertEqual(1, result.returncode) self.assertIn("[param-header]", result.stdout) def test_nullary_property_rejects_a_parameter_table(self): self.page.write_text( "# 示例 / 类\n\n" "## `Widget`\n\n" "声明:class\n\n" "表示组件。\n\n" "### `Title`\n\n" "声明:property\n\n" "组件标题。\n\n" "可见性:`public`\n\n" "类型:string\n\n" "访问:read\n\n" "| 参数 | 类型 | 说明 |\n" "| --- | --- | --- |\n" "| `unused` | integer | 不应存在 |\n", encoding="utf-8", ) result = self.run_cli("--file", self.page) self.assertEqual(1, result.returncode) self.assertIn("[param-table]", result.stdout) def test_class_method_allows_h4_value_and_example_subheadings(self): self.page.write_text( "# 示例 / 类\n\n" "## `Widget`\n\n" "声明:class\n\n" "表示组件。\n\n" "### `Open(mode)`\n\n" "声明:function\n\n" "打开组件。\n\n" "可见性:`public`\n\n" "| 参数 | 类型 | 说明 |\n" "| --- | --- | --- |\n" "| `mode` | integer | 打开模式 |\n\n" "#### `mode` 取值\n\n" "- `0` — 默认模式\n\n" "#### 示例\n\n" "```tsl\nreturn Open(0);\n```\n", encoding="utf-8", ) result = self.run_cli("--file", self.page, "--strict") self.assertEqual(0, result.returncode, result.stdout + result.stderr) def test_property_rejects_example_subheading(self): self.page.write_text( "# 示例 / 类\n\n" "## `Widget`\n\n" "声明:class\n\n" "表示组件。\n\n" "### `Title`\n\n" "声明:property\n\n" "组件标题。\n\n" "可见性:`public`\n\n" "类型:string\n\n" "访问:read\n\n" "#### 示例\n\n" "```tsl\nreturn Widget.Title;\n```\n", encoding="utf-8", ) result = self.run_cli("--file", self.page) self.assertEqual(1, result.returncode) self.assertIn("[subheading]", result.stdout) self.assertIn("property", result.stdout) def test_accepts_unit_page_with_nested_class_member(self): self.page.write_text( "# 示例 / Unit\n\n" "## `DemoUnit`\n\n" "声明:unit\n\n" "提供接口。\n\n" "### `DefaultSize`\n\n" "声明:const\n\n" "默认大小。\n\n" "值:`100`\n\n" "### `Open()`\n\n" "声明:function\n\n" "打开。\n\n" "返回:Document\n\n" "### `Document`\n\n" "声明:class\n\n" "文档对象。\n\n" "#### `Save()`\n\n" "声明:function\n\n" "保存。\n\n" "可见性:`protected`\n", encoding="utf-8", ) result = self.run_cli("--file", self.page, "--strict") self.assertEqual(0, result.returncode, result.stdout + result.stderr) def test_unit_class_method_allows_h5_value_and_example_subheadings(self): self.page.write_text( "# 示例 / Unit\n\n" "## `DemoUnit`\n\n" "声明:unit\n\n" "提供接口。\n\n" "### `Document`\n\n" "声明:class\n\n" "文档对象。\n\n" "#### `Save(mode)`\n\n" "声明:function\n\n" "保存。\n\n" "可见性:`public`\n\n" "| 参数 | 类型 | 说明 |\n" "| --- | --- | --- |\n" "| `mode` | integer | 保存模式 |\n\n" "##### `mode` 取值\n\n" "- `0` — 默认模式\n\n" "##### 示例\n\n" "```tsl\nreturn Save(0);\n```\n", encoding="utf-8", ) result = self.run_cli("--file", self.page, "--strict") self.assertEqual(0, result.returncode, result.stdout + result.stderr) def test_unit_function_requires_return(self): self.page.write_text( "# 示例 / Unit\n\n" "## `DemoUnit`\n\n" "声明:unit\n\n" "提供接口。\n\n" "### `Open()`\n\n" "声明:function\n\n" "打开。\n", encoding="utf-8", ) result = self.run_cli("--file", self.page) self.assertEqual(1, result.returncode) self.assertIn("[return]", result.stdout) def test_unit_function_rejects_an_empty_return_type(self): self.page.write_text( "# 示例 / Unit\n\n" "## `DemoUnit`\n\n" "声明:unit\n\n" "提供接口。\n\n" "### `Open()`\n\n" "声明:function\n\n" "打开。\n\n" "返回:\n", encoding="utf-8", ) result = self.run_cli("--file", self.page) self.assertEqual(1, result.returncode) self.assertIn("[return]", result.stdout) def test_empty_type_and_value_lines_are_rejected(self): cases = { "property": ( "# 示例 / 类\n\n## `Widget`\n\n声明:class\n\n组件。\n\n" "### `Title`\n\n声明:property\n\n标题。\n\n可见性:`public`\n\n" "类型:\n\n访问:read\n", "[type]", ), "field": ( "# 示例 / 类\n\n## `Widget`\n\n声明:class\n\n组件。\n\n" "### `Count`\n\n声明:field\n\n数量。\n\n可见性:`public`\n\n类型:\n", "[type]", ), "variable": ( "# 示例 / Unit\n\n## `DemoUnit`\n\n声明:unit\n\n接口。\n\n" "### `Current`\n\n声明:var\n\n当前值。\n\n类型:\n", "[type]", ), "constant": ( "# 示例 / Unit\n\n## `DemoUnit`\n\n声明:unit\n\n接口。\n\n" "### `Default`\n\n声明:const\n\n默认值。\n\n值:\n", "[value]", ), } for name, (text, expected) in cases.items(): with self.subTest(name=name): self.page.write_text(text, encoding="utf-8") result = self.run_cli("--file", self.page) self.assertEqual(1, result.returncode) self.assertIn(expected, result.stdout) def test_class_parent_metadata_does_not_count_as_description(self): self.page.write_text( "# 示例 / 类\n\n" "## `Widget`\n\n" "声明:class\n\n" "父类:`BaseWidget`\n\n" "", encoding="utf-8", ) result = self.run_cli("--file", self.page) self.assertEqual(1, result.returncode) self.assertIn("[description]", result.stdout) def test_class_parameter_rows_must_match_signature(self): self.page.write_text( "# 示例 / 类\n\n" "## `Widget`\n\n" "声明:class\n\n" "表示组件。\n\n" "### `Open(mode)`\n\n" "声明:function\n\n" "打开组件。\n\n" "可见性:`public`\n\n" "| 参数 | 类型 | 说明 |\n" "| --- | --- | --- |\n" "| `other` | integer | 错误参数 |\n", encoding="utf-8", ) result = self.run_cli("--file", self.page) self.assertEqual(1, result.returncode) self.assertIn("[param-names]", result.stdout) def test_class_parameter_rows_require_type_and_description(self): self.page.write_text( "# 示例 / 类\n\n" "## `Widget`\n\n" "声明:class\n\n" "表示组件。\n\n" "### `Open(mode)`\n\n" "声明:function\n\n" "打开组件。\n\n" "可见性:`public`\n\n" "| 参数 | 类型 | 说明 |\n" "| --- | --- | --- |\n" "| `mode` | | |\n", encoding="utf-8", ) result = self.run_cli("--file", self.page) self.assertEqual(1, result.returncode) self.assertIn("[param-row]", result.stdout) def test_unit_class_member_requires_visibility(self): self.page.write_text( "# 示例 / Unit\n\n" "## `DemoUnit`\n\n" "声明:unit\n\n" "提供接口。\n\n" "### `Document`\n\n" "声明:class\n\n" "文档对象。\n\n" "#### `Name`\n\n" "声明:field\n\n" "文档名称。\n\n" "类型:string\n", encoding="utf-8", ) result = self.run_cli("--file", self.page) self.assertEqual(1, result.returncode) self.assertIn("[visibility]", result.stdout) def test_class_and_unit_member_data_lines_are_required(self): cases = { "property": ( "# 示例 / 类\n\n## `Widget`\n\n声明:class\n\n组件。\n\n" "### `Title`\n\n声明:property\n\n标题。\n\n可见性:`public`\n", "[access]", ), "field": ( "# 示例 / 类\n\n## `Widget`\n\n声明:class\n\n组件。\n\n" "### `Count`\n\n声明:field\n\n数量。\n\n可见性:`public`\n", "[type]", ), "variable": ( "# 示例 / Unit\n\n## `DemoUnit`\n\n声明:unit\n\n接口。\n\n" "### `Current`\n\n声明:var\n\n当前值。\n", "[type]", ), "constant": ( "# 示例 / Unit\n\n## `DemoUnit`\n\n声明:unit\n\n接口。\n\n" "### `Default`\n\n声明:const\n\n默认值。\n", "[value]", ), } for name, (text, expected) in cases.items(): with self.subTest(name=name): self.page.write_text(text, encoding="utf-8") result = self.run_cli("--file", self.page) self.assertEqual(1, result.returncode) self.assertIn(expected, result.stdout) def test_class_legacy_typed_api_heading_is_rejected(self): self.page.write_text( "# 示例 / 类\n\n" "## `Widget`\n\n" "声明:class\n\n" "表示组件。\n\n" "### function `Open()`\n\n" "打开组件。\n\n" "可见性:`public`\n", encoding="utf-8", ) result = self.run_cli("--file", self.page) self.assertEqual(1, result.returncode) self.assertIn("[heading]", result.stdout) self.assertIn("标题只写名称或调用签名", result.stdout) def test_unit_legacy_typed_api_heading_is_rejected(self): self.page.write_text( "# 示例 / Unit\n\n" "## `DemoUnit`\n\n" "声明:unit\n\n" "提供接口。\n\n" "### function `Open()`\n\n" "打开。\n\n" "返回:Document\n", encoding="utf-8", ) result = self.run_cli("--file", self.page) self.assertEqual(1, result.returncode) self.assertIn("[heading]", result.stdout) self.assertIn("标题只写名称或调用签名", result.stdout) def test_class_member_at_h4_is_rejected(self): self.page.write_text( "# 示例 / 类\n\n" "## `Widget`\n\n" "声明:class\n\n" "表示组件。\n\n" "#### `Open()`\n\n" "声明:function\n\n" "打开组件。\n\n" "可见性:`public`\n", encoding="utf-8", ) result = self.run_cli("--file", self.page) self.assertEqual(1, result.returncode) self.assertIn("[heading]", result.stdout) self.assertIn("H3", result.stdout) def test_unit_class_member_at_h5_is_rejected(self): self.page.write_text( "# 示例 / Unit\n\n" "## `DemoUnit`\n\n" "声明:unit\n\n" "提供接口。\n\n" "### `Document`\n\n" "声明:class\n\n" "文档对象。\n\n" "##### `Save()`\n\n" "声明:function\n\n" "保存。\n\n" "可见性:`public`\n", encoding="utf-8", ) result = self.run_cli("--file", self.page) self.assertEqual(1, result.returncode) self.assertIn("[heading]", result.stdout) self.assertIn("H4", result.stdout) def test_rejects_static_function_and_private_api(self): self.page.write_text( "# 示例 / 类\n\n" "## `Widget`\n\n" "声明:class\n\n" "表示组件。\n\n" "### `Create()`\n\n" "声明:static function\n\n" "创建。\n\n" "可见性:`private`\n", encoding="utf-8", ) result = self.run_cli("--file", self.page) self.assertEqual(1, result.returncode) self.assertIn("static function", result.stdout) self.assertIn("private", result.stdout) def test_mixed_h2_declarations_reset_nested_context(self): module = load_api_markdown() lines = [ "# Demo", "", "## `Widget`", "", "声明:class", "", "组件。", "", "### `Open()`", "", "声明:function", "", "打开。", "", "可见性:public", "", "## `Parse(text)`", "", "声明:function", "", "解析。", "", "返回:Widget", "", "## `DemoUnit`", "", "声明:unit", "", "接口。", "", "### `Document`", "", "声明:class", "", "文档。", "", "#### `Save()`", "", "声明:function", "", "保存。", "", "可见性:public", ] headings = [entry.heading for entry in module.iter_api_entries(lines)] self.assertEqual( [ (2, "class", "class", "Widget"), (3, "method", "class", "Open"), (2, "function", "function", "Parse"), (2, "unit", "unit", "DemoUnit"), (3, "class", "unit", "Document"), (4, "method", "unit", "Save"), ], [ (item.level, item.kind, item.root_kind, item.name) for item in headings ], ) def test_missing_duplicate_and_unknown_declarations_are_errors(self): module = load_api_markdown() cases = { "missing": ( ["## `Open()`", "", "打开。", "", "返回:nil"], "第一条非空正文", ), "duplicate": ( [ "## `Widget`", "", "声明:class", "", "组件。", "", "声明:unit", ], "必须且只能包含一行", ), "unknown": ( ["## `Runtime`", "", "声明:module", "", "接口。"], "必须是 function、class 或 unit", ), } for name, (lines, expected) in cases.items(): with self.subTest(name=name): headings = [ entry.heading for entry in module.iter_api_entries(lines) ] self.assertEqual(1, len(headings)) self.assertFalse(headings[0].valid) self.assertIn(expected, headings[0].error) def test_plain_h2_ends_api_body_and_resets_nested_context(self): module = load_api_markdown() lines = [ "# Demo", "", "## `Parse()`", "", "声明:function", "", "解析。", "", "返回:nil", "", "## 其他说明", "", "### function `NotAMember()`", ] entries = list(module.iter_api_entries(lines)) self.assertEqual(["Parse"], [entry.heading.name for entry in entries]) self.assertEqual(10, entries[0].end) def test_parameter_value_and_example_headings_are_not_api_entries(self): module = load_api_markdown() lines = [ "# Demo", "", "## `Widget`", "", "声明:class", "", "表示组件。", "", "### `Open(mode)`", "", "声明:function", "", "打开。", "", "可见性:`public`", "", "#### `mode` 取值", "", "#### 示例", "", "## `Parse(mode)`", "", "声明:function", "", "解析。", "", "| 参数 | 类型 | 说明 |", "| --- | --- | --- |", "| `mode` | integer | 模式 |", "", "**mode 取值**", "", "### 示例", "", "## `DemoUnit`", "", "声明:unit", "", "接口。", "", "### `Load(mode)`", "", "声明:function", "", "加载。", "", "#### `mode` 取值", "", "#### 示例", ] entries = list(module.iter_api_entries(lines)) self.assertEqual( ["Widget", "Open", "Parse", "DemoUnit", "Load"], [entry.heading.name for entry in entries], ) if __name__ == "__main__": unittest.main()