407 lines
14 KiB
Python
407 lines
14 KiB
Python
import importlib.util
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import textwrap
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SKILL_ROOT = ROOT / "skills" / "tsl-syntax-reference"
|
|
LOOKUP_PATH = SKILL_ROOT / "scripts" / "lookup.py"
|
|
SKILL_PATH = SKILL_ROOT / "SKILL.md"
|
|
CI_PATH = ROOT / ".gitea" / "workflows" / "checks.yml"
|
|
PREPARE_PATH = ROOT / ".gitea" / "workflows" / "prepare.yml"
|
|
|
|
|
|
def load_lookup_module():
|
|
spec = importlib.util.spec_from_file_location("tsl_syntax_lookup", LOOKUP_PATH)
|
|
if spec is None or spec.loader is None:
|
|
raise RuntimeError(f"cannot load {LOOKUP_PATH}")
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
lookup = load_lookup_module()
|
|
|
|
|
|
def write_reference(directory: Path, name: str, content: str) -> Path:
|
|
page = directory / name
|
|
page.write_text(textwrap.dedent(content).lstrip(), encoding="utf-8", newline="\n")
|
|
return page
|
|
|
|
|
|
def run_lookup(*args: str) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(
|
|
[sys.executable, str(LOOKUP_PATH), *args],
|
|
cwd=ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
|
|
class TslSyntaxReferenceTests(unittest.TestCase):
|
|
def test_bundled_references_pass_structural_check(self):
|
|
self.assertEqual([], lookup.validate_references())
|
|
|
|
def test_bundled_sections_use_unique_explicit_ids(self):
|
|
sections = lookup.load_sections()
|
|
ids = [section.id for section in sections]
|
|
|
|
self.assertEqual(len(ids), len(set(ids)))
|
|
self.assertTrue(ids)
|
|
for section_id in ids:
|
|
with self.subTest(section_id=section_id):
|
|
self.assertRegex(section_id, r"^syntax-\d{2}-\d{3}$")
|
|
|
|
def test_explicit_section_id_survives_heading_rename(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
references = Path(temp_dir)
|
|
page = write_reference(
|
|
references,
|
|
"99_fixture.md",
|
|
"""
|
|
# Fixture
|
|
|
|
## 本篇职责
|
|
|
|
<!-- section-id: syntax-99-001 -->
|
|
|
|
测试职责。
|
|
|
|
## 原标题
|
|
|
|
<!-- section-id: syntax-99-002 -->
|
|
|
|
事实正文。
|
|
""",
|
|
)
|
|
before = lookup.load_sections(references)[1].id
|
|
page.write_text(
|
|
page.read_text(encoding="utf-8").replace("## 原标题", "## 新标题"),
|
|
encoding="utf-8",
|
|
newline="\n",
|
|
)
|
|
after = lookup.load_sections(references)[1].id
|
|
|
|
self.assertEqual("syntax-99-002", before)
|
|
self.assertEqual(before, after)
|
|
|
|
def test_missing_explicit_section_id_fails_structure_check(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
references = Path(temp_dir)
|
|
write_reference(
|
|
references,
|
|
"99_fixture.md",
|
|
"""
|
|
# Fixture
|
|
|
|
## 本篇职责
|
|
|
|
测试职责。
|
|
""",
|
|
)
|
|
|
|
messages = [item.message for item in lookup.validate_references(references)]
|
|
|
|
self.assertTrue(any("缺少显式 section ID" in message for message in messages))
|
|
|
|
def test_duplicate_explicit_section_id_fails_check_and_loading(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
references = Path(temp_dir)
|
|
write_reference(
|
|
references,
|
|
"99_fixture.md",
|
|
"""
|
|
# Fixture
|
|
|
|
## 本篇职责
|
|
|
|
<!-- section-id: syntax-99-001 -->
|
|
|
|
测试职责。
|
|
|
|
## 重复 ID
|
|
|
|
<!-- section-id: syntax-99-001 -->
|
|
|
|
事实正文。
|
|
""",
|
|
)
|
|
|
|
messages = [item.message for item in lookup.validate_references(references)]
|
|
with self.assertRaisesRegex(lookup.ReferenceStructureError, "重复 section ID"):
|
|
lookup.load_sections(references)
|
|
|
|
self.assertTrue(any("重复 section ID" in message for message in messages))
|
|
|
|
def test_orphan_identity_line_fails_structure_check(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
references = Path(temp_dir)
|
|
write_reference(
|
|
references,
|
|
"99_fixture.md",
|
|
"""
|
|
# Fixture
|
|
|
|
## 本篇职责
|
|
|
|
<!-- section-id: syntax-99-001 -->
|
|
|
|
测试职责。
|
|
|
|
## 示例
|
|
|
|
<!-- section-id: syntax-99-002 -->
|
|
|
|
代码块身份:可直接照写示例
|
|
""",
|
|
)
|
|
|
|
messages = [item.message for item in lookup.validate_references(references)]
|
|
|
|
self.assertTrue(any("孤立的代码块身份" in message for message in messages))
|
|
|
|
def test_h5_and_h6_headings_fail_structure_check(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
references = Path(temp_dir)
|
|
write_reference(
|
|
references,
|
|
"99_fixture.md",
|
|
"""
|
|
# Fixture
|
|
|
|
## 本篇职责
|
|
|
|
<!-- section-id: syntax-99-001 -->
|
|
|
|
测试职责。
|
|
|
|
##### 不会被索引的标题
|
|
|
|
隐藏事实。
|
|
""",
|
|
)
|
|
|
|
messages = [item.message for item in lookup.validate_references(references)]
|
|
|
|
self.assertTrue(any("H5/H6" in message for message in messages))
|
|
|
|
def test_quickstart_rule_drift_fails_structure_check(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
references = Path(temp_dir)
|
|
write_reference(
|
|
references,
|
|
"01_quickstart.md",
|
|
"""
|
|
# Quickstart
|
|
|
|
## 本篇职责
|
|
|
|
<!-- section-id: syntax-01-001 -->
|
|
|
|
派生摘要。
|
|
|
|
## 语言核心事实速查
|
|
|
|
<!-- section-id: syntax-01-002 -->
|
|
|
|
<!-- quickstart-rule: assignment -->
|
|
- 普通赋值使用 `=`。
|
|
""",
|
|
)
|
|
write_reference(
|
|
references,
|
|
"02_topic.md",
|
|
"""
|
|
# Topic
|
|
|
|
## 本篇职责
|
|
|
|
<!-- section-id: syntax-02-001 -->
|
|
|
|
完整事实源。
|
|
|
|
## 核心规则
|
|
|
|
<!-- section-id: syntax-02-002 -->
|
|
|
|
<!-- quickstart-rule: assignment -->
|
|
- 普通赋值使用 `:=`。
|
|
""",
|
|
)
|
|
|
|
messages = [item.message for item in lookup.validate_references(references)]
|
|
|
|
self.assertTrue(
|
|
any("派生摘要规则与专题事实不一致" in message for message in messages)
|
|
)
|
|
|
|
def test_complete_quickstart_contract_detects_rule_deleted_from_both_pages(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
references = Path(temp_dir)
|
|
quickstart = write_reference(
|
|
references,
|
|
"01_quickstart.md",
|
|
"""
|
|
# Quickstart
|
|
|
|
## 本篇职责
|
|
|
|
<!-- section-id: syntax-01-001 -->
|
|
|
|
派生摘要。
|
|
|
|
## 语言核心事实速查
|
|
|
|
<!-- section-id: syntax-01-002 -->
|
|
|
|
没有任何规则。
|
|
""",
|
|
)
|
|
pages = {quickstart: quickstart.read_text(encoding="utf-8").splitlines()}
|
|
|
|
messages = [
|
|
item.message
|
|
for item in lookup._quickstart_rule_problems(
|
|
pages,
|
|
require_complete=True,
|
|
)
|
|
]
|
|
|
|
self.assertTrue(any("assignment" in message for message in messages))
|
|
|
|
def test_missing_references_are_installation_errors_for_every_action(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
missing = Path(temp_dir) / "missing"
|
|
actions = (
|
|
("--map",),
|
|
("--query", "数组下标", "--mode", "explain"),
|
|
("--section", "syntax-03-002"),
|
|
("--check",),
|
|
)
|
|
for action in actions:
|
|
with self.subTest(action=action):
|
|
result = run_lookup(
|
|
*action,
|
|
"--references-dir",
|
|
str(missing),
|
|
)
|
|
self.assertEqual(1, result.returncode)
|
|
self.assertEqual("", result.stdout)
|
|
self.assertIn("参考目录不可用", result.stderr)
|
|
|
|
def test_documented_array_index_query_ranks_basic_array_section_first(self):
|
|
for query in ("数组下标", "请帮我解释数组的下标"):
|
|
with self.subTest(query=query):
|
|
result = lookup.query_sections(query, "explain", limit=1)
|
|
self.assertEqual("11_matrix_and_collections.md", result.matches[0].section.page.name)
|
|
self.assertEqual("基础数组与键表", result.matches[0].section.heading_path[-1])
|
|
|
|
def test_documented_write_and_diagnose_queries_rank_expected_sections(self):
|
|
write_result = lookup.query_sections("命名参数", "write", limit=1)
|
|
diagnose_result = lookup.query_sections(
|
|
"invalid statement 声明区",
|
|
"diagnose",
|
|
limit=1,
|
|
)
|
|
|
|
self.assertEqual(
|
|
["syntax-02-002", "syntax-01-002"],
|
|
[section.id for section in write_result.prelude],
|
|
)
|
|
self.assertEqual("syntax-05-008", write_result.matches[0].section.id)
|
|
self.assertEqual("syntax-02-006", diagnose_result.matches[0].section.id)
|
|
|
|
def test_external_call_queries_retrieve_platform_and_abi_boundaries(self):
|
|
cases = {
|
|
"动态库常驻": "syntax-17-012",
|
|
"外部出参": "syntax-17-013",
|
|
"Linux 动态加载": "syntax-17-014",
|
|
"函数指针释放": "syntax-17-008",
|
|
}
|
|
for query, expected_id in cases.items():
|
|
with self.subTest(query=query):
|
|
result = lookup.query_sections(query, "write", limit=1)
|
|
self.assertEqual(expected_id, result.matches[0].section.id)
|
|
|
|
def test_index_origin_variants_rank_value_rules_first(self):
|
|
for query in ("下标从几开始", "下标是从几开始", "请问下标是从几开始"):
|
|
with self.subTest(query=query):
|
|
result = lookup.query_sections(query, "explain", limit=1)
|
|
self.assertEqual("03_values_and_literals.md", result.matches[0].section.page.name)
|
|
self.assertEqual("核心规则", result.matches[0].section.heading_path[-1])
|
|
|
|
def test_every_curated_page_alias_still_ranks_its_page_first(self):
|
|
for page_name, aliases in lookup.PAGE_INTENT_ALIASES.items():
|
|
for alias in aliases:
|
|
with self.subTest(page=page_name, alias=alias):
|
|
result = lookup.query_sections(alias, "explain", limit=1)
|
|
self.assertTrue(result.matches, alias)
|
|
self.assertEqual(page_name, result.matches[0].section.page.name)
|
|
|
|
def test_weak_only_query_remains_a_no_match(self):
|
|
result = run_lookup("--query", "只能确认", "--mode", "explain")
|
|
|
|
self.assertEqual(2, result.returncode)
|
|
self.assertIn("only weak candidates", result.stderr)
|
|
|
|
def test_batch_section_retrieval_is_atomic(self):
|
|
ids = [section.id for section in lookup.load_sections()[:2]]
|
|
success = run_lookup("--section", *ids)
|
|
failure = run_lookup("--section", ids[0], "syntax-99-999")
|
|
|
|
self.assertEqual(0, success.returncode)
|
|
self.assertTrue(all(section_id in success.stdout for section_id in ids))
|
|
self.assertEqual(2, failure.returncode)
|
|
self.assertEqual("", failure.stdout)
|
|
self.assertIn("section not found: syntax-99-999", failure.stderr)
|
|
|
|
def test_structural_metadata_is_not_exposed_in_lookup_output(self):
|
|
sections = {section.id: section for section in lookup.load_sections()}
|
|
candidates = lookup.render_candidates(
|
|
lookup.query_sections("命名参数", "write", limit=1)
|
|
)
|
|
quickstart = lookup.render_section(sections["syntax-01-002"])
|
|
|
|
for output in (candidates, quickstart):
|
|
with self.subTest(output=output[:40]):
|
|
self.assertNotIn("<!-- section-id:", output)
|
|
self.assertNotIn("<!-- quickstart-rule:", output)
|
|
|
|
def test_skill_contract_uses_extracted_queries_and_deliverable_api_checks(self):
|
|
skill = SKILL_PATH.read_text(encoding="utf-8")
|
|
help_text = lookup._parser().format_help()
|
|
|
|
self.assertIn("保留这些词在用户原话中", skill)
|
|
self.assertIn("不传完整句", skill)
|
|
self.assertNotIn("用户怎么说就怎么传", skill)
|
|
self.assertIn("不传完整用户句", help_text)
|
|
self.assertIn("面向用户交付的 TSL/TSF 代码", skill)
|
|
self.assertIn("每个 builtin/API", skill)
|
|
self.assertIn("纯语法说明", skill)
|
|
self.assertIn("不得声称该占位调用的 API 行为或输出", skill)
|
|
|
|
def test_ci_runs_syntax_structure_and_format_gates(self):
|
|
workflow = CI_PATH.read_text(encoding="utf-8")
|
|
prepare = PREPARE_PATH.read_text(encoding="utf-8")
|
|
|
|
self.assertIn(
|
|
"npm ci --ignore-scripts --no-audit --no-fund --no-bin-links",
|
|
workflow,
|
|
)
|
|
self.assertIn("skills/tsl-syntax-reference/scripts/lookup.py --check", workflow)
|
|
self.assertIn(
|
|
"node_modules/prettier/bin/prettier.cjs --check skills/tsl-syntax-reference",
|
|
workflow,
|
|
)
|
|
self.assertIn("[node]=\"nodejs\"", prepare)
|
|
self.assertIn("[npm]=\"npm\"", prepare)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|