import json import subprocess import sys import tempfile import textwrap import unittest from pathlib import Path try: import yaml except ImportError: yaml = None TOOL_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = TOOL_ROOT.parents[1] CONVERT = TOOL_ROOT / "scripts" / "convert_tsf.py" GENERATE = TOOL_ROOT / "scripts" / "generate.py" LINT = TOOL_ROOT / "scripts" / "lint.py" BUILD_INDEX = TOOL_ROOT / "scripts" / "build_index.py" LOOKUP = REPO_ROOT / "skills" / "tsl-api-reference" / "scripts" / "lookup.py" STANDARD = TOOL_ROOT / "STANDARD.md" README = TOOL_ROOT / "README.md" EXAMPLE_JSON = TOOL_ROOT / "examples" / "example.json" EXAMPLE_YAML = TOOL_ROOT / "examples" / "example.yaml" SKILL = REPO_ROOT / "skills" / "tsl-api-reference" / "SKILL.md" class UnifiedPipelineTest(unittest.TestCase): def setUp(self): self.temp_dir = tempfile.TemporaryDirectory() self.root = Path(self.temp_dir.name) self.skill_dir = self.root / "skills" / "tsl-api-reference" def tearDown(self): self.temp_dir.cleanup() def run_command(self, *args, cwd=None): return subprocess.run( [sys.executable, *map(str, args)], cwd=cwd or REPO_ROOT, capture_output=True, text=True, encoding="utf-8", check=False, ) def run_pipeline(self, sources, queries): tsf_paths = [] for name, source in sources: tsf_path = self.root / f"{name}.tsf" tsf_path.write_text( textwrap.dedent(source).lstrip(), encoding="utf-8" ) tsf_paths.append(tsf_path) page_path = "base/mixed" recordings = {} recording_paths = {} for output_format in ("json", "yaml"): recording = self.root / f"mixed.{output_format}" convert = self.run_command( CONVERT, *tsf_paths, "--format", output_format, "--module", "示例 / 混合 API", "--path", page_path, "--output", recording, ) self.assertEqual(0, convert.returncode, convert.stderr) recording_paths[output_format] = recording text = recording.read_text(encoding="utf-8") recordings[output_format] = ( json.loads(text) if output_format == "json" else yaml.safe_load(text) ) self.assertEqual(recordings["json"], recordings["yaml"]) markdown = ( self.skill_dir / "references" / "codegen" / "project" / f"{page_path}.md" ) generated_markdown = {} for output_format, recording in recording_paths.items(): generate = self.run_command(GENERATE, recording, cwd=self.root) self.assertEqual(0, generate.returncode, generate.stderr) self.assertTrue(markdown.is_file()) generated_markdown[output_format] = markdown.read_text( encoding="utf-8" ) lint = self.run_command(LINT, "--file", markdown, "--strict") self.assertEqual(0, lint.returncode, lint.stdout + lint.stderr) self.assertEqual( generated_markdown["json"], generated_markdown["yaml"] ) build = self.run_command(BUILD_INDEX, "--skill-dir", self.skill_dir) self.assertEqual(0, build.returncode, build.stderr) check = self.run_command( BUILD_INDEX, "--skill-dir", self.skill_dir, "--check" ) self.assertEqual(0, check.returncode, check.stderr) lookup_outputs = {} for query in queries: lookup = self.run_command( LOOKUP, "--name", query, "--tsv", self.skill_dir / "data" / "function_index.tsv", ) self.assertEqual(0, lookup.returncode, lookup.stderr) lookup_outputs[query] = lookup.stdout return ( recordings["json"], generated_markdown["json"], lookup_outputs, ) @unittest.skipUnless(yaml is not None, "pyyaml is not installed") def test_mixed_pipeline_convert_generate_lint_index_lookup(self): recording, markdown, lookups = self.run_pipeline( sources=[ ( "Widget", """ type Widget = class /// 组件 public /// 重命名组件 /// @param: name 组件名称 function Rename(name: string): string; /// 创建组件 class function Create(): Widget; /// 组件标题 property Title: string read title_ write title_; /// 原始标题 property RawTitle read title_; /// 标题存储 title_: string; /// 组件数量 static Count: integer; /// 默认类型 const DefaultKind = 1; /// 最大数量 static const Maximum = 100; end; """, ), ( "OpenWidget", """ function OpenWidget(path: string): Widget; begin /// 打开组件 /// @param: path 组件路径 return nil; end; """, ), ( "DemoUnit", """ unit DemoUnit; /// 接口 interface /// 默认大小 const DefaultSize = 100; /// 当前文档 var CurrentDocument: Document; /// 打开文档 /// @param: path 文档路径 function OpenDocument(path: string): Document; type Document = class /// 文档 public /// 保存 function Save(): boolean; /// 创建文档 class function Create(): Document; /// 文档标题 property Title: string read title_ write title_; private title_: string; end; implementation end. """, ), ], queries=("Widget", "OpenWidget", "DemoUnit.Document"), ) self.assertEqual( ["class", "function", "unit"], [item["kind"] for item in recording["declarations"]], ) widget_members = recording["declarations"][0]["members"] raw_title = next( item for item in widget_members if item["name"] == "RawTitle" ) self.assertEqual("", raw_title["type"]) self.assertEqual([], raw_title["params"]) self.assertIn("## `Widget`\n\n声明:class", markdown) self.assertIn("## `OpenWidget(path)`\n\n声明:function", markdown) self.assertIn("## `DemoUnit`\n\n声明:unit", markdown) self.assertIn( "### `Create()`\n\n声明:class function", lookups["Widget"] ) self.assertIn( "### `Rename(name)`\n\n声明:function", lookups["Widget"] ) self.assertNotIn("## `OpenWidget()`", lookups["Widget"]) self.assertIn("声明:function", lookups["OpenWidget"]) self.assertNotIn("## `DemoUnit`", lookups["OpenWidget"]) self.assertIn( "#### `Save()`\n\n声明:function", lookups["DemoUnit.Document"] ) self.assertIn( "#### `Create()`\n\n声明:class function", lookups["DemoUnit.Document"], ) def test_standard_and_readme_describe_unified_declarations(self): standard = STANDARD.read_text(encoding="utf-8") readme = README.read_text(encoding="utf-8") skill = SKILL.read_text(encoding="utf-8") self.assertTrue(standard.startswith("# TSL API 文档标准\n")) self.assertNotIn("根字段 `functions`、`class`、`unit`", standard) self.assertNotIn("每个 markdown 叶子页只记录一种页面类型", standard) for fragment in ( "declarations", "声明:function", "声明:class", "声明:unit", "顶级 function", "顶级 class", "顶级 unit", "综合示例", ): self.assertIn(fragment, standard) for heading in ( "### function\n", "### class\n", "### unit\n", "### 综合示例\n", ): self.assertIn(heading, standard) for heading in ( "### 顶级 function\n", "### 顶级 class\n", "### 顶级 unit\n", "### 混合页面示例\n", ): self.assertNotIn(heading, standard) for fragment in ( "declarations", "混合输入", "OpenXmlAttribute.tsf", "OpenXmlRuntime.tsf", "13 列", "property 类型可选", ): self.assertIn(fragment, readme) self.assertIn( "## `OpenXmlAttribute`\n\n声明:class", readme ) self.assertIn( "### `CreateVirtual(position, row_index, _story)`\n\n" "声明:class function", readme, ) self.assertNotIn("property/field/variable 缺少类型", readme) self.assertIn("混合页面", skill) self.assertIn("page#anchor", skill) def test_standard_class_methods_reuse_top_level_function_structure(self): standard = STANDARD.read_text(encoding="utf-8") class_section = standard.split("### class\n", 1)[1].split( "### unit\n", 1 )[0] normalized_class = " ".join(class_section.split()) normalized_standard = " ".join(standard.split()) self.assertIn("正文结构与顶级 function 相同", normalized_class) self.assertIn("实例 function 和 class function", normalized_class) self.assertIn("标题及其子标题整体下移一级", normalized_class) self.assertEqual( 2, class_section.count(""), ) self.assertIn( "所有 class member 都可以包含可选的 `tags`", normalized_standard, ) self.assertIn( "### `CreateVirtual(position, row_index, _story)`", class_section, ) self.assertIn("声明:class function", class_section) self.assertIn("返回:ParagraphSegment", class_section) @unittest.skipUnless(yaml is not None, "pyyaml is not installed") def test_json_and_yaml_examples_use_the_same_complete_structure(self): json_data = json.loads(EXAMPLE_JSON.read_text(encoding="utf-8")) yaml_data = yaml.safe_load(EXAMPLE_YAML.read_text(encoding="utf-8")) expected_root = {"module", "path", "declarations"} self.assertEqual(expected_root, set(json_data)) self.assertEqual(expected_root, set(yaml_data)) self.assertEqual(json_data, yaml_data) declarations = { item["kind"]: item for item in json_data["declarations"] } self.assertEqual({"function", "class", "unit"}, set(declarations)) self.assertEqual( ["class", "function", "unit"], [item["kind"] for item in json_data["declarations"]], ) class_members = declarations["class"]["members"] self.assertEqual( [ "create", "CreateVirtual", "Prefix", "NamespaceUri", "value_", "Count", "DefaultPrefix", "MaximumAttributes", ], [member["name"] for member in class_members], ) self.assertIn("class", [m.get("binding") for m in class_members]) self.assertEqual( "string", next(m for m in class_members if m["name"] == "Prefix")["type"], ) self.assertNotIn( "type", next(m for m in class_members if m["name"] == "NamespaceUri"), ) self.assertEqual( {"method", "property", "field", "constant"}, {member["kind"] for member in class_members}, ) self.assertTrue( next(m for m in class_members if m["name"] == "Count")["static"] ) self.assertTrue( next( m for m in class_members if m["name"] == "MaximumAttributes" )["static"] ) unit_members = declarations["unit"]["members"] self.assertEqual( {"constant", "variable", "function", "class"}, {member["kind"] for member in unit_members}, ) document = next( member for member in unit_members if member["kind"] == "class" ) self.assertEqual( {("method", "instance"), ("method", "class"), ("property", None)}, {(m["kind"], m.get("binding")) for m in document["members"]}, ) page = ( self.skill_dir / "references" / "codegen" / "project" / f"{json_data['path']}.md" ) markdown_by_format = {} for output_format, recording in ( ("json", EXAMPLE_JSON), ("yaml", EXAMPLE_YAML), ): generate = self.run_command(GENERATE, recording, cwd=self.root) self.assertEqual(0, generate.returncode, generate.stderr) markdown_by_format[output_format] = page.read_bytes() lint = self.run_command(LINT, "--file", page, "--strict") self.assertEqual(0, lint.returncode, lint.stdout + lint.stderr) self.assertEqual(markdown_by_format["json"], markdown_by_format["yaml"]) if __name__ == "__main__": unittest.main()