Files
playbook/test/test_tsl_syntax_reference.py
T

624 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import importlib.util
import re
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"
NODE_VERSION_PATH = ROOT / ".node-version"
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_quickstart_owner_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 -->
- 普通赋值使用 `:=`。
Owner Section`syntax-02-999`
""",
)
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("Owner Section 不存在" in message for message in messages))
def test_quickstart_routes_every_derived_rule_to_its_owner_section(self):
quickstart = run_lookup("--section", "syntax-01-002")
self.assertEqual(0, quickstart.returncode, quickstart.stderr)
owner_ids = set(
re.findall(r"Owner Section`(syntax-\d{2}-\d{3})`", quickstart.stdout)
)
self.assertEqual(
{
"syntax-02-002",
"syntax-03-002",
"syntax-03-004",
"syntax-05-002",
"syntax-06-004",
"syntax-08-002",
"syntax-09-002",
},
owner_ids,
)
for owner_id in owner_ids:
with self.subTest(owner_id=owner_id):
owner = run_lookup("--section", owner_id)
self.assertEqual(0, owner.returncode, owner.stderr)
def test_oversized_leaf_section_fails_structure_check(self):
with tempfile.TemporaryDirectory() as temp_dir:
references = Path(temp_dir)
long_body = "\n".join(f"事实行 {index}" for index in range(181))
write_reference(
references,
"99_fixture.md",
"# Fixture\n\n"
"## 本篇职责\n\n"
"<!-- section-id: syntax-99-001 -->\n\n"
"测试职责。\n\n"
"## 过长事实段\n\n"
"<!-- section-id: syntax-99-002 -->\n\n"
f"{long_body}\n",
)
result = run_lookup(
"--check",
"--references-dir",
str(references),
)
self.assertEqual(1, result.returncode)
self.assertIn("叶子 Section 正文超过 180 行", result.stderr)
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("03_values_and_literals.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_object_and_class_queries_return_focused_sections(self):
cases = {
"成员访问可见性": "syntax-08-013",
"类外实现": "syntax-08-014",
"固定索引 property": "syntax-08-015",
"参数化 property": "syntax-08-016",
"方法隐藏 hide": "syntax-08-017",
"调用父类 inherited": "syntax-08-018",
"析构 destroy": "syntax-08-019",
}
for query, expected_id in cases.items():
with self.subTest(query=query):
result = lookup.query_sections(query, "explain", limit=1)
self.assertTrue(result.matches, query)
self.assertEqual(expected_id, result.matches[0].section.id)
section = run_lookup("--section", expected_id)
self.assertEqual(0, section.returncode, section.stderr)
self.assertLessEqual(len(section.stdout.splitlines()), 186)
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_string_literal_queries_rank_generation_policy_first(self):
for query in ("普通中文字符串", "原始字符串 %%", "字符串 U L 前缀"):
with self.subTest(query=query):
result = lookup.query_sections(query, "explain", limit=1)
self.assertEqual("syntax-03-004", result.matches[0].section.id)
def test_string_literal_generation_policy_is_in_quickstart_and_detail(self):
quickstart = run_lookup("--section", "syntax-01-002")
detail = run_lookup("--section", "syntax-03-004")
self.assertEqual(0, quickstart.returncode, msg=quickstart.stderr)
self.assertEqual(0, detail.returncode, msg=detail.stderr)
for output in (quickstart.stdout, detail.stdout):
self.assertIn("普通单行文本默认使用", output)
self.assertIn("不得因为内容是中文、非 ASCII 或较长", output)
self.assertIn("不能因为内容是中文就自动添加", output)
self.assertIn('普通中文内容优先直接写成 `"中文内容"`', output)
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_strong_candidates_are_ranked_before_weak_diagnose_boosts(self):
result = lookup.query_sections("变量赋值", "diagnose", limit=1)
self.assertTrue(result.matches)
self.assertFalse(result.matches[0].weak)
self.assertNotEqual("syntax-04-008", result.matches[0].section.id)
def test_page_intent_aliases_score_once_per_page(self):
result = lookup.query_sections("高性能矩阵 fmarray", "explain", limit=3)
self.assertTrue(result.matches)
self.assertEqual("syntax-22-004", result.matches[0].section.id)
for match in result.matches:
self.assertNotIn("intent=160", match.reasons)
self.assertIn("intent=80", result.matches[0].reasons)
def test_program_is_a_searchable_language_keyword(self):
result = lookup.query_sections("PROGRAM", "explain", limit=1)
self.assertTrue(result.matches)
self.assertEqual("syntax-02-009", result.matches[0].section.id)
def test_maintenance_html_comments_do_not_leak_into_lookup_output(self):
result = run_lookup("--query", "PROGRAM", "--mode", "explain")
self.assertEqual(0, result.returncode)
self.assertNotIn("prettier-ignore", result.stdout)
def test_new_language_gaps_have_stable_retrieval_entries(self):
cases = {
"全局变量": "syntax-04-009",
"运行时常量": "syntax-04-010",
"静态计算": "syntax-06-016",
"只计算一次": "syntax-06-016",
"指定系统函数": "syntax-05-013",
"运算符优先级": "syntax-06-015",
"内存上限": "syntax-14-011",
"嵌套注释": "syntax-15-005",
"JOIN 第二张表当前行": "syntax-13-010",
"按行广播": "syntax-11-011",
}
for query, expected_id in cases.items():
with self.subTest(query=query):
result = lookup.query_sections(query, "explain", limit=1)
self.assertTrue(result.matches, query)
self.assertEqual(expected_id, result.matches[0].section.id)
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_result = run_lookup("--help")
self.assertEqual(0, help_result.returncode, help_result.stderr)
help_text = help_result.stdout
self.assertNotIn("## 构造查询词", skill)
self.assertNotIn("用户怎么说就怎么传", skill)
self.assertIn("从用户原话提取", help_text)
self.assertIn("保留原写法但不传完整用户句", help_text)
self.assertIn("不传完整用户句", help_text)
self.assertIn("面向用户交付的 TSL/TSF 代码", skill)
self.assertIn("每个 builtin/API", skill)
self.assertIn("纯语法说明", skill)
self.assertIn("不得声称该占位调用的 API 行为或输出", skill)
def test_help_owns_cli_details_and_skill_defers_to_it(self):
skill = SKILL_PATH.read_text(encoding="utf-8")
result = run_lookup("--help")
self.assertEqual(0, result.returncode, result.stderr)
for text in (
"一次 --query 只覆盖一个语法要素",
"Required: yes",
"Owner Section",
"混合候选",
"180 行粒度上限",
"退出码",
):
with self.subTest(text=text):
self.assertIn(text, result.stdout)
self.assertIn("构造任一命令前先运行", skill)
self.assertIn("scripts/lookup.py --help", skill)
self.assertIn("不用于查询 API 签名", skill)
self.assertIn("不用于选择解释器或运行方式", 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("[unzip]=\"unzip\"", prepare)
self.assertIn('FNM_BIN: "/data/bin/fnm"', prepare)
self.assertIn('FNM_DIR: "/data/fnm"', prepare)
self.assertIn('"$FNM_BIN" install "$node_version"', prepare)
self.assertNotIn('[node]="nodejs"', prepare)
self.assertNotIn('[npm]="npm"', prepare)
self.assertIn('"$FNM_BIN" use --install-if-missing "$node_version"', workflow)
self.assertIn("node --version", workflow)
self.assertRegex(
NODE_VERSION_PATH.read_text(encoding="utf-8").strip(),
r"^\d+\.\d+\.\d+$",
)
if __name__ == "__main__":
unittest.main()