Files
playbook/test/test_tsl_syntax_reference.py
T
csh 736d1a8ad7 feat(tsl-syntax-reference): harden retrieval contracts
Add stable section IDs, structural and routing regressions, quickstart consistency checks, and CI enforcement.

BREAKING CHANGE: replace heading-derived Section IDs with explicit syntax-NN-NNN identifiers.
2026-07-31 10:05:15 +08:00

395 lines
13 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_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()