import io import importlib.util import subprocess import sys import tempfile import unittest from contextlib import redirect_stdout from pathlib import Path SCRIPT = ( Path(__file__).resolve().parents[3] / "skills" / "tsl-api-reference" / "scripts" / "lookup.py" ) SKILL_MD = SCRIPT.parents[1] / "SKILL.md" def load_script(): spec = importlib.util.spec_from_file_location("tsl_lookup", SCRIPT) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module class LookupTest(unittest.TestCase): def write_unified_api_fixture(self, skill_root): data_dir = skill_root / "data" codegen_dir = skill_root / "references" / "codegen" data_dir.mkdir() (codegen_dir / "project").mkdir(parents=True) header = [ "name", "scope", "module", "signature", "page", "anchor", "tags", "summary", "kind", "binding", "visibility", "owner", "qualified_name", ] def row( name, signature, page, anchor, summary, kind, binding="", visibility="", owner="", qualified_name="", ): return [ name, "project", "demo", signature, page, anchor, "", summary, kind, binding, visibility, owner, qualified_name or name, ] rows = [ row( "DemoUnit", "DemoUnit", "project/demo_unit.md", "demounit", "接口。", "unit", ), row( "Open", "Open()", "project/demo_unit.md", "open", "打开。", "function", "unit", "public", "DemoUnit", "DemoUnit.Open", ), row( "Document", "Document", "project/demo_unit.md", "document", "文档。", "class", "unit", "public", "DemoUnit", "DemoUnit.Document", ), row( "Save", "Save(path)", "project/demo_unit.md", "savepath", "按路径保存。", "method", "instance", "public", "DemoUnit.Document", "DemoUnit.Document.Save", ), row( "Save", "Save(mode)", "project/demo_unit.md", "savemode", "按模式保存。", "method", "instance", "protected", "DemoUnit.Document", "DemoUnit.Document.Save", ), row( "Name", "Name", "project/demo_unit.md", "name", "文档名称。", "field", "instance", "public", "DemoUnit.Document", "DemoUnit.Document.Name", ), row( "Other", "Other", "project/demo_unit.md", "other", "其他对象。", "class", "unit", "public", "DemoUnit", "DemoUnit.Other", ), row( "Save", "Save()", "project/demo_unit.md", "save", "保存其他对象。", "method", "instance", "public", "DemoUnit.Other", "DemoUnit.Other.Save", ), row( "Widget", "Widget", "project/widget.md", "widget", "组件。", "class", ), row( "Create", "Create()", "project/widget.md", "create", "创建组件。", "method", "class", "public", "Widget", "Widget.Create", ), row( "Count", "Count", "project/widget.md", "count", "组件数量。", "field", "static", "protected", "Widget", "Widget.Count", ), ] index_text = "\n".join( ["\t".join(header), *("\t".join(values) for values in rows)] ) tsv_path = data_dir / "function_index.tsv" tsv_path.write_text(index_text + "\n", encoding="utf-8") (codegen_dir / "project" / "demo_unit.md").write_text( "# Demo / Unit\n\n" "## `DemoUnit`\n\n" "声明:unit\n\n" "接口。\n\n" "### `Open()`\n\n" "声明:function\n\n" "打开。\n\n" "返回:Document\n\n" "### `Document`\n\n" "声明:class\n\n" "文档。\n\n" "#### `Save(path)`\n\n" "声明:function\n\n" "按路径保存。\n\n" "可见性:`public`\n\n" "#### `Save(mode)`\n\n" "声明:function\n\n" "按模式保存。\n\n" "可见性:`protected`\n\n" "#### `Name`\n\n" "声明:field\n\n" "文档名称。\n\n" "可见性:`public`\n\n" "类型:string\n\n" "### `Other`\n\n" "声明:class\n\n" "其他对象。\n\n" "#### `Save()`\n\n" "声明:function\n\n" "保存其他对象。\n\n" "可见性:`public`\n", encoding="utf-8", ) (codegen_dir / "project" / "widget.md").write_text( "# Demo / Class\n\n" "## `Widget`\n\n" "声明:class\n\n" "组件。\n\n" "### `Create()`\n\n" "声明:class function\n\n" "创建组件。\n\n" "可见性:`public`\n\n" "### `Count`\n\n" "声明:static field\n\n" "组件数量。\n\n" "可见性:`protected`\n\n" "类型:integer\n", encoding="utf-8", ) return tsv_path def write_mixed_api_fixture(self, skill_root): data_dir = skill_root / "data" codegen_dir = skill_root / "references" / "codegen" / "project" data_dir.mkdir() codegen_dir.mkdir(parents=True) header = [ "name", "scope", "module", "signature", "page", "anchor", "tags", "summary", "kind", "binding", "visibility", "owner", "qualified_name", ] page = "project/mixed.md" rows = [ [ "Widget", "project", "mixed", "Widget", page, "widget", "", "组件。", "class", "", "", "", "Widget", ], [ "Open", "project", "mixed", "Open()", page, "open", "", "打开。", "method", "instance", "public", "Widget", "Widget.Open", ], [ "Parse", "project", "mixed", "Parse(text)", page, "parse", "", "解析。", "function", "", "", "", "Parse", ], [ "Runtime", "project", "mixed", "Runtime", page, "runtime", "", "接口。", "unit", "", "", "", "Runtime", ], [ "Document", "project", "mixed", "Document", page, "document", "", "文档。", "class", "unit", "public", "Runtime", "Runtime.Document", ], [ "Save", "project", "mixed", "Save()", page, "save", "", "保存。", "method", "instance", "public", "Runtime.Document", "Runtime.Document.Save", ], [ "Other", "project", "mixed", "Other", page, "other", "", "其他组件。", "class", "", "", "", "Other", ], ] tsv_path = data_dir / "function_index.tsv" tsv_path.write_text( "\n".join( ["\t".join(header), *("\t".join(row) for row in rows)] ) + "\n", encoding="utf-8", ) (codegen_dir / "mixed.md").write_text( "# Mixed\n\n" "## `Widget`\n\n声明:class\n\n组件。\n\n" "### `Open()`\n\n声明:function\n\n打开。\n\n可见性:public\n\n" "## `Parse(text)`\n\n声明:function\n\n解析。\n\n" "| 参数 | 类型 | 说明 |\n" "| --- | --- | --- |\n" "| `text` | string | 文本 |\n\n" "返回:Widget\n\n" "### 示例\n\n" "```markdown\n## `NotBoundary`\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\n" "## `Other`\n\n声明:class\n\n其他组件。\n", encoding="utf-8", ) return tsv_path def run_lookup(self, tsv_path, *args): return subprocess.run( [ sys.executable, str(SCRIPT), *args, "--tsv", str(tsv_path), ], capture_output=True, text=True, encoding="utf-8", check=False, ) def test_tsv_without_tags_is_rejected(self): with tempfile.TemporaryDirectory() as temp_dir: tsv_path = Path(temp_dir) / "function_index.tsv" tsv_path.write_text( "name\tscope\tmodule\tsignature\tpage\tanchor\tsummary\n" "foo\tbuiltin\tmisc\tfoo()\tmisc.md\tfoo\tLegacy entry\n", encoding="utf-8", ) result = subprocess.run( [ sys.executable, str(SCRIPT), "--kw", "Legacy", "--tsv", str(tsv_path), ], capture_output=True, text=True, check=False, ) self.assertEqual(1, result.returncode) self.assertEqual("", result.stdout) self.assertIn("missing required columns: tags", result.stderr) self.assertNotIn("Traceback", result.stderr) def test_missing_required_tsv_columns_are_rejected(self): with tempfile.TemporaryDirectory() as temp_dir: tsv_path = Path(temp_dir) / "function_index.tsv" tsv_path.write_text("name\nfoo\n", encoding="utf-8") result = subprocess.run( [ sys.executable, str(SCRIPT), "--name", "foo", "--tsv", str(tsv_path), ], capture_output=True, text=True, check=False, ) self.assertEqual(1, result.returncode) self.assertIn("missing required columns", result.stderr) self.assertNotIn("Traceback", result.stderr) def test_tsv_row_width_is_validated(self): with tempfile.TemporaryDirectory() as temp_dir: tsv_path = Path(temp_dir) / "function_index.tsv" tsv_path.write_text( "name\tscope\tmodule\tsignature\tpage\tanchor\ttags\tsummary\n" "foo\tbuiltin\n", encoding="utf-8", ) result = subprocess.run( [ sys.executable, str(SCRIPT), "--name", "foo", "--tsv", str(tsv_path), ], capture_output=True, text=True, check=False, ) self.assertEqual(1, result.returncode) self.assertIn("line 2 has 2 columns; expected 8", result.stderr) self.assertNotIn("Traceback", result.stderr) def test_missing_entry_body_is_rejected(self): with tempfile.TemporaryDirectory() as temp_dir: skill_root = Path(temp_dir) data_dir = skill_root / "data" codegen_dir = skill_root / "references" / "codegen" data_dir.mkdir() codegen_dir.mkdir(parents=True) (data_dir / "function_index.tsv").write_text( "name\tscope\tmodule\tsignature\tpage\tanchor\ttags\tsummary\n" "foo\tbuiltin\tmisc\tfoo()\tmissing.md\tfoo\t\tFallback only\n", encoding="utf-8", ) result = subprocess.run( [ sys.executable, str(SCRIPT), "--name", "foo", "--tsv", str(data_dir / "function_index.tsv"), ], capture_output=True, text=True, check=False, ) self.assertEqual(1, result.returncode) self.assertEqual("", result.stdout) self.assertIn("entry body not found", result.stderr) self.assertIn("missing.md#foo", result.stderr) def test_empty_queries_are_rejected(self): for option in ("--name", "--kw"): with self.subTest(option=option): result = subprocess.run( [sys.executable, str(SCRIPT), option, ""], capture_output=True, text=True, check=False, ) self.assertEqual(2, result.returncode) self.assertIn("must not be empty", result.stderr) def test_non_positive_limit_is_rejected(self): for limit in ("0", "-1"): with self.subTest(limit=limit): result = subprocess.run( [ sys.executable, str(SCRIPT), "--kw", "数组", "--limit", limit, ], capture_output=True, text=True, check=False, ) self.assertEqual(2, result.returncode) self.assertIn("--limit must be >= 1", result.stderr) def test_exact_search_uses_anchor_for_duplicate_signatures(self): module = load_script() with tempfile.TemporaryDirectory() as temp_dir: skill_root = Path(temp_dir) data_dir = skill_root / "data" codegen_dir = skill_root / "references" / "codegen" data_dir.mkdir() codegen_dir.mkdir(parents=True) header = [ "name", "scope", "module", "signature", "page", "anchor", "tags", "summary", ] rows = [ [ "CAPA", "builtin", "language", "CAPA()", "objects.md", "capa", "", "IMAP summary", ], [ "CAPA", "builtin", "language", "CAPA()", "objects.md", "capa-1", "", "Pop3 summary", ], ] index_text = "\n".join( ["\t".join(header), *("\t".join(row) for row in rows)] ) (data_dir / "function_index.tsv").write_text( index_text + "\n", encoding="utf-8" ) (codegen_dir / "objects.md").write_text( "# Objects\n\n" "## `CAPA()`\n\n" "声明:function\n\n" "对象:IMAP\n\n" "## `CAPA()`\n\n" "声明:function\n\n" "对象:Pop3\n\n" "", encoding="utf-8", ) stdout = io.StringIO() with redirect_stdout(stdout): status = module.main( [ "--name", "CAPA", "--tsv", str(data_dir / "function_index.tsv"), ] ) output = stdout.getvalue() self.assertEqual(0, status) self.assertEqual(1, output.count("对象:IMAP")) self.assertEqual(1, output.count("对象:Pop3")) self.assertIn("objects.md#capa -->", output) self.assertIn("objects.md#capa-1 -->", output) def test_exact_qualified_member_query_returns_only_member_overloads(self): with tempfile.TemporaryDirectory() as temp_dir: tsv_path = self.write_unified_api_fixture(Path(temp_dir)) result = self.run_lookup( tsv_path, "--name", "demounit.document.save" ) self.assertEqual(0, result.returncode, result.stderr) self.assertEqual(2, result.stdout.count("#### `Save(")) self.assertNotIn("#### `Name`", result.stdout) self.assertNotIn("### `Other`", result.stdout) def test_exact_unit_class_query_returns_its_h4_subtree_only(self): with tempfile.TemporaryDirectory() as temp_dir: tsv_path = self.write_unified_api_fixture(Path(temp_dir)) result = self.run_lookup( tsv_path, "--name", "DemoUnit.Document" ) self.assertEqual(0, result.returncode, result.stderr) self.assertIn("### `Document`", result.stdout) self.assertIn("#### `Save(path)`", result.stdout) self.assertIn("#### `Name`", result.stdout) self.assertNotIn("### `Other`", result.stdout) def test_exact_unit_query_returns_the_complete_unit_subtree(self): with tempfile.TemporaryDirectory() as temp_dir: tsv_path = self.write_unified_api_fixture(Path(temp_dir)) result = self.run_lookup(tsv_path, "--name", "DemoUnit") self.assertEqual(0, result.returncode, result.stderr) self.assertIn("## `DemoUnit`", result.stdout) self.assertIn("### `Open()`", result.stdout) self.assertIn("### `Document`", result.stdout) self.assertIn("#### `Save(path)`", result.stdout) self.assertIn("### `Other`", result.stdout) self.assertNotIn("## `Widget`", result.stdout) def test_exact_class_query_returns_all_class_members(self): with tempfile.TemporaryDirectory() as temp_dir: tsv_path = self.write_unified_api_fixture(Path(temp_dir)) result = self.run_lookup(tsv_path, "--name", "widget") self.assertEqual(0, result.returncode, result.stderr) self.assertIn("## `Widget`", result.stdout) self.assertIn("### `Create()`\n\n声明:class function", result.stdout) self.assertIn("### `Count`\n\n声明:static field", result.stdout) self.assertNotIn("## `DemoUnit`", result.stdout) def test_exact_class_in_mixed_page_stops_at_next_h2(self): with tempfile.TemporaryDirectory() as temp_dir: tsv_path = self.write_mixed_api_fixture(Path(temp_dir)) result = self.run_lookup(tsv_path, "--name", "Widget") self.assertEqual(0, result.returncode, result.stderr) self.assertIn("### `Open()`", result.stdout) self.assertNotIn("## `Parse(text)`", result.stdout) def test_exact_function_in_mixed_page_keeps_h3_example_only(self): with tempfile.TemporaryDirectory() as temp_dir: tsv_path = self.write_mixed_api_fixture(Path(temp_dir)) result = self.run_lookup(tsv_path, "--name", "Parse") self.assertEqual(0, result.returncode, result.stderr) self.assertIn("### 示例", result.stdout) self.assertIn("## `NotBoundary`", result.stdout) self.assertNotIn("## `Runtime`", result.stdout) def test_exact_unit_in_mixed_page_stops_before_following_class(self): with tempfile.TemporaryDirectory() as temp_dir: tsv_path = self.write_mixed_api_fixture(Path(temp_dir)) result = self.run_lookup(tsv_path, "--name", "Runtime") self.assertEqual(0, result.returncode, result.stderr) self.assertIn("#### `Save()`", result.stdout) self.assertNotIn("## `Other`", result.stdout) def test_unqualified_member_query_returns_all_owners(self): with tempfile.TemporaryDirectory() as temp_dir: tsv_path = self.write_unified_api_fixture(Path(temp_dir)) result = self.run_lookup(tsv_path, "--name", "Save") self.assertEqual(0, result.returncode, result.stderr) self.assertEqual(3, result.stdout.count("#### `Save(")) self.assertIn("按路径保存。", result.stdout) self.assertIn("按模式保存。", result.stdout) self.assertIn("保存其他对象。", result.stdout) def test_keyword_search_includes_unified_fields_and_declaration_term(self): with tempfile.TemporaryDirectory() as temp_dir: tsv_path = self.write_unified_api_fixture(Path(temp_dir)) qualified = self.run_lookup( tsv_path, "--kw", "Widget.Create" ) visibility = self.run_lookup( tsv_path, "--kw", "protected", "method" ) self.assertEqual(0, qualified.returncode, qualified.stderr) self.assertIn("Widget.Create\tclass function\tCreate()", qualified.stdout) self.assertNotIn("static function", qualified.stdout) self.assertEqual(0, visibility.returncode, visibility.stderr) self.assertIn("DemoUnit.Document.Save", visibility.stdout) def test_skill_documents_qualified_queries_and_legacy_index_compatibility(self): text = SKILL_MD.read_text(encoding="utf-8") self.assertIn("完全限定名称", text) self.assertIn("qualified_name", text) self.assertIn("owner、kind、binding、visibility", text) self.assertIn("class function", text) self.assertIn("旧 8 列", text) def test_keyword_search_includes_tags_and_summary(self): module = load_script() rows = [ { "name": "demo", "signature": "demo()", "module": "base", "tags": "数组 列表", "summary": "返回示例值", } ] self.assertEqual(rows, module.search_keyword(rows, ["数组"])) self.assertEqual(rows, module.search_keyword(rows, ["返回示例"])) if __name__ == "__main__": unittest.main()