基于 skill-template 脚手架开发,包含: - payroll_config(费率配置,可编辑)+ payslip_snapshots(解析历史,只读)2 张业务表 - 全国默认 + 北上广 4 套费率(社保/公积金/个税起征点/7 级税率表) - 5 个业务 Action:calculate-payslip-simple / calculate-payslip-full / explain-term / view-config / edit-config - 个税累计预扣法计算,支持多月历史累加 - 20 个薪资名词库(社保/公积金/个税/全勤/加班费等) - 完整市场文档(README/TUTORIAL/DEMO/CHANGELOG) - 296 测试全部通过
222 lines
8.0 KiB
Python
222 lines
8.0 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""用户市场文档契约:README≠教程、TUTORIAL 三入口结构、与 actions.json 对齐。
|
||
|
||
契约全文:development/TUTORIAL_SURFACES.md
|
||
- 模板占位 slug(query-payslip-default):只检查骨架与契约文档存在
|
||
- 业务技能:按 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 = "query-payslip-default"
|
||
|
||
_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()
|