Files
playbook/tools/tsl-codegen/tests/test_lint.py
T
2026-07-29 07:58:05 +00:00

835 lines
26 KiB
Python

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"
"<!-- tags: 示例 -->\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()