832 lines
34 KiB
Python
832 lines
34 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""文档与 pytest 配置守护:防止模板标准退化。"""
|
||
from __future__ import annotations
|
||
|
||
import configparser
|
||
import os
|
||
import re
|
||
import unittest
|
||
|
||
from _support import get_skill_root
|
||
|
||
|
||
FORBIDDEN_README_DESCRIPTION_TERMS_EN = (
|
||
"cli",
|
||
"schema",
|
||
"references",
|
||
"development",
|
||
"playwright",
|
||
"python",
|
||
"dom",
|
||
"rpa",
|
||
)
|
||
|
||
FORBIDDEN_README_DESCRIPTION_TERMS_ZH = (
|
||
"Agent 参考索引",
|
||
"契约",
|
||
"数据结构",
|
||
"开发规范",
|
||
)
|
||
|
||
|
||
def _description_has_forbidden_term(description: str) -> str | None:
|
||
lowered = description.lower()
|
||
for term in FORBIDDEN_README_DESCRIPTION_TERMS_EN:
|
||
if term in lowered:
|
||
return term
|
||
for term in FORBIDDEN_README_DESCRIPTION_TERMS_ZH:
|
||
if term in description:
|
||
return term
|
||
return None
|
||
|
||
|
||
def _extract_workflow_uses_line(text: str) -> str | None:
|
||
for line in text.splitlines():
|
||
stripped = line.strip()
|
||
if stripped.startswith("uses:"):
|
||
return stripped
|
||
return None
|
||
|
||
|
||
class TestDocsStandards(unittest.TestCase):
|
||
def _read(self, rel_path: str) -> str:
|
||
path = os.path.join(get_skill_root(), rel_path)
|
||
with open(path, encoding="utf-8") as f:
|
||
return f.read()
|
||
|
||
def _parse_frontmatter(self, text: str) -> str:
|
||
parts = text.split("---", 2)
|
||
if len(parts) < 2:
|
||
return ""
|
||
return parts[1]
|
||
|
||
def test_root_readme_has_yaml_frontmatter(self) -> None:
|
||
text = self._read("README.md")
|
||
self.assertTrue(text.startswith("---"), "README.md must start with YAML frontmatter")
|
||
parts = text.split("---", 2)
|
||
self.assertGreaterEqual(len(parts), 3, "README.md frontmatter must be closed")
|
||
|
||
def test_root_readme_frontmatter_has_description(self) -> None:
|
||
frontmatter = self._parse_frontmatter(self._read("README.md"))
|
||
self.assertIn("description:", frontmatter, "README.md frontmatter must include description")
|
||
|
||
def test_root_readme_description_avoids_technical_index_terms(self) -> None:
|
||
frontmatter = self._parse_frontmatter(self._read("README.md"))
|
||
match = re.search(r"description:\s*(.+)", frontmatter)
|
||
self.assertIsNotNone(match, "README.md description field not found")
|
||
description = match.group(1).strip().strip("\"'")
|
||
forbidden = _description_has_forbidden_term(description)
|
||
self.assertIsNone(
|
||
forbidden,
|
||
msg=f"README.md description must not contain technical index term {forbidden!r}",
|
||
)
|
||
|
||
def test_description_forbidden_terms_are_case_insensitive_for_english(self) -> None:
|
||
fail_cases = (
|
||
"CLI 契约说明",
|
||
"cli 工具入口",
|
||
"SCHEMA 定义",
|
||
"Schema 映射",
|
||
"schema 字段",
|
||
"Playwright 自动化",
|
||
"python 脚本",
|
||
"RPA 流程",
|
||
)
|
||
pass_cases = (
|
||
"用用户能理解的方式说明这个技能能做什么、适合什么场景,以及如何开始使用。",
|
||
"帮你自动整理订单并生成处理报告",
|
||
"适合每天需要重复核对的业务场景",
|
||
)
|
||
for description in fail_cases:
|
||
with self.subTest(description=description):
|
||
self.assertIsNotNone(
|
||
_description_has_forbidden_term(description),
|
||
msg=f"expected forbidden term in {description!r}",
|
||
)
|
||
for description in pass_cases:
|
||
with self.subTest(description=description):
|
||
self.assertIsNone(
|
||
_description_has_forbidden_term(description),
|
||
msg=f"expected no forbidden term in {description!r}",
|
||
)
|
||
|
||
def test_references_readme_has_no_yaml_frontmatter(self) -> None:
|
||
text = self._read("references/README.md")
|
||
self.assertFalse(
|
||
text.lstrip().startswith("---"),
|
||
"references/README.md must not contain YAML frontmatter",
|
||
)
|
||
|
||
def test_references_readme_has_no_description_field(self) -> None:
|
||
text = self._read("references/README.md")
|
||
self.assertNotRegex(
|
||
text,
|
||
r"(?m)^description\s*:",
|
||
msg="references/README.md must not contain description field",
|
||
)
|
||
|
||
def test_skill_md_requires_read_readme_first_rule(self) -> None:
|
||
text = self._read("SKILL.md")
|
||
self.assertIn("README.md", text)
|
||
self.assertTrue(
|
||
"优先读取" in text or "优先读" in text,
|
||
msg="SKILL.md must require reading README.md first for user-facing questions",
|
||
)
|
||
|
||
def test_skill_md_states_readme_is_for_users(self) -> None:
|
||
text = self._read("SKILL.md")
|
||
self.assertIn("README.md", text)
|
||
self.assertTrue(
|
||
any(marker in text for marker in ("面向用户", "普通用户", "用户市场")),
|
||
msg="SKILL.md must state README.md is user-facing documentation",
|
||
)
|
||
|
||
def test_market_tab_docs_exist(self) -> None:
|
||
root = get_skill_root()
|
||
for name in ("README.md", "TUTORIAL.md", "DEMO.md", "CHANGELOG.md"):
|
||
path = os.path.join(root, name)
|
||
self.assertTrue(os.path.isfile(path), msg=f"{name} must exist at skill root")
|
||
|
||
def test_demo_md_frontmatter_has_demo_video_url(self) -> None:
|
||
text = self._read("DEMO.md")
|
||
self.assertTrue(text.startswith("---"), "DEMO.md must start with YAML frontmatter")
|
||
frontmatter = self._parse_frontmatter(text)
|
||
self.assertIn(
|
||
"demo_video_url:",
|
||
frontmatter,
|
||
"DEMO.md frontmatter must include demo_video_url",
|
||
)
|
||
|
||
def test_changelog_has_section_for_skill_version(self) -> None:
|
||
skill_fm = self._parse_frontmatter(self._read("SKILL.md"))
|
||
match = re.search(r"(?m)^version:\s*[\"']?([0-9]+\.[0-9]+\.[0-9]+)", skill_fm)
|
||
self.assertIsNotNone(match, "SKILL.md must declare semver version")
|
||
version = match.group(1)
|
||
changelog = self._read("CHANGELOG.md")
|
||
self.assertRegex(
|
||
changelog,
|
||
rf"(?m)^##\s*\[?v?{re.escape(version)}\]?",
|
||
msg=f"CHANGELOG.md must include a ## section for version {version}",
|
||
)
|
||
|
||
def test_skill_md_documents_market_four_tabs(self) -> None:
|
||
text = self._read("SKILL.md")
|
||
for name in ("TUTORIAL.md", "DEMO.md", "CHANGELOG.md", "readme_md", "tutorial_md"):
|
||
self.assertIn(name, text, msg=f"SKILL.md must document market material {name}")
|
||
|
||
def test_development_md_documents_market_four_tabs(self) -> None:
|
||
text = self._read("development/DEVELOPMENT.md")
|
||
self.assertIn("TUTORIAL.md", text)
|
||
self.assertIn("DEMO.md", text)
|
||
self.assertIn("CHANGELOG.md", text)
|
||
self.assertIn("demo_video_url", text)
|
||
self.assertIn("tutorial_md", text)
|
||
self.assertIn("TUTORIAL_SURFACES.md", text)
|
||
|
||
def test_tutorial_surfaces_contract_doc_and_checklist(self) -> None:
|
||
surfaces = self._read("development/TUTORIAL_SURFACES.md")
|
||
self.assertIn("说明", surfaces)
|
||
self.assertIn("教程", surfaces)
|
||
self.assertIn("skill-detail", surfaces)
|
||
checklist = self._read("development/CHECKLIST.md")
|
||
self.assertIn("A2-7", checklist)
|
||
self.assertIn("A2-8", checklist)
|
||
self.assertIn("A2-12", checklist)
|
||
self.assertIn("TUTORIAL_SURFACES.md", checklist)
|
||
|
||
def test_naming_md_exists_and_covers_standards(self) -> None:
|
||
text = self._read("development/NAMING.md")
|
||
self.assertIn("verb-noun-platform", text)
|
||
self.assertIn("48", text)
|
||
self.assertTrue("LEGACY" in text or "历史" in text)
|
||
self.assertTrue("verbs.txt" in text or "动词白名单" in text)
|
||
self.assertTrue("platforms.txt" in text or "平台白名单" in text)
|
||
self.assertTrue("仅适用于新创建" in text or "新技能" in text)
|
||
|
||
def test_release_workflow_uses_main_channel(self) -> None:
|
||
text = self._read(".github/workflows/release_skill.yaml")
|
||
uses_line = _extract_workflow_uses_line(text)
|
||
self.assertIsNotNone(uses_line, "release_skill.yaml must contain a uses: line")
|
||
self.assertIn(
|
||
"client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml",
|
||
uses_line,
|
||
)
|
||
self.assertRegex(
|
||
uses_line,
|
||
r"reusable-release-skill\.yaml@main\b",
|
||
msg="workflow uses must reference reusable-release-skill.yaml@main",
|
||
)
|
||
|
||
def test_rpa_md_covers_video_and_playwright_standards(self) -> None:
|
||
text = self._read("development/RPA.md")
|
||
self.assertIn("title", text)
|
||
self.assertIn("closing_title", text)
|
||
self.assertIn("中文", text)
|
||
self.assertIn("--no-sandbox", text)
|
||
self.assertIn("--disable-blink-features=AutomationControlled", text)
|
||
self.assertIn("RpaVideoSession", text)
|
||
self.assertIn("1.2.0", text)
|
||
|
||
def test_rpa_md_forbids_rpa_helpers_import(self) -> None:
|
||
text = self._read("development/RPA.md")
|
||
self.assertIn("rpa_helpers", text)
|
||
self.assertTrue(
|
||
"不要 import" in text or "不要" in text and "import" in text,
|
||
msg="RPA.md must document forbidding rpa_helpers import",
|
||
)
|
||
|
||
def test_rpa_md_covers_simulator_playwright_layering(self) -> None:
|
||
text = self._read("development/RPA.md")
|
||
self.assertTrue(
|
||
"simulator_playwright" in text
|
||
or ("薄 adapter" in text and "playwright.py" in text),
|
||
msg="RPA.md must describe thin adapter + *_playwright.py layering",
|
||
)
|
||
|
||
def test_rpa_md_covers_industry_simulator_portal(self) -> None:
|
||
text = self._read("development/RPA.md")
|
||
self.assertTrue(
|
||
any(marker in text for marker in ("jc2009", "行业仿真", "门户")),
|
||
msg="RPA.md must mention jc2009 / industry simulator / portal gate",
|
||
)
|
||
|
||
def test_rpa_md_covers_web_profile_headed_and_login_split(self) -> None:
|
||
text = self._read("development/RPA.md")
|
||
self.assertIn("0.2", text)
|
||
self.assertIn("profile_dir", text)
|
||
self.assertIn("POLICY-RPA-004", text)
|
||
self.assertIn("有头", text)
|
||
self.assertTrue(
|
||
"登录不全局强制" in text or "登录 ≠ Profile" in text or "登录≠Profile" in text,
|
||
msg="RPA.md §0.2 must separate site-login from Profile requirement",
|
||
)
|
||
|
||
def test_account_manager_standard_is_discoverable(self) -> None:
|
||
account_text = self._read("development/ACCOUNT_MANAGER.md")
|
||
for marker in (
|
||
"一账号一",
|
||
"profile_dir",
|
||
"lease_token",
|
||
"数据管理",
|
||
"account-manager.db",
|
||
"关闭浏览器并释放租约",
|
||
"两个以上账号",
|
||
):
|
||
self.assertIn(marker, account_text)
|
||
for rel in ("SKILL.md", "development/README.md", "development/RPA.md"):
|
||
self.assertIn("ACCOUNT_MANAGER.md", self._read(rel), msg=f"{rel} must link account standard")
|
||
|
||
def test_simulator_example_readme_covers_async_and_account_manager(self) -> None:
|
||
text = self._read("examples/simulator_browser_rpa/README.md")
|
||
self.assertIn("async", text.lower())
|
||
self.assertTrue(
|
||
"account-manager" in text.lower() or "pick-web" in text or "pick_web_account" in text,
|
||
msg="simulator_browser_rpa README must mention async and account-manager",
|
||
)
|
||
|
||
def test_development_md_documents_scaffold_and_git_cleanup(self) -> None:
|
||
text = self._read("development/DEVELOPMENT.md")
|
||
self.assertTrue(
|
||
"scaffold_skill" in text or "scaffold_skill.ps1" in text,
|
||
msg="DEVELOPMENT.md must document scaffold_skill",
|
||
)
|
||
self.assertTrue(
|
||
("Remove-Item" in text and ".git" in text)
|
||
or ("删除" in text and ".git" in text),
|
||
msg="DEVELOPMENT.md must document deleting .git",
|
||
)
|
||
|
||
def test_naming_md_copy_flow_documents_scaffold_or_git_cleanup(self) -> None:
|
||
text = self._read("development/NAMING.md")
|
||
self.assertTrue(
|
||
"scaffold" in text.lower() or ("清理" in text and ".git" in text),
|
||
msg="NAMING.md §9 must mention scaffold or Git cleanup",
|
||
)
|
||
|
||
def test_scaffold_ps1_exists(self) -> None:
|
||
path = os.path.join(get_skill_root(), "tools", "scaffold_skill.ps1")
|
||
self.assertTrue(os.path.isfile(path), msg="tools/scaffold_skill.ps1 must exist")
|
||
|
||
def test_actions_manifest_and_docs_exist(self) -> None:
|
||
root = get_skill_root()
|
||
self.assertTrue(os.path.isfile(os.path.join(root, "assets", "actions.json")))
|
||
self.assertTrue(os.path.isfile(os.path.join(root, "references", "ACTIONS.md")))
|
||
self.assertTrue(os.path.isfile(os.path.join(root, "references", "POLICY.md")))
|
||
self.assertTrue(os.path.isfile(os.path.join(root, "references", "CELL_PRESENTATION.md")))
|
||
self.assertTrue(os.path.isfile(os.path.join(root, "references", "WORK_ITEM_STATUS.md")))
|
||
self.assertTrue(
|
||
os.path.isfile(os.path.join(root, "assets", "schemas", "skill-actions.schema.json"))
|
||
)
|
||
self.assertTrue(
|
||
os.path.isfile(
|
||
os.path.join(root, "assets", "schemas", "skill-policy-snapshot.schema.json")
|
||
)
|
||
)
|
||
self.assertTrue(
|
||
os.path.isfile(
|
||
os.path.join(root, "assets", "schemas", "skill-cell-presentation.schema.json")
|
||
)
|
||
)
|
||
self.assertTrue(
|
||
os.path.isfile(
|
||
os.path.join(root, "assets", "schemas", "skill-grid-presentation.schema.json")
|
||
)
|
||
)
|
||
self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_actions_manifest.py")))
|
||
self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_data_management_contract.py")))
|
||
|
||
def test_tools_readme_documents_actions_scaffold(self) -> None:
|
||
text = self._read("tools/README.md")
|
||
self.assertIn("actions.json", text)
|
||
self.assertIn("your-skill-slug", text)
|
||
self.assertIn("your_skill_slug", text)
|
||
|
||
def test_development_md_documents_both_slug_placeholders(self) -> None:
|
||
text = self._read("development/DEVELOPMENT.md")
|
||
self.assertIn("your-skill-slug", text)
|
||
self.assertIn("your_skill_slug", text)
|
||
|
||
def test_schema_md_documents_data_management_contract(self) -> None:
|
||
text = self._read("references/SCHEMA.md")
|
||
for marker in (
|
||
"_jiangchang_tables",
|
||
"_jiangchang_columns",
|
||
"editable",
|
||
"options_json",
|
||
"demo_items",
|
||
"readonly",
|
||
"新增/编辑表单:字段说明与默认值(宿主支持)",
|
||
"表单与展示:稳定字段类型(宿主支持,须与 Action 对称)",
|
||
"datetime_unix_seconds",
|
||
"year_month",
|
||
"display_type: \"number\"",
|
||
"extra_column",
|
||
"{value} · {label}",
|
||
"DEFAULT",
|
||
"description",
|
||
"文件 / 图片上传",
|
||
"default_visible_columns_json",
|
||
"默认展示列",
|
||
"grid_help",
|
||
"表格说明 `grid_help`",
|
||
"表与表之间:禁止 SQLite 外键",
|
||
"禁止 SQLite 外键",
|
||
"跨表引用 lookup",
|
||
"\"multiple\": true",
|
||
"Lookup 多选",
|
||
"数据管理:列筛选与全局搜索",
|
||
"列筛选与全局搜索",
|
||
"searchable: 1",
|
||
"筛选栏控件",
|
||
"表增删改与退役",
|
||
"drop_deprecated_user_table",
|
||
"孤儿物理表",
|
||
"_jiangchang_grid_presentation",
|
||
):
|
||
self.assertIn(marker, text, msg=f"SCHEMA.md missing {marker!r}")
|
||
|
||
def test_data_sources_md_documents_mysql_and_declaration(self) -> None:
|
||
text = self._read("references/DATA_SOURCES.md")
|
||
for marker in (
|
||
"data-sources.json",
|
||
"MYSQL_DSN",
|
||
"6.9.28",
|
||
"resolve_backend",
|
||
"一个 `sourceId`",
|
||
"process-work-order",
|
||
"tableAdmin",
|
||
):
|
||
self.assertIn(marker, text, msg=f"DATA_SOURCES.md missing {marker!r}")
|
||
self.assertTrue(os.path.isfile(os.path.join(get_skill_root(), "assets/data-sources.example.json")))
|
||
self.assertTrue(
|
||
os.path.isfile(os.path.join(get_skill_root(), "assets/examples/data-sources.mysql.example.json"))
|
||
)
|
||
env_example = self._read(".env.example")
|
||
self.assertIn("MYSQL_DSN", env_example)
|
||
checklist = self._read("development/CHECKLIST.md")
|
||
self.assertIn("A6-16", checklist)
|
||
self.assertIn("DATA_SOURCES.md", checklist)
|
||
|
||
def test_actions_md_documents_form_schema_split(self) -> None:
|
||
text = self._read("references/ACTIONS.md")
|
||
for marker in (
|
||
"inputSchema 与参数表单",
|
||
"表单字段类型对照(须与 CRUD 对称)",
|
||
"跨表引用:Lookup",
|
||
'"display_type": "lookup"',
|
||
"extra_column",
|
||
"multiple",
|
||
"skill_cli",
|
||
"paged_table",
|
||
"page_size",
|
||
"暂无可选项",
|
||
"local_sql",
|
||
"external_api",
|
||
"items_path",
|
||
"数据管理新增/编辑",
|
||
"_jiangchang_columns.description",
|
||
"datetime_unix_seconds",
|
||
"year_month",
|
||
"SCHEMA.md",
|
||
"字段条件显隐",
|
||
"visibleWhen",
|
||
"grid_help",
|
||
"列筛选",
|
||
):
|
||
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
|
||
self.assertTrue(
|
||
os.path.isfile(
|
||
os.path.join(get_skill_root(), "assets/examples/lookup-skill-cli.action.example.json")
|
||
),
|
||
msg="lookup-skill-cli.action.example.json missing",
|
||
)
|
||
self.assertTrue(
|
||
os.path.isfile(
|
||
os.path.join(get_skill_root(), "assets/examples/lookup-paged-table.action.example.json")
|
||
),
|
||
msg="lookup-paged-table.action.example.json missing",
|
||
)
|
||
self.assertTrue(
|
||
os.path.isfile(
|
||
os.path.join(get_skill_root(), "assets/examples/enum-names-action.example.json")
|
||
),
|
||
msg="enum-names-action.example.json missing",
|
||
)
|
||
checklist = self._read("development/CHECKLIST.md")
|
||
self.assertIn("skill_cli", checklist)
|
||
self.assertIn("A4-12", checklist)
|
||
self.assertIn("A4-13", checklist)
|
||
self.assertIn("enumNames", checklist)
|
||
self.assertIn("paged_table", checklist)
|
||
account_mgr = self._read("development/ACCOUNT_MANAGER.md")
|
||
self.assertIn("skill_cli", account_mgr)
|
||
schema = self._read("references/SCHEMA.md")
|
||
self.assertIn("skill_cli", schema)
|
||
self.assertIn("enumNames", schema)
|
||
schema_json = self._read("assets/schemas/skill-actions.schema.json")
|
||
self.assertIn("skill_cli", schema_json)
|
||
self.assertIn("paged_table", schema_json)
|
||
self.assertIn("enumNames", schema_json)
|
||
self.assertIn("page_size", schema_json)
|
||
self.assertIn('"skill"', schema_json)
|
||
|
||
def test_actions_md_disambiguates_visible_when_from_policy(self) -> None:
|
||
actions = self._read("references/ACTIONS.md")
|
||
policy = self._read("references/POLICY.md")
|
||
self.assertIn("字段条件显隐", actions)
|
||
self.assertIn("inputSchema.properties", actions)
|
||
self.assertTrue(
|
||
"POLICY.md" in actions and "L3" in actions,
|
||
msg="ACTIONS.md must point readers away from Policy L3 visibleWhen",
|
||
)
|
||
self.assertTrue(
|
||
"inputSchema" in policy and "ACTIONS.md" in policy,
|
||
msg="POLICY.md must disambiguate L3 visibleWhen from Action form visibleWhen",
|
||
)
|
||
|
||
def test_visible_when_example_is_listed(self) -> None:
|
||
text = self._read("assets/examples/README.md")
|
||
self.assertIn("visible-when-action.example.json", text)
|
||
self.assertIn("bind-visible-when-row.action.example.json", text)
|
||
self.assertIn("selection-mode-optional.action.example.json", text)
|
||
self.assertIn("skill-cell-presentation.example.json", text)
|
||
self.assertIn("skill-grid-presentation.example.json", text)
|
||
checklist = self._read("development/CHECKLIST.md")
|
||
self.assertIn("A4-7", checklist)
|
||
self.assertIn("visibleWhen", checklist)
|
||
self.assertIn("A4-8", checklist)
|
||
self.assertIn("revealInForm", checklist)
|
||
self.assertIn("A4-9", checklist)
|
||
self.assertIn("selectionMode", checklist)
|
||
self.assertIn("定时任务配置页", checklist)
|
||
self.assertIn("A4-10", checklist)
|
||
self.assertIn("A4-11", checklist)
|
||
self.assertIn("bind.visibleWhen", checklist)
|
||
self.assertIn("用户可见文案", checklist)
|
||
runtime = self._read("development/SKILL_ACTION_RUNTIME.md")
|
||
self.assertIn("visibleWhen", runtime)
|
||
self.assertIn("revealInForm", runtime)
|
||
self.assertIn("selectionMode", runtime)
|
||
self.assertIn("bind.visibleWhen", runtime)
|
||
self.assertIn("用户可见文案", runtime)
|
||
self.assertIn("无表勾选入口按无勾选分支", runtime)
|
||
actions = self._read("references/ACTIONS.md")
|
||
self.assertIn("用户可见文案", actions)
|
||
self.assertIn("双受众", actions)
|
||
self.assertIn("行菜单显隐", actions)
|
||
self.assertIn("bind.visibleWhen", actions)
|
||
self.assertIn("无表勾选入口", actions)
|
||
self.assertIn("编号列表", actions)
|
||
|
||
def test_guarded_docs_do_not_reference_amazon_implementation(self) -> None:
|
||
forbidden = (
|
||
"download-settlement-report-amazon",
|
||
"amazon_sim",
|
||
"#amz-email",
|
||
)
|
||
guarded_docs = (
|
||
"development/RPA.md",
|
||
"development/ADAPTER.md",
|
||
"development/DEVELOPMENT.md",
|
||
"development/CONFIG.md",
|
||
"development/TESTING.md",
|
||
"references/SCHEMA.md",
|
||
"references/ACTIONS.md",
|
||
"tools/README.md",
|
||
"examples/README.md",
|
||
"examples/simulator_browser_rpa/README.md",
|
||
)
|
||
for rel in guarded_docs:
|
||
text = self._read(rel)
|
||
for fragment in forbidden:
|
||
self.assertNotIn(
|
||
fragment,
|
||
text,
|
||
msg=f"{rel} must not reference Amazon implementation {fragment!r}",
|
||
)
|
||
|
||
def test_adapter_md_covers_four_tiers_and_config_get(self) -> None:
|
||
text = self._read("development/ADAPTER.md")
|
||
for marker in (
|
||
"mock",
|
||
"simulator_rpa",
|
||
"real_" + "api",
|
||
"real_" + "rpa",
|
||
"config.get",
|
||
"sibling_bridge",
|
||
):
|
||
self.assertIn(marker, text, msg=f"ADAPTER.md missing {marker!r}")
|
||
self.assertNotIn("ALLOW_" + "REAL_" + "API", text)
|
||
self.assertNotIn("ALLOW_WRITE_ACTIONS", text)
|
||
|
||
def test_cli_md_requires_shared_python_not_uv_run(self) -> None:
|
||
text = self._read("references/CLI.md")
|
||
self.assertIn("python-runtime", text)
|
||
self.assertIn("python {baseDir}/scripts/main.py", text)
|
||
self.assertNotIn("JIANGCHANG_PYTHON_EXE", text)
|
||
self.assertIn("禁止", text)
|
||
self.assertIn("uv run python", text)
|
||
self.assertNotRegex(
|
||
text,
|
||
r"(?m)^(?!\*\*禁止\*\*).*\*\*建议\*\*.*uv run python",
|
||
msg="CLI.md must not recommend uv run for skill scripts",
|
||
)
|
||
|
||
def test_cli_md_has_agent_routing_tables(self) -> None:
|
||
text = self._read("references/CLI.md")
|
||
self.assertIn("短 CLI 白名单", text)
|
||
self.assertIn("Action 强制黑名单", text)
|
||
self.assertIn("run_skill_action", text)
|
||
|
||
def test_skill_md_requires_shared_python_not_uv_run(self) -> None:
|
||
text = self._read("SKILL.md")
|
||
self.assertIn("python {baseDir}/scripts/main.py", text)
|
||
self.assertNotIn("JIANGCHANG_PYTHON_EXE", text)
|
||
self.assertIn("uv run python", text)
|
||
self.assertIn("禁止", text)
|
||
self.assertIn("run_skill_action", text)
|
||
self.assertIn("executionProfile", text)
|
||
self.assertTrue(
|
||
"任务中心" in text and "无关" in text,
|
||
msg="SKILL.md must state Task Center is independent of entry point",
|
||
)
|
||
|
||
def test_skill_md_has_intent_routing_table(self) -> None:
|
||
text = self._read("SKILL.md")
|
||
self.assertIn("用户意图路由表", text)
|
||
self.assertIn("用户意图", text)
|
||
self.assertIn("准备 vs 执行", text)
|
||
|
||
def test_skill_md_how_to_forbids_cli_paste(self) -> None:
|
||
text = self._read("SKILL.md")
|
||
self.assertIn("回答「怎么用", text)
|
||
self.assertIn("禁止", text)
|
||
self.assertTrue(
|
||
"粘贴" in text and "python" in text,
|
||
msg="SKILL.md must forbid pasting python CLI when answering how-to",
|
||
)
|
||
self.assertTrue(
|
||
"dir" in text or "列" in text,
|
||
msg="SKILL.md must discourage directory probing for how-to answers",
|
||
)
|
||
|
||
def test_actions_md_has_async_side_effect_checklist(self) -> None:
|
||
text = self._read("references/ACTIONS.md")
|
||
self.assertIn("最小组合", text)
|
||
self.assertIn("confirmation.message", text)
|
||
self.assertIn("hoverHint", text)
|
||
self.assertIn("用户意图路由表", text)
|
||
|
||
def test_actions_md_states_profile_not_entry_for_task_center(self) -> None:
|
||
text = self._read("references/ACTIONS.md")
|
||
self.assertIn("executionProfile", text)
|
||
self.assertIn("任务中心", text)
|
||
self.assertTrue(
|
||
"无关" in text or "orthogonal" in text.lower() or "正交" in text,
|
||
msg="ACTIONS.md must keep placements orthogonal to Task Center",
|
||
)
|
||
|
||
def test_testing_md_mentions_pytest_txt_collection_guard(self) -> None:
|
||
text = self._read("development/TESTING.md")
|
||
self.assertIn(".txt", text)
|
||
self.assertIn("pytest.ini", text)
|
||
|
||
def test_docs_do_not_claim_1_0_11_as_current_standard(self) -> None:
|
||
for rel in (
|
||
"SKILL.md",
|
||
"development/RUNTIME.md",
|
||
"development/RPA.md",
|
||
"development/CONFIG.md",
|
||
"development/TESTING.md",
|
||
):
|
||
text = self._read(rel)
|
||
self.assertNotIn(
|
||
">=1.0.11",
|
||
text.replace(" ", ""),
|
||
msg=f"{rel} still claims 1.0.11 as current minimum",
|
||
)
|
||
|
||
def test_scripts_has_no_legacy_example_artifacts(self) -> None:
|
||
scripts_dir = os.path.join(get_skill_root(), "scripts")
|
||
service_dir = os.path.join(scripts_dir, "service")
|
||
self.assertFalse(
|
||
os.path.isfile(os.path.join(service_dir, "example_browser_rpa.py")),
|
||
"scripts/service/example_browser_rpa.py must not exist",
|
||
)
|
||
self.assertFalse(
|
||
os.path.isdir(os.path.join(service_dir, "example_adapter")),
|
||
"scripts/service/example_adapter/ must not exist",
|
||
)
|
||
|
||
def test_gitignore_covers_python_runtime_artifacts(self) -> None:
|
||
text = self._read(".gitignore")
|
||
self.assertIn("__pycache__", text)
|
||
self.assertIn(".pytest_cache", text)
|
||
self.assertTrue(
|
||
"py[cod]" in text or ".pyc" in text,
|
||
msg=".gitignore must ignore Python bytecode (e.g. *.py[cod])",
|
||
)
|
||
|
||
def test_scripts_runtime_artifacts_not_tracked_by_git(self) -> None:
|
||
import subprocess
|
||
|
||
try:
|
||
out = subprocess.run(
|
||
["git", "ls-files", "scripts"],
|
||
cwd=get_skill_root(),
|
||
capture_output=True,
|
||
text=True,
|
||
check=True,
|
||
)
|
||
except (FileNotFoundError, subprocess.CalledProcessError):
|
||
self.skipTest("git not available or not a git repo")
|
||
tracked = out.stdout.splitlines()
|
||
for rel in tracked:
|
||
lowered = rel.replace("\\", "/").lower()
|
||
self.assertNotIn("__pycache__", lowered, msg=f"tracked cache path: {rel}")
|
||
self.assertNotIn(".pytest_cache", lowered, msg=f"tracked cache path: {rel}")
|
||
self.assertFalse(lowered.endswith(".pyc"), msg=f"tracked .pyc file: {rel}")
|
||
|
||
def test_examples_top_level_modes_present(self) -> None:
|
||
examples_dir = os.path.join(get_skill_root(), "examples")
|
||
for mode in (
|
||
"real_" + "api",
|
||
"real_browser_rpa",
|
||
"simulator_api",
|
||
"simulator_browser_rpa",
|
||
"real_desktop_rpa",
|
||
"mobile_rpa",
|
||
):
|
||
self.assertTrue(
|
||
os.path.isdir(os.path.join(examples_dir, mode)),
|
||
msg=f"examples/{mode}/ must exist",
|
||
)
|
||
|
||
def test_three_surface_locator_standards_are_present(self) -> None:
|
||
required = {
|
||
"development/RPA_ELEMENT_LOCATING.md": (
|
||
"元素清单",
|
||
"expected_count",
|
||
"ERROR:RESULT_UNCONFIRMED",
|
||
"AI 编程约束",
|
||
),
|
||
"development/RPA_BROWSER.md": (
|
||
"Pick Locator",
|
||
"iframe",
|
||
"Shadow DOM",
|
||
"禁止模式",
|
||
),
|
||
"development/RPA_DESKTOP.md": (
|
||
"uiautomation",
|
||
"add-friend-wecom",
|
||
"AutomationId",
|
||
"probe-ui",
|
||
),
|
||
"development/RPA_MOBILE.md": (
|
||
"Appium Inspector",
|
||
"resourceId",
|
||
"Native/WebView",
|
||
"尚无团队真实成功案例",
|
||
),
|
||
}
|
||
for rel, markers in required.items():
|
||
text = self._read(rel)
|
||
for marker in markers:
|
||
self.assertIn(marker, text, msg=f"{rel} missing {marker!r}")
|
||
|
||
def test_desktop_and_mobile_examples_state_validation_level(self) -> None:
|
||
desktop = self._read("examples/real_desktop_rpa/README.md")
|
||
self.assertIn("技术路线已真实验证", desktop)
|
||
self.assertIn("add-friend-wecom", desktop)
|
||
self.assertIn("dry-run", desktop)
|
||
mobile = self._read("examples/mobile_rpa/README.md")
|
||
self.assertIn("不是可复制实现", mobile)
|
||
self.assertIn("不得宣称已跑通", mobile)
|
||
|
||
def test_examples_are_not_full_skill_repos(self) -> None:
|
||
examples_dir = os.path.join(get_skill_root(), "examples")
|
||
for mode in os.listdir(examples_dir):
|
||
mode_path = os.path.join(examples_dir, mode)
|
||
if not os.path.isdir(mode_path):
|
||
continue
|
||
self.assertFalse(
|
||
os.path.isfile(os.path.join(mode_path, "SKILL.md")),
|
||
msg=f"examples/{mode}/SKILL.md must not exist",
|
||
)
|
||
self.assertFalse(
|
||
os.path.isdir(os.path.join(mode_path, "references")),
|
||
msg=f"examples/{mode}/references/ must not exist",
|
||
)
|
||
self.assertFalse(
|
||
os.path.isdir(os.path.join(mode_path, "development")),
|
||
msg=f"examples/{mode}/development/ must not exist",
|
||
)
|
||
self.assertFalse(
|
||
os.path.isdir(os.path.join(mode_path, ".github")),
|
||
msg=f"examples/{mode}/.github/ must not exist",
|
||
)
|
||
|
||
def test_examples_readme_states_copy_boundary(self) -> None:
|
||
text = self._read("examples/README.md")
|
||
self.assertIn("不是完整 skill 仓库", text)
|
||
self.assertIn("核心实现案例", text)
|
||
self.assertTrue(
|
||
"不要整包照搬" in text or "不要整包" in text,
|
||
msg="examples/README.md must warn against copying whole example tree",
|
||
)
|
||
self.assertTrue(
|
||
"scripts" in text and ("复制" in text or "copy map" in text.lower()),
|
||
msg="examples/README.md must describe copying into scripts/",
|
||
)
|
||
|
||
def test_placeholder_api_examples_are_not_implementation_references(self) -> None:
|
||
for rel in (f"examples/{'real_' + 'api'}/README.md", "examples/simulator_api/README.md"):
|
||
text = self._read(rel)
|
||
self.assertTrue(
|
||
"占位" in text or "规划" in text,
|
||
msg=f"{rel} must state placeholder/planned status",
|
||
)
|
||
self.assertTrue(
|
||
"尚未沉淀" in text or "不可作为实现参考" in text or "不可" in text,
|
||
msg=f"{rel} must warn not to use as implementation reference",
|
||
)
|
||
|
||
def test_docs_do_not_reference_scripts_legacy_examples(self) -> None:
|
||
forbidden_fragments = (
|
||
"scripts/service/example_browser_rpa",
|
||
"scripts/service/example_adapter",
|
||
"example_browser_rpa.py",
|
||
"example_adapter/",
|
||
)
|
||
doc_paths = []
|
||
for root, _dirs, files in os.walk(get_skill_root()):
|
||
rel_root = os.path.relpath(root, get_skill_root())
|
||
if rel_root.startswith(".git"):
|
||
continue
|
||
for name in files:
|
||
if name.endswith(".md"):
|
||
doc_paths.append(os.path.join(rel_root, name).replace("\\", "/"))
|
||
for rel in doc_paths:
|
||
text = self._read(rel)
|
||
for fragment in forbidden_fragments:
|
||
self.assertNotIn(
|
||
fragment,
|
||
text,
|
||
msg=f"{rel} must not reference legacy scripts example path {fragment!r}",
|
||
)
|
||
|
||
|
||
class TestPytestIniCollection(unittest.TestCase):
|
||
def test_pytest_ini_exists_and_limits_python_files(self) -> None:
|
||
ini_path = os.path.join(get_skill_root(), "pytest.ini")
|
||
self.assertTrue(os.path.isfile(ini_path), "pytest.ini must exist at repo root")
|
||
parser = configparser.ConfigParser()
|
||
parser.read(ini_path, encoding="utf-8")
|
||
python_files = parser.get("pytest", "python_files", fallback="")
|
||
self.assertIn("test_*.py", python_files)
|
||
self.assertIn("*_test.py", python_files)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|