Files
skill-template/tests/test_tutorial_surfaces.py
chendelian 9d33c5ea2c
All checks were successful
技能自动化发布 / release (push) Successful in 5s
docs: sediment TUTORIAL_SURFACES contract for README vs tutorial and three entry surfaces
2026-08-31 14:18:42 +08:00

199 lines
7.1 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.
# -*- coding: utf-8 -*-
"""用户市场文档契约:README≠教程、TUTORIAL 三入口结构、与 actions.json 对齐。
契约全文:development/TUTORIAL_SURFACES.md
- 模板占位 slug(your-skill-slug):只检查骨架与契约文档存在
- 业务技能:按 actions.json placements 硬校验教程章节与 README 反模式
"""
from __future__ import annotations
import json
import os
import re
import unittest
from _support import get_skill_root
from util.constants import SKILL_SLUG
TEMPLATE_PLACEHOLDER_SLUG = "your-skill-slug"
_SECTION_DATA_MGMT = re.compile(r"(?m)^##\s*.*数据管理")
_SECTION_DIALOG = re.compile(r"(?m)^##\s*.*(对话任务|对话)")
_SECTION_CRON = re.compile(r"(?m)^##\s*.*定时任务")
_SECTION_COMPARE = re.compile(r"(?m)^##\s*.*(对照表|主能力)")
_SECTION_STUCK = re.compile(r"(?m)^##\s*.*(常见卡住|卡住怎么办)")
_SECTION_WHAT = re.compile(r"(?m)^##\s*.*(本技能做什么|做什么)")
_NO_CRON_HINT = re.compile(r"不需要定时|未提供定时|不提供定时|不适用.*定时|定时.*不适用")
# README 反模式:分步操作 / Agent 话术专章(说明 Tab 禁止)
_README_HOWTO_AGENT = re.compile(r"(?m)^##\s*.*告诉\s*Agent")
_README_ENTRY_CHAPTER = re.compile(r"(?m)^##\s*入口[一二三]")
_README_NUMBERED_DM_PATH = re.compile(
r"(?m)^\d+[\.、]\s*.*(数据管理|选品任务|表顶).{0,40}(点击|点\s*「)"
)
def _read(rel: str) -> str:
path = os.path.join(get_skill_root(), rel)
with open(path, encoding="utf-8") as f:
return f.read()
def _load_actions() -> list[dict]:
path = os.path.join(get_skill_root(), "assets", "actions.json")
if not os.path.isfile(path):
return []
with open(path, encoding="utf-8") as f:
data = json.load(f)
return list(data.get("actions") or [])
def _placement_sets(actions: list[dict]) -> tuple[bool, bool, bool]:
has_dm = False
has_agent = False
has_cron = False
dm_tokens = frozenset({"toolbar", "row", "batch"})
for action in actions:
placements = set(action.get("placements") or [])
if placements & dm_tokens:
has_dm = True
if "agent" in placements and str(action.get("id") or "") not in (
"health",
"version",
"config-path",
):
has_agent = True
if "cron" in placements:
has_cron = True
return has_dm, has_agent, has_cron
class TestTutorialSurfacesContract(unittest.TestCase):
def test_tutorial_surfaces_doc_exists(self) -> None:
path = os.path.join(get_skill_root(), "development", "TUTORIAL_SURFACES.md")
self.assertTrue(
os.path.isfile(path),
msg="development/TUTORIAL_SURFACES.md must exist (user market doc contract)",
)
text = _read("development/TUTORIAL_SURFACES.md")
for marker in (
"README.md",
"TUTORIAL.md",
"数据管理",
"对话",
"定时",
"skill-detail",
"对照表",
):
self.assertIn(marker, text, msg=f"TUTORIAL_SURFACES.md missing {marker!r}")
def test_development_md_points_to_tutorial_surfaces(self) -> None:
text = _read("development/DEVELOPMENT.md")
self.assertIn(
"TUTORIAL_SURFACES.md",
text,
msg="DEVELOPMENT.md must reference TUTORIAL_SURFACES.md for market docs",
)
def test_template_or_business_tutorial_has_required_skeleton(self) -> None:
tutorial = _read("TUTORIAL.md")
self.assertRegex(
tutorial,
_SECTION_WHAT,
msg="TUTORIAL.md must have a '本技能做什么' (or equivalent) section",
)
self.assertRegex(
tutorial,
_SECTION_DATA_MGMT,
msg="TUTORIAL.md must have a 数据管理 section (or 不适用 placeholder)",
)
self.assertRegex(
tutorial,
_SECTION_DIALOG,
msg="TUTORIAL.md must have a 对话 section (or 不适用 placeholder)",
)
self.assertRegex(
tutorial,
_SECTION_CRON,
msg="TUTORIAL.md must have a 定时任务 section (teach or N/A)",
)
self.assertRegex(
tutorial,
_SECTION_COMPARE,
msg="TUTORIAL.md must have a 主能力对照表 section",
)
self.assertRegex(
tutorial,
_SECTION_STUCK,
msg="TUTORIAL.md must have a 常见卡住 section",
)
# 禁止旧「对话单线」作为唯一主结构标题
self.assertNotRegex(
tutorial,
r"(?m)^##\s*第一步:打开技能并说清目标\s*$",
msg="TUTORIAL.md must not use legacy dialogue-only first step as sole structure",
)
def test_readme_has_no_legacy_agent_howto_chapter(self) -> None:
readme = _read("README.md")
self.assertIsNone(
_README_HOWTO_AGENT.search(readme),
msg="README.md must not have '告诉 Agent' howto chapter (belongs in TUTORIAL)",
)
self.assertIsNone(
_README_ENTRY_CHAPTER.search(readme),
msg="README.md must not have 入口一/二/三 chapters (belongs in TUTORIAL)",
)
def test_business_skill_tutorial_aligns_with_actions(self) -> None:
if SKILL_SLUG == TEMPLATE_PLACEHOLDER_SLUG:
self.skipTest("template placeholder slug: skip actions alignment hard checks")
actions = _load_actions()
has_dm, has_agent, has_cron = _placement_sets(actions)
tutorial = _read("TUTORIAL.md")
readme = _read("README.md")
if has_dm:
self.assertRegex(
tutorial,
_SECTION_DATA_MGMT,
msg="business skill with data-management placements must document 数据管理 in TUTORIAL",
)
# 准备类若挂了 toolbar,教程不应把 skill-detail 当唯一主路径
self.assertNotRegex(
tutorial,
r"(?m)^\d+[\.、]\s*.*技能详情.{0,30}点击",
msg="TUTORIAL must not use 技能详情点击 as primary numbered path when DM exists",
)
if has_agent:
self.assertRegex(
tutorial,
_SECTION_DIALOG,
msg="business skill with agent placements must document 对话 in TUTORIAL",
)
if has_cron:
self.assertRegex(
tutorial,
_SECTION_CRON,
msg="with cron placements, TUTORIAL must have 定时任务 section",
)
else:
self.assertRegex(
tutorial,
_NO_CRON_HINT,
msg="without cron placements, TUTORIAL must declare 不需要/未提供定时",
)
# README 不应出现「数据管理 → … → 点击」式编号步骤
self.assertIsNone(
_README_NUMBERED_DM_PATH.search(readme),
msg="README.md must not contain numbered data-management click steps",
)
if __name__ == "__main__":
unittest.main()