# -*- 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_market_docs_forbid_redundant_page_h1(self) -> None: """宿主已有技能名与 Tab 标题:说明/教程正文禁止再用 # 一级标题。""" def strip_fences(src: str) -> str: return re.sub(r"```.*?```", "", src, flags=re.S) for rel in ("README.md", "TUTORIAL.md"): body = strip_fences(_read(rel)) # YAML frontmatter 里不应出现 ATX H1;正文亦禁止 # self.assertIsNone( re.search(r"(?m)^#\s+\S", body), msg=( f"{rel} must not use ATX H1 (# …); host already shows skill name / tab. " "Use ## for sections (see TUTORIAL_SURFACES.md §0.1)." ), ) self.assertIsNone( re.search(r"(?m)^#\s+.·\s*使用教程\s*$", body), msg=f"{rel} must not start sections with '# … · 使用教程'", ) 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()