chore: auto release commit (2026-09-14 22:22:49)
All checks were successful
技能自动化发布 / release (push) Successful in 7s

This commit is contained in:
chenfuqiang
2026-09-14 22:22:49 +08:00
parent 6998fb3bd7
commit 3a80db8a7e
155 changed files with 1365 additions and 21023 deletions

10
tests/.gitignore vendored
View File

@@ -1,10 +0,0 @@
# 测试运行产物与本地密钥,勿提交
__pycache__/
*.py[cod]
.pytest_cache/
desktop/artifacts/
integration/artifacts/
*.log
.env
.env.*
!.env.sample

View File

@@ -1,141 +0,0 @@
# -*- coding: utf-8 -*-
"""
测试公共支撑。
1. 将 scripts/ 加入 sys.path,便于 `from cli…` / `from db…` / `from util…` 等导入。
2. `IsolatedDataRoot`:设置 `JIANGCHANG_DATA_ROOT` 与 `JIANGCHANG_USER_ID`,
退出时恢复原值并删除临时目录,避免污染真实用户数据目录。
"""
from __future__ import annotations
import os
import shutil
import sys
import tempfile
from typing import Optional
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
_SKILL_ROOT = os.path.abspath(os.path.join(_TESTS_DIR, ".."))
_SCRIPTS_DIR = os.path.join(_SKILL_ROOT, "scripts")
if _SCRIPTS_DIR not in sys.path:
sys.path.insert(0, _SCRIPTS_DIR)
_ISOLATION_KEYS = (
"JIANGCHANG_DATA_ROOT",
"JIANGCHANG_USER_ID",
)
def get_scripts_dir() -> str:
return _SCRIPTS_DIR
def get_skill_root() -> str:
return _SKILL_ROOT
def platform_kit_version_patch(version: str = "1.2.11"):
"""Mock installed jiangchang-platform-kit version for health/diagnostics tests."""
from unittest.mock import patch
return patch(
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
return_value=version,
)
class IsolatedDataRoot:
"""
在独立临时目录下模拟数据根,避免污染本机默认数据目录。
进入上下文时:
- JIANGCHANG_DATA_ROOT -> tempfile.mkdtemp(prefix='skill-test-')
- JIANGCHANG_USER_ID -> _test(与业务默认用户区分)
退出时:两个变量均恢复为进入前状态(若原先未设置则 pop),并删除临时目录。
"""
def __init__(self, user_id: str = "_test") -> None:
self._tmp: Optional[str] = None
self._old: dict[str, Optional[str]] = {}
self._user_id = user_id
def __enter__(self) -> str:
self._tmp = tempfile.mkdtemp(prefix="skill-test-")
for k in _ISOLATION_KEYS:
self._old[k] = os.environ.get(k)
os.environ["JIANGCHANG_DATA_ROOT"] = self._tmp
os.environ["JIANGCHANG_USER_ID"] = self._user_id
return self._tmp
def __exit__(self, *exc_info: object) -> None:
for k in _ISOLATION_KEYS:
v = self._old.get(k)
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
if self._tmp and os.path.isdir(self._tmp):
shutil.rmtree(self._tmp, ignore_errors=True)
self._tmp = None
DEMO_ITEMS_TABLE = "demo_items"
DEMO_ITEMS_DDL = """
CREATE TABLE IF NOT EXISTS demo_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
)
"""
DEMO_ITEMS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
{"column_name": "id", "display_name": "编号", "description": "主键", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "title", "display_name": "标题", "description": "标题", "visible": 1, "searchable": 1, "editable": 1},
{"column_name": "status", "display_name": "状态", "description": "状态", "visible": 1, "searchable": 1, "editable": 1},
{
"column_name": "created_at",
"display_name": "创建时间",
"description": "记录创建时间(Unix 秒级时间戳,数据库自动生成)",
"visible": 1,
"searchable": 0,
"editable": 0,
"display_type": "datetime_unix_seconds",
},
{
"column_name": "updated_at",
"display_name": "更新时间",
"description": "记录最后更新时间(Unix 秒级时间戳,自动维护)",
"visible": 1,
"searchable": 0,
"editable": 0,
"display_type": "datetime_unix_seconds",
},
)
def seed_demo_items(cursor, *, readonly: int = 0) -> None:
"""测试夹具:创建 demo_items 物理表、时间戳 trigger 与展示元数据。"""
from db.display_metadata import seed_column_metadata_batch, upsert_table_metadata
from db.timestamp_columns import init_updated_at_trigger
cursor.execute(DEMO_ITEMS_DDL)
init_updated_at_trigger(cursor, DEMO_ITEMS_TABLE)
upsert_table_metadata(
cursor,
table_name=DEMO_ITEMS_TABLE,
display_name="示例记录",
description="模板测试用业务表示例,不是默认交付表",
visible=1,
readonly=readonly,
)
seed_column_metadata_batch(
cursor,
table_name=DEMO_ITEMS_TABLE,
columns=DEMO_ITEMS_COLUMN_METADATA,
valid_column_names=[str(column["column_name"]) for column in DEMO_ITEMS_COLUMN_METADATA],
)

View File

@@ -1,118 +0,0 @@
# -*- coding: utf-8 -*-
"""
企业技能对接外部系统(ERP / TMS / WMS / 船司 / 报关 / 邮件 / 浏览器 / LLM / RPA)时,
测试侧共用的 **profile / 禁止真实外呼** 工具。
- 仅标准库 + jiangchang_skill_core.config;不发起 HTTP;不启动浏览器。
- 真实 API / RPA 契约测试请放在 ``tests/integration/*.sample``,并显式设置 ``OPENCLAW_TEST_TARGET``。
"""
from __future__ import annotations
from typing import Any, Final
from jiangchang_skill_core import config
ALLOWED_TEST_TARGETS: Final[frozenset[str]] = frozenset(
{
"unit",
"mock",
"simulator_api",
"simulator_rpa",
"real_api",
"real_rpa",
}
)
def _raw_test_target_env() -> str:
return (config.get("OPENCLAW_TEST_TARGET") or "").strip()
def get_test_target() -> str:
"""
读取 ``OPENCLAW_TEST_TARGET``(经 ``config.get`` 三级合并)。
未设置时返回 ``unit``。非法取值抛出 ``ValueError``,便于尽早发现拼写错误。
"""
raw = _raw_test_target_env()
if not raw:
return "unit"
if raw not in ALLOWED_TEST_TARGETS:
raise ValueError(
f"Invalid test target {raw!r}. Allowed: {sorted(ALLOWED_TEST_TARGETS)}"
)
return raw
def real_access_allowed(profile: str) -> bool:
"""
判断当前 ``OPENCLAW_TEST_TARGET`` 是否允许运行 ``profile`` 档位测试。
"""
target = get_test_target()
if profile in ("mock", "simulator_api", "simulator_rpa"):
return True
if profile == "real_api":
return target == "real_api"
if profile == "real_rpa":
return target == "real_rpa"
raise ValueError(f"unknown profile for real_access_allowed: {profile!r}")
def should_skip_profile(profile: str, write: bool = False) -> tuple[bool, str]:
"""
返回 ``(should_skip, reason)``。
策略摘要:
- ``mock``:永不跳过(内存 / FakeAdapter,无外呼)。
- ``simulator_api`` / ``simulator_rpa``:仅当 ``get_test_target()`` 与 profile 一致时不跳过。
- ``real_api`` / ``real_rpa``:仅当 ``get_test_target()`` 与 profile 一致时不跳过。
- ``write`` 参数保留以兼容 integration 样例签名;档位一致即允许写操作测试。
"""
_ = write
target = get_test_target()
if profile == "mock":
return (False, "")
if profile in ("simulator_api", "simulator_rpa", "real_api", "real_rpa"):
if target != profile:
return (True, f"OPENCLAW_TEST_TARGET must be {profile} (got {target!r})")
return (False, "")
raise ValueError(f"unknown profile: {profile!r}")
class FakeAdapter:
"""
模拟外部系统 adapter,用于单元测试中的超时 / 鉴权 / 畸形响应等分支。
``mode``:``success`` | ``timeout`` | ``invalid_response`` | ``unauthorized``
"""
def __init__(self, mode: str = "success") -> None:
if mode not in ("success", "timeout", "invalid_response", "unauthorized"):
raise ValueError(f"unsupported FakeAdapter mode: {mode!r}")
self._mode = mode
def call(self, payload: Any) -> Any:
if self._mode == "success":
return {"success": True, "data": {"echo": payload}}
if self._mode == "timeout":
raise TimeoutError("simulated upstream timeout")
if self._mode == "invalid_response":
return object() # 非 dict,模拟无法解析的响应体
if self._mode == "unauthorized":
raise PermissionError("simulated 401/403")
raise RuntimeError("unreachable")
def assert_no_real_network_env() -> None:
"""
断言默认必跑套件未误设真实外联档位(``real_api`` / ``real_rpa``)。
若需在受控环境跑真实集成,请显式设置 ``OPENCLAW_TEST_TARGET`` 并使用 ``tests/integration/*.sample``。
"""
target = get_test_target()
assert target not in ("real_api", "real_rpa"), (
f"OPENCLAW_TEST_TARGET must not be {target!r} in default unit tests"
)

View File

@@ -1,63 +0,0 @@
# 可选桌面 E2E(pytest)
本目录用于**匠厂桌面 + jiangchang_desktop_sdk** 的端到端冒烟,**不是** `python tests/run_tests.py` 默认路径的一部分。
## 依赖
- `pytest`
- `jiangchang_desktop_sdk`(由 `conftest.py` 按多级路径尝试加入 `sys.path`,见该文件说明)
- 匠厂桌面已安装或开发态可启动,且 `jiangchang://` 协议可用
## 为什么不默认跑
- 需要真实宿主环境,无法在纯 CI/无头环境稳定复现。
- `unittest` 的 `tests/run_tests.py` **只发现** `tests/` 根目录下 `test_*.py`,**不递归**本目录。
- 示例文件为 **`test_desktop_smoke.py.sample`**,避免被误收集。
## 如何启用
1. 将 `test_desktop_smoke.py.sample` **复制或重命名**为 `test_desktop_smoke.py`。
2. 按你的技能名称、路由关键词、期望输出修改 `ask` 与断言。
3. 运行:
```powershell
pytest tests/desktop/test_desktop_smoke.py -v -s
```
失败时,`conftest.py` 中的 `failure_snapshot_dir` fixture 会将现场目录指向 `tests/desktop/artifacts/`(需自行避免将大文件提交进仓库)。
## 与后端测试的关系
- **后端 / CLI / DB**:请使用仓库根目录下 `python tests/run_tests.py`(见 `tests/README.md`)。
- **桌面 E2E**:仅在本目录用 **pytest** 维护,二者互补、互不替代。
若仅需验证 **service 业务逻辑**、契约与 golden 回归,**不要**写 desktop E2E;desktop 只用于验证**宿主 UI、路由、`jiangchang://` 协议、真实用户交互链路**等与桌面宿主强相关的行为。
## 模板选择指南
| 场景 | 用哪个模板 |
|---|---|
| 纯问答 skill(不上传附件、不跑长流程) | `test_desktop_smoke.py.sample` |
| 需要上传附件 / RPA 长流程 / Agent 多轮工具调用 | `test_desktop_smoke_with_attachment.py.sample` |
录屏入口都用 `record_screencast.py.sample`,里面 `TEST_FILE_NAME` 常量切换测试文件。
## 已知 SDK 坑(来自 disburse-payroll-icbc 实战)
1. **`client.send_file()` 在 v2.0.17+ 抛 NotImplementedError**——附件场景必须用
`jiangchang_desktop_sdk.e2e_helpers.drop_file_into_composer`(DataTransfer 拖拽)。
2. **`client.ask()` 内部 `user_msg_index<0` 死循环**——如果匠厂启动慢 / gateway 握手有
延迟,导致 15s 内没抓到 user 节点,ask() 会锁死后续 900s 等待。建议长流程 / 附件场景
绕开 ask(),用 `jiangchang_desktop_sdk.e2e_helpers` 里的 `send_prompt_via_composer`
+ `wait_for_user_message` + `wait_for_agent_complete` 自己控时。
3. **字幕关键词建议用 ASCII**——虽然 platform-kit 已修了 subprocess 编码乱码,
保险起见字幕脚本里关键词用 `step: 1/` 这种 ASCII 锚而非中文短语。
4. **`client.bring_to_front()` 已升级**——v?? 起会 ctypes ShowWindow(SW_MAXIMIZE) +
SetForegroundWindow,能真正铺满桌面。dev 模式下也能用(不依赖 jiangchang:// 协议)。
## 录屏新能力(platform-kit v??)
- `run_screencast(..., activate_window=True)` 自动激活+最大化匠厂窗口
- `run_screencast(..., monitor_index=1)` 多显示器场景显式录主屏
- 字幕脚本支持三元组 `(keyword, text, duration)` 给个别字幕指定独立时长,
覆盖长等待场景中间没日志输出的"字幕真空"

View File

@@ -1,88 +0,0 @@
# -*- coding: utf-8 -*-
"""
可选桌面 E2E:pytest 收集时自动加载,负责把 jiangchang_desktop_sdk 加入 sys.path。
查找顺序:
1. 已 pip install 则直接 import;
2. 环境变量 JIANGCHANG_KIT_ROOT → sdk/jiangchang-desktop-sdk/src(旧版布局);
3. 环境变量 JIANGCHANG_KIT_ROOT → src(新版 platform-kit 布局);
4. 自本文件上溯:skill 根 → workspace 根 → OpenClaw 根,拼路径;
5. 兜底 D:\\AI\\jiangchang-platform-kit\\src(若存在);
6. 都找不到则 pytest.exit 给出可操作的排障说明。
"""
from __future__ import annotations
import importlib.util
import os
import sys
import pytest
def _bootstrap_jiangchang_desktop_sdk() -> None:
if importlib.util.find_spec("jiangchang_desktop_sdk") is not None:
return
env_kit_root = (os.environ.get("JIANGCHANG_KIT_ROOT") or "").strip()
here = os.path.dirname(os.path.abspath(__file__))
skill_root = os.path.abspath(os.path.join(here, "..", ".."))
workspace_root = os.path.abspath(os.path.join(skill_root, ".."))
open_claw_root = os.path.abspath(os.path.join(workspace_root, ".."))
candidates = []
if env_kit_root:
candidates.append(os.path.join(env_kit_root, "sdk", "jiangchang-desktop-sdk", "src"))
candidates.append(os.path.join(env_kit_root, "src"))
candidates.append(
os.path.join(workspace_root, "jiangchang-platform-kit", "sdk", "jiangchang-desktop-sdk", "src")
)
candidates.append(
os.path.join(open_claw_root, "jiangchang-platform-kit", "sdk", "jiangchang-desktop-sdk", "src")
)
candidates.append(
os.path.join(workspace_root, "jiangchang-platform-kit", "src")
)
candidates.append(
os.path.join(open_claw_root, "jiangchang-platform-kit", "src")
)
default_kit_src = r"D:\AI\jiangchang-platform-kit\src"
if os.path.isdir(default_kit_src):
candidates.append(default_kit_src)
tried = []
for cand in candidates:
cand_abs = os.path.abspath(cand)
tried.append(cand_abs)
if os.path.isdir(cand_abs) and os.path.isdir(os.path.join(cand_abs, "jiangchang_desktop_sdk")):
if cand_abs not in sys.path:
sys.path.insert(0, cand_abs)
return
pytest.exit(
"无法定位 jiangchang_desktop_sdk。请任选一种方式解决:\n"
" 1) pip install -e <path-to>/jiangchang-platform-kit\n"
" 2) 设置环境变量 JIANGCHANG_KIT_ROOT=<path-to>/jiangchang-platform-kit\n"
" (SDK 新版位置:<root>\\src;旧版:<root>\\sdk\\jiangchang-desktop-sdk\\src)\n"
" 3) 将 jiangchang-platform-kit 与本仓库放在同一 workspace 父级可推断的位置\n"
"已尝试的候选路径:\n - " + "\n - ".join(tried),
returncode=4,
)
_bootstrap_jiangchang_desktop_sdk()
_ARTIFACT_DIR = os.path.join(os.path.dirname(__file__), "artifacts")
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
setattr(item, f"rep_{rep.when}", rep)
@pytest.fixture
def failure_snapshot_dir() -> str:
os.makedirs(_ARTIFACT_DIR, exist_ok=True)
return _ARTIFACT_DIR

View File

@@ -1,116 +0,0 @@
# -*- coding: utf-8 -*-
"""
Screencast 录制入口(模板)。
启用方式:
1. 复制为 record_screencast.py
2. 修改 SKILL_SLUG 和 SUBTITLE_SCRIPT
3. 决定 test_file 指向哪个测试(默认 test_desktop_smoke.py;
有附件场景的用 test_desktop_smoke_with_attachment.py)
4. 运行:python tests/desktop/record_screencast.py
依赖:
1. pip install mss
2. 系统已安装 ffmpeg 并在 PATH 中
3. jiangchang-platform-kit 可推断路径(见 _bootstrap 逻辑)
4. MEDIA_ASSETS_ROOT 环境变量指向 media-assets 仓库(或使用默认路径)
平台 kit v?? 之后 run_screencast 自动激活并最大化匠厂窗口、支持指定显示器、
字幕支持每条独立时长。本模板已经利用这些能力,新 skill 复制后无需再内嵌
ctypes 激活代码。
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
def _bootstrap_screencast() -> None:
"""将 jiangchang-platform-kit/tools 加入 sys.path,使 screencast 包可导入。"""
here = Path(__file__).resolve()
skill_root = here.parents[2]
workspace_root = skill_root.parent
open_claw_root = workspace_root.parent
candidates = [
workspace_root / "jiangchang-platform-kit" / "tools",
open_claw_root / "jiangchang-platform-kit" / "tools",
Path(r"D:\AI\jiangchang-platform-kit") / "tools",
]
env_kit = os.environ.get("JIANGCHANG_KIT_ROOT", "")
if env_kit:
candidates.insert(0, Path(env_kit) / "tools")
for cand in candidates:
if (cand / "screencast" / "__init__.py").exists():
if str(cand) not in sys.path:
sys.path.insert(0, str(cand))
return
raise RuntimeError(
"找不到 jiangchang-platform-kit/tools/screencast。\n"
"请设置 JIANGCHANG_KIT_ROOT 环境变量,或将 platform-kit 放在 workspace "
"同级目录。"
)
_bootstrap_screencast()
from screencast import run_screencast # noqa: E402
# ============================================================
# ↓↓↓ 复制后只需修改下面这些 ↓↓↓
# ============================================================
SKILL_SLUG = "verify-invoice-default" # 替换为本技能 slug
# 字幕脚本设计要点(来自 disburse-payroll-icbc 实战经验):
# 1. **关键词全用 ASCII**:保险起见绕开 Windows cp936/UTF-8 编码偶发问题。
# 虽然 platform-kit 已修了 subprocess 编码,ASCII 关键词仍是最稳的。
# 2. **用 "step: N/X" 模式作触发锚**:在测试代码里加 _logger.info("step: 1/5 ...")
# 日志作为字幕触发点,能精确控制字幕出现时机,比依赖 SDK 内部日志更可靠。
# 3. **关键词每条只触发一次**,所以"step: 1/" 这种唯一前缀比"准备数据"更稳。
# 4. **三元组支持每条独立时长**:(关键词, 文案, 显示秒数);
# long wait 场景可以让某条字幕停留更久(如 30s)覆盖中间无日志输出的等待期。
SUBTITLE_SCRIPT = [
# (keyword, text) 二元组用默认时长(5s)
("ensure_app_running", "启动匠厂智能体平台"),
("wait_gateway_ready", "网关就绪"),
("step: 1/", "准备测试数据"),
("step: 2/", "执行技能操作"),
# (keyword, text, duration) 三元组指定独立时长
("step: 3/", "技能在后端持续执行,请稍候...", 30.0),
("step: 4/", "结果汇总返回"),
("PASSED", "测试通过 ✓"),
]
# 测试文件选择(按需切换):
# - test_desktop_smoke.py: 纯问答 skill 用这个
# - test_desktop_smoke_with_attachment.py: 需要上传附件的 skill 用这个
TEST_FILE_NAME = "test_desktop_smoke.py"
# 音乐子目录(按 skill 风格选):
# - "music/corporate": 商务严肃(金融、办公场景)
# - "music/calm": 舒缓(流程演示、配置类)
# - "music/upbeat": 活力(成功汇报、营销视频)
# - "music"(默认): 三个子目录混选
MUSIC_SUBDIR = "music/corporate"
# ============================================================
if __name__ == "__main__":
_here = Path(__file__).resolve()
test_file = str(_here.parent / TEST_FILE_NAME)
output_dir = str(_here.parent / "artifacts" / "recordings")
run_screencast(
skill_slug=SKILL_SLUG,
subtitle_script=SUBTITLE_SCRIPT,
pytest_args=[test_file, "-v", "-s"],
output_dir=output_dir,
music_subdir=MUSIC_SUBDIR,
# platform-kit 自动激活+最大化窗口(Windows ctypes / 其他系统协议),
# 想关掉传 activate_window=False
# monitor_index=1 显式录主屏(多显示器场景推荐)
)

View File

@@ -1,75 +0,0 @@
# -*- coding: utf-8 -*-
"""
可选桌面 E2E 示例(默认不执行:文件名后缀为 .sample)—— **简单问答场景**。
启用方式:
复制本文件为 test_desktop_smoke.py,按目标技能修改提问文案与断言。
运行:pytest tests/desktop/test_desktop_smoke.py -v -s
依赖:pytest、jiangchang_desktop_sdk、匠厂桌面宿主已就绪。
【何时用此模板】
- 技能只需要发一句话给 Main Agent,等回复,做断言(纯问答)
- 不涉及上传附件 / 不涉及 RPA 长流程
【何时不要用此模板,改用 test_desktop_smoke_with_attachment.py.sample】
- 需要上传文件(Excel / PDF / 图片)作为附件
- 技能内部跑 RPA / 长流程(> 2 分钟),需要精细等待
【已知 SDK 限制(来自 disburse-payroll-icbc 实战)】
- client.ask() 内部对"找到 user 消息节点"只给 15 秒窗口,找不到就锁死
user_msg_index=-1,后续等待 assistant 永远 return None。如果你的场景里
匠厂启动较慢 / gateway 握手有延迟,client.ask() 可能假死到 timeout。
此模板仍用 ask(),因为它对**简单场景**够用。复杂场景请用 _with_attachment.py.sample
里的"绕开 ask()"模式。
- client.send_file() 在 v2.0.17+ 抛 NotImplementedError,**绝不要调用它**。
需要附件请用 _with_attachment.py.sample 里的拖拽方案。
"""
from __future__ import annotations
import logging
import os
import pytest
from jiangchang_desktop_sdk import AskOptions, JiangchangDesktopClient
_logger = logging.getLogger("skill-template-desktop-e2e-sample")
if not _logger.handlers:
_logger.setLevel(logging.INFO)
_h = logging.StreamHandler()
_h.setFormatter(logging.Formatter("[E2E-sample] %(message)s"))
_logger.addHandler(_h)
_ARTIFACT_DIR = os.path.join(os.path.dirname(__file__), "artifacts")
class TestDesktopSmokeSample:
"""最小 smoke:拉起桌面、等待网关、发一条与技能路由相关的问句并做宽松断言。"""
@pytest.fixture(autouse=True)
def connect_and_teardown(self, request):
client = JiangchangDesktopClient()
_logger.info("ensure_app_running")
client.ensure_app_running(wait_timeout_s=30)
client.bring_to_front()
client.wait_gateway_ready(timeout_ms=30000)
self.client = client
yield
try:
rep = getattr(request.node, "rep_call", None)
if rep is not None and rep.failed:
os.makedirs(_ARTIFACT_DIR, exist_ok=True)
paths = client.snapshot(_ARTIFACT_DIR, tag=request.node.name)
_logger.error("snapshot: %s", paths)
finally:
client.disconnect()
def test_gateway_and_simple_ask(self) -> None:
"""请将 SKILL 名与路由关键词改成你复制后的真实技能。"""
answer = self.client.ask(
"请使用当前已安装技能执行一次 health 检查并返回 OK 或等价结果。",
AskOptions(timeout=120000, new_task=True),
)
assert answer
assert "OK" in answer or "ok" in answer.lower() or "健康" in answer

View File

@@ -1,137 +0,0 @@
# -*- coding: utf-8 -*-
"""
可选桌面 E2E 示例(默认不执行:文件名后缀为 .sample)—— **附件上传 + 长流程场景**。
启用方式:
1. 复制为 test_desktop_smoke_with_attachment.py
2. 修改 SKILL_SLUG / PROMPT / 断言为你技能的真实业务
3. 准备样例附件,或动态生成(参考 _prepare_sample_attachment)
4. 运行:pytest tests/desktop/test_desktop_smoke_with_attachment.py -v -s
依赖:
- pytest / jiangchang_desktop_sdk / 匠厂桌面宿主已就绪
- 通过 conftest.py 自动 bootstrap,jiangchang_desktop_sdk.e2e_helpers 模块在 sys.path
【为什么用此模板而不是 test_desktop_smoke.py.sample】
- client.send_file() 在 v2.0.17+ 抛 NotImplementedError,必须用拖拽方式
- client.ask() 在 user_idx<0 时会锁死(已知 SDK 缺陷),本模板绕开 ask(),
在测试侧自己用 jiangchang_desktop_sdk.e2e_helpers 做等待
【字幕配合】
- 关键节点都打了 _logger.info("step: N/7 ..."),对应 record_screencast.py 的
SUBTITLE_SCRIPT 用 step: 关键词触发字幕
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
import pytest
from jiangchang_desktop_sdk import JiangchangDesktopClient
from jiangchang_desktop_sdk.e2e_helpers import (
drop_file_into_composer,
send_prompt_via_composer,
wait_for_agent_complete,
wait_for_attachment_ready,
wait_for_composer_enabled,
wait_for_user_message,
)
# ============================================================
# ↓↓↓ 复制后修改这些 ↓↓↓
# ============================================================
SKILL_SLUG = "verify-invoice-default"
PROMPT = (
"请使用 /verify-invoice-default 处理我刚才上传的文件。"
"完成后请用清单方式汇总:[改成你技能要求的字段]。"
)
# 业务断言:根据你的技能输出修改
EXPECTED_KEYWORDS_IN_REPLY = ["关键词1", "关键词2"]
# ============================================================
_logger = logging.getLogger("desktop-e2e-with-attachment-sample")
if not _logger.handlers:
_logger.setLevel(logging.INFO)
_h = logging.StreamHandler()
_h.setFormatter(logging.Formatter("[E2E] %(message)s"))
_logger.addHandler(_h)
_ARTIFACT_DIR = os.path.join(os.path.dirname(__file__), "artifacts")
def _prepare_sample_attachment(tmp_path: Path) -> Path:
"""在 tmp_path 下生成一个样例附件(按 skill 真实需要修改)。
示例:生成一个 5 行的 xlsx。你的 skill 可能需要 CSV / PDF / 图片等其他格式。
"""
from openpyxl import Workbook # noqa: WPS433
xlsx = tmp_path / "sample.xlsx"
wb = Workbook()
ws = wb.active
ws.append(["列1", "列2", "列3"])
for i in range(1, 6):
ws.append([f"值{i}A", f"值{i}B", i * 100])
wb.save(str(xlsx))
return xlsx
@pytest.fixture
def client(request):
c = JiangchangDesktopClient()
_logger.info("ensure_app_running")
c.ensure_app_running(wait_timeout_s=45)
c.bring_to_front()
c.wait_gateway_ready(timeout_ms=30000)
yield c
try:
rep = getattr(request.node, "rep_call", None)
if rep is not None and rep.failed:
os.makedirs(_ARTIFACT_DIR, exist_ok=True)
paths = c.snapshot(_ARTIFACT_DIR, tag=request.node.name)
_logger.error("failure snapshot: %s", paths)
finally:
c.disconnect()
def test_desktop_with_attachment(client: JiangchangDesktopClient, tmp_path: Path) -> None:
_logger.info("step: 1/7 准备样例附件")
attachment_path = _prepare_sample_attachment(tmp_path)
client.new_task()
page = client.get_page()
client.bring_to_front()
_logger.info("step: 2/7 拖拽附件到聊天框")
drop_file_into_composer(page, attachment_path)
_logger.info("step: 3/7 等附件 stage-buffer 完成")
wait_for_attachment_ready(page, attachment_path.name)
wait_for_composer_enabled(page)
client.bring_to_front()
_logger.info("step: 4/7 键入并发送指令")
send_prompt_via_composer(page, PROMPT)
_logger.info("step: 5/7 Main Agent 接收,等待路由到技能")
user_idx = wait_for_user_message(page, PROMPT, timeout_s=60.0)
_logger.info("step: 6/7 技能在后端执行(可能 2-3 分钟)")
client.bring_to_front()
reply = wait_for_agent_complete(
page, user_idx,
overall_timeout_s=1200.0,
stable_seconds=5.0,
)
_logger.info("step: 7/7 校验回复内容")
_logger.info("reply length=%d", len(reply))
assert reply, "assistant 回复为空"
for kw in EXPECTED_KEYWORDS_IN_REPLY:
assert kw in reply, f"期望关键词 {kw!r} 未出现在回复中;片段:{reply[:400]!r}"

View File

@@ -1,27 +0,0 @@
# fixtures(脱敏样例)
本目录存放 **golden case / contract test** 用的输入、期望输出与 **adapter profile 样例**。
## 规则
- 只能放**虚构、脱敏、可公开**的数据(演示租户、演示 request_id 等)。
- **禁止**:真实客户名、真实订单/提单号、真实报关单、真实 token/cookie、真实内网 URL、生产环境凭证。
- `adapter_profiles.sample.yaml` 中的 URL 须使用 `localhost`、`example.invalid` 等明显占位;`credential_ref` 仅表达「从密钥库引用」的约定,**不得**内嵌密钥。
## 与本仓库测试的对应关系
| 文件 | 用途 |
|------|------|
| `sample_request.json` | 通用请求体样例,供 ``tests/samples/test_golden_cases.py.sample`` 演示读取。 |
| `expected_response.json` | 与上配套的期望摘要字段(业务复制后按域替换)。 |
| `adapter_profiles.sample.yaml` | 五类运行方式(mock / simulator_api / simulator_rpa / real_api / real_rpa)的配置表达样例。 |
---
## 给 AI 工具的替换规则
- 可以按业务**重命名字段**、增减键名,但必须保持**虚构、脱敏**;不得把真实客户标识原样搬进仓库。
- 可以**新增**更多 fixture 文件,例如 `case_valid_001.json`、`case_missing_required_001.json`,并在测试中通过相对路径读取。
- **禁止**将真实企业数据、真实单证截图中的文字、真实报关要素未经处理直接复制进 `fixtures/`。
- 若用例来自真实客户样本,须先**脱敏、泛化、替换编号与地名**,再作为模板数据提交。

View File

@@ -1,40 +0,0 @@
# 样例:五类 adapter 运行方式(复制到业务仓后按环境改名;勿提交真实凭证)
runtime:
profile: mock
adapters:
demo_external_system:
interface: demo
profile: mock
mock:
target: mock
channel: none
simulator_api:
target: simulator
channel: api
base_url: http://localhost:5180/api/demo
timeout_seconds: 10
simulator_rpa:
target: simulator
channel: rpa
entry_url: http://localhost:5180/select
timeout_seconds: 30
real_api:
target: real
channel: api
base_url: https://example.invalid/api
credential_ref: vault://tenant/demo/external-system/api
write_enabled: false
timeout_seconds: 20
real_rpa:
target: real
channel: rpa
entry_url: https://example.invalid/portal
credential_ref: vault://tenant/demo/external-system/rpa
write_enabled: false
timeout_seconds: 60

View File

@@ -1,6 +0,0 @@
{
"success": true,
"data": {
"status": "ok"
}
}

View File

@@ -1,11 +0,0 @@
{
"request_id": "req_test_001",
"tenant_id": "tenant_demo",
"actor": "tester",
"parameters": {
"input_text": "demo input",
"options": {
"dry_run": true
}
}
}

View File

@@ -1,48 +0,0 @@
# integration(可选集成 / 真实外联)
本目录**默认不执行**:
- ``python tests/run_tests.py`` 只收集 ``tests/`` 根目录下 ``test_*.py``,**不会**进入本目录。
- 本目录下文件一律为 ``*.py.sample``,pytest / unittest **默认也不会**按普通 ``test_*.py`` 收集。
## 与 samples 的区别
| 维度 | `tests/samples/` | `tests/integration/` |
|------|------------------|------------------------|
| 目的 | 默认可**升级**为根目录 `test_*.py` 的 **业务单元 / service 契约 / golden** 范式 | **外联联调**范式:网络、浏览器、仿真或真实系统 |
| 默认执行 | `.sample` 不执行;复制到根目录并改名后才进入默认套件 | **永远不**作为默认 unittest 的一部分;复制后仍需显式环境变量与手动/CI 任务触发 |
| 典型内容 | `FakeAdapter`、fixture、隔离 DB | `localhost` 仿真、真实 API、真实 RPA(最高风险) |
**原则**:只要会访问**网络**、**浏览器**、**真实或仿真外部系统**,应优先放在 `integration`(并保持 `.sample` 直至团队同意启用),而不是写进默认 `tests/test_*.py`。
## 何时使用
| 场景 | 建议 ``OPENCLAW_TEST_TARGET`` | 其它条件 |
|------|------------------------------|----------|
| 本地 / CI 对接**仿真 HTTP** | ``simulator_api`` | 仿真服务如 ``http://localhost:5180`` |
| **仿真页面** / 录播 RPA | ``simulator_rpa`` | 本地页面或沙箱浏览器 profile |
| **真实 API** | ``real_api`` | **仅手动触发** |
| **真实 RPA**(最高风险) | ``real_rpa`` | **仅手动触发** |
## 环境变量(与 ``adapter_test_utils`` 一致)
- **目标档位**:``OPENCLAW_TEST_TARGET``
取值:``unit`` | ``mock`` | ``simulator_api`` | ``simulator_rpa`` | ``real_api`` | ``real_rpa``
默认未设置等价于 ``unit``。
## 安全约束
- **禁止**在样例或复制后的测试代码中硬编码真实账号、密码、token、cookie、生产 URL。
- 凭证应来自**平台密钥库**、受控环境变量或本机安全配置(``.env`` 已列入 ``tests/.gitignore``,勿提交)。
- 真实 RPA 可能对 ERP/TMS/WMS 等产生真实操作;**务必**沙箱账号、只读权限,并由人工触发 CI job。
## 如何运行某个样例
1. 将目标 ``*.sample`` 复制为 ``test_*.py``(去掉 ``.sample``)。
2. 按技能修改其中的占位 URL / 路由 / 断言。
3. 使用 **pytest** 指向该文件(或整目录),并导出上表所需环境变量。
```powershell
$env:OPENCLAW_TEST_TARGET = "simulator_api"
pytest tests/integration/test_simulator_api_contract.py -v
```

View File

@@ -1,23 +0,0 @@
# -*- coding: utf-8 -*-
# 样例:复制为 test_real_api_contract.py;需 OPENCLAW_TEST_TARGET=real_api。
"""真实 HTTP API(只读优先;默认跳过)。"""
from __future__ import annotations
import unittest
from adapter_test_utils import should_skip_profile
class TestRealApiContractSample(unittest.TestCase):
def test_skip_by_default(self) -> None:
skip, reason = should_skip_profile("real_api", write=False)
if skip:
raise unittest.SkipTest(reason)
# 写操作示例(复制后使用):
# skip_w, _ = should_skip_profile("real_api", write=True)
# 禁止在此文件写入真实 token;从 vault / 环境注入读取 endpoint。
self.assertFalse(skip)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,26 +0,0 @@
# -*- coding: utf-8 -*-
# 样例:复制为 test_real_rpa_contract.py;需 OPENCLAW_TEST_TARGET=real_rpa。
"""
真实 RPA / 桌面自动化(**最高风险**,仅手动触发)。
默认只读思路示例:打开沙箱门户、读取标题、**不提交表单**。
写操作测试同样须显式设 ``OPENCLAW_TEST_TARGET=real_rpa`` 后手动触发。
"""
from __future__ import annotations
import unittest
from adapter_test_utils import should_skip_profile
class TestRealRpaContractSample(unittest.TestCase):
def test_skip_by_default(self) -> None:
skip, reason = should_skip_profile("real_rpa", write=False)
if skip:
raise unittest.SkipTest(reason)
# 真实技能中:连接企业沙箱门户,执行只读断言;禁止默认操作生产系统。
self.assertFalse(skip)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,24 +0,0 @@
# -*- coding: utf-8 -*-
# 样例:复制为 test_simulator_api_contract.py 后,在 OPENCLAW_TEST_TARGET=simulator_api 下用 pytest 或 unittest 运行。
"""仿真 HTTP API 契约(默认跳过;不引入 requests 运行时依赖)。"""
from __future__ import annotations
import unittest
from adapter_test_utils import should_skip_profile
class TestSimulatorApiContractSample(unittest.TestCase):
"""演示:仅当 ``OPENCLAW_TEST_TARGET=simulator_api`` 时不跳过。"""
def test_skip_unless_simulator_api_target(self) -> None:
skip, reason = should_skip_profile("simulator_api")
if skip:
raise unittest.SkipTest(reason)
# 真实技能中:在此使用 urllib 访问 localhost 仿真,或通过 adapter factory 注入 stub。
# 禁止默认连接生产 ERP/TMS。
self.assertTrue(True)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,21 +0,0 @@
# -*- coding: utf-8 -*-
# 样例:复制为 test_simulator_rpa_contract.py 后按需运行;默认跳过。
"""仿真页面 / RPA 契约(不默认启动浏览器)。"""
from __future__ import annotations
import unittest
from adapter_test_utils import should_skip_profile
class TestSimulatorRpaContractSample(unittest.TestCase):
def test_skip_unless_simulator_rpa_target(self) -> None:
skip, reason = should_skip_profile("simulator_rpa")
if skip:
raise unittest.SkipTest(reason)
# 真实技能中:可替换为 Playwright 连接 http://localhost:5180/select 等仿真入口。
self.assertFalse(skip)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,61 +1,14 @@
# -*- coding: utf-8 -*-
"""后端 / CLI / DB 单元测试入口(unittest,不强制 pytest)。
"""默认测试入口:pytest + 基本冒烟测试。"""
用法:
python tests/run_tests.py # 发现 tests/ 根目录下全部 test_*.py
python tests/run_tests.py -v # 详细输出
python tests/run_tests.py cli_smoke # 仅匹配 test_cli_smoke.py
python tests/run_tests.py test_cli_smoke
说明:
只发现本目录(tests/)下一层的 test_*.py;不递归 ``samples/``、``integration/``、
``desktop/``(后三者提供 ``*.sample`` 或 pytest 专用用例,见 ``tests/README.md``)。
"""
from __future__ import annotations
import os
import subprocess
import sys
import unittest
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
_SKILL_ROOT = os.path.abspath(os.path.join(_TESTS_DIR, ".."))
_SCRIPTS_DIR = os.path.abspath(os.path.join(_SKILL_ROOT, "scripts"))
for _p in (_SCRIPTS_DIR, _TESTS_DIR):
if _p not in sys.path:
sys.path.insert(0, _p)
# Windows 下统一 stdout/stderr 为 UTF-8,避免 GBK 控制台中文/emoji 报错
if sys.platform == "win32":
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
def _pattern_from_argv(argv: list[str]) -> str:
for a in argv[1:]:
if a.startswith("-"):
continue
s = a if a.startswith("test_") else f"test_{a}"
if not s.endswith(".py"):
s = f"{s}.py"
return s
return "test_*.py"
def _verbosity_from_argv(argv: list[str]) -> int:
return 2 if ("-v" in argv or "--verbose" in argv) else 1
def main() -> int:
pattern = _pattern_from_argv(sys.argv)
verbosity = _verbosity_from_argv(sys.argv)
loader = unittest.TestLoader()
suite = loader.discover(_TESTS_DIR, pattern=pattern)
runner = unittest.TextTestRunner(verbosity=verbosity)
result = runner.run(suite)
return 0 if result.wasSuccessful() else 1
def main():
"""运行 pytest。"""
args = [sys.executable, "-m", "pytest", "tests/", "-v", "--tb=short"]
args.extend(sys.argv[1:])
return subprocess.call(args)
if __name__ == "__main__":

View File

@@ -1,128 +0,0 @@
# samples(业务测试样例,默认不执行)
本目录存放**可复制到根目录的业务测试范式**,面向「企业数字员工技能」中 **ERP、TMS、WMS、船司、报关、邮件、LLM、RPA** 等外联场景下的 **service / golden** 测法说明。
---
## 作用
- 这里的文件是 **`.py.sample`**:`python tests/run_tests.py` **不会**收集本目录(`run_tests.py` 只发现 `tests/` 根目录下的 `test_*.py`)。
- 人类或 AI 需要启用范式时:**复制**到 `tests/test_<名称>.py`(去掉 `.sample`),再按业务改 import、断言与 fixture 路径。
- **禁止**把真实联调、真实网络、真实 RPA 写进复制后的默认用例;此类内容应放在 `tests/integration/*.sample` 并配合环境变量开关。
---
## 样例文件
### 1. `test_service_contract.py.sample`
**适合:**
- 技能有清晰的 **service 层入口**,例如 `run(...)`、`handle(...)`、`execute(...)`。
- 需要验证:**成功路径**、**参数/校验错误**、**adapter 异常被转换为业务可读错误**。
- 需要用 **`FakeAdapter`**(见 `tests/adapter_test_utils.py`)或自建 **stub** 替代 ERP、TMS、WMS、船司、报关、LLM、RPA 等,**不**发真实 HTTP、**不**开真实浏览器。
### 2. `test_sync_contract.py.sample`
**适合:** 外源 → 本地库的同步 / refresh / collect 能力。
至少应覆盖:首次插入、第二次幂等、更新、失效数据处理、不可信空结果保护 vs 完整空快照失效、遗漏 `snapshot_complete` 立即失败、部分写入后故障注入与回滚(`fail_after_writes`)、统计字段 `inserted/updated/unchanged/removed`、同步路径不调用 CSV/Excel 导出器。`snapshot_complete` 为调用方**必须显式传入**的 keyword-only 参数(无默认值),禁止默认把结果当成完整快照,也禁止靠条数猜测完整性。使用通用表名(如 `business_key`),不要写具体平台业务名。
### 3. `test_action_core_contract.py.sample`
**适合:** 校验「单业务内核、多入口」——task/CLI/兼容命令调用同一 domain service;不按 toolbar/cron/agent 来源改变业务结果。
### 4. `test_golden_cases.py.sample`
**适合:**
- **解析类**:邮件/单证/PDF/Excel 等结构化输出。
- **计算类**:费用、税费、分摊、账单字段。
- **校验类**:合规、字段一致性、状态机规则。
推荐配合根目录下的 **`tests/fixtures/sample_request.json`** 与 **`tests/fixtures/expected_response.json`**(复制技能后按域替换字段,保持脱敏)。
### 5. `test_logging_contract.py.sample`
**适合:** 长任务 / RPA 技能,需断言统一文件日志含 `task_start`、`task_failed|task_success`、`error_code=` 等事件词(见 [`development/LOGGING.md`](../../development/LOGGING.md) §3)。复制后用 `IsolatedDataRoot` + mock 域 RPA,**不要**依赖真实企微/浏览器。
---
## 如何启用 service contract 样例
1. **复制文件**
`tests/samples/test_service_contract.py.sample`
→ `tests/test_service_contract.py`(或 `tests/test_<domain>_service_contract.py`,须符合根目录 `test_*.py` 命名以便 `run_tests.py` 收集)。
2. **替换示例 import**
把注释中的示意:
`# from service.your_service import run`
改为真实模块,例如:
`from service.quote_service import run_quote`
(具体路径以技能代码为准。)
3. **注入假外部依赖**
- **不要**在默认用例里直接 `requests` 访问 ERP/TMS/WMS 等。
- **不要**打开真实网页或驱动真实 RPA。
- **不要**调用真实 LLM;可 stub 返回固定 JSON。
- 对 **timeout / unauthorized / invalid_response** 等分支,用 `FakeAdapter` 或自建 fake 模拟(与 `adapter_test_utils` 思路一致)。
4. **至少保留三类断言习惯**
- **成功路径**:返回 `success: true` 或与技能约定的成功结构。
- **参数错误**:缺必填字段时返回**结构化** error(字段/码),而非未捕获裸异常。
- **外部异常**:adapter 超时、鉴权失败、脏响应等被转换为**可读、可重试标识明确**的错误形态(按产品约定)。
5. **给 AI 的提示**
复制后全文搜索 `your_service`、`TODO`、`伪代码` 等占位符并替换;不要留下指向真实客户的示例数据。
---
## 如何启用 golden case 样例
1. **复制文件**
`tests/samples/test_golden_cases.py.sample`
→ `tests/test_golden_cases.py`(或 `tests/test_<domain>_golden.py`)。
2. **按业务修改 fixtures**(仍须脱敏)
- `tests/fixtures/sample_request.json`:请求或输入快照。
- `tests/fixtures/expected_response.json`:期望摘要或关键字段。
可新增多组文件,如 `case_valid_001.json`,并在测试中显式读取路径。
3. **断言建议(避免脆弱用例)**
- 比较**关键字段**,避免对整段 JSON 做机械全文 diff。
- **忽略**时间戳、随机 id、`trace_id` 等非确定字段。
- 对列表:断言**长度**、**关键元素**、**排序规则**(若业务保证顺序)。
- 对金额/数量:断言 Decimal 字符串、或允许文档约定的小范围误差。
4. **与 service 的关系**
golden 用例仍应通过 **service 层**(或纯函数)得到输出,再与 fixture 对比,而不是从 CLI 字符串 scrape。
---
## 不应该写在 samples 复制件里的内容
以下若出现,应移到 **`tests/integration/*.sample`** 或 **`tests/desktop/*.sample`**,并加环境变量授权说明:
- 访问**真实** API 或内网服务。
- 打开**真实** RPA 页面或生产门户。
- 读取**真实**客户文件、生产数据库。
- 在测试中写入**真实**数据根或共享盘。
- 任何真实 **token / cookie / 密码** 字面量。
- 耗时极长、强依赖宿主 UI 抖动、无法在 CI 稳定复现的用例(不要塞进默认 `tests/test_*.py`)。
---
## 推荐命名(复制到根目录后)
| 用途 | 示例文件名 |
|------|------------|
| Service 契约 | `tests/test_service_contract.py` |
| 同步契约 | `tests/test_sync_contract.py` |
| 单内核多入口 | `tests/test_action_core_contract.py` |
| Golden / fixture 回归 | `tests/test_golden_cases.py` |
| 领域规则 | `tests/test_<domain>_rules.py` |
| 校验逻辑 | `tests/test_<domain>_validation.py` |
命名需满足:`tests/` 根目录下 `test_*.py`,以便 `python tests/run_tests.py` 默认发现。
更多分层说明见 **`tests/README.md`**(「我该把测试写在哪里?」与「新技能最小测试清单」)。

View File

@@ -1,116 +0,0 @@
# -*- coding: utf-8 -*-
# 样例:复制到 ``tests/test_action_core_contract.py`` 后按实际模块改名运行。
# 默认不会被 ``python tests/run_tests.py`` 收集(扩展名为 .sample)。
"""
单业务内核、多入口契约范式(通用)。
覆盖:
- task / command adapter 调用唯一 domain service
- 兼容命令复用同一个核心函数
- CLI handler 不复制业务逻辑
- 不根据 toolbar / cron / agent 来源改变业务处理
复制后把示意函数换成真实 import;禁止按入口维护多套实现。
"""
from __future__ import annotations
import unittest
from typing import Any, Callable
class DomainService:
"""唯一业务内核示意。"""
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
def sync_records(self, *, limit: int = 100) -> dict[str, Any]:
self.calls.append({"op": "sync_records", "limit": limit})
return {"ok": True, "limit": limit, "inserted": 1}
def cmd_sync_records(domain: DomainService, *, limit: int = 100) -> dict[str, Any]:
"""task_service 编排层:可加 job_context,但不按 source 分叉业务。"""
return domain.sync_records(limit=limit)
def cmd_export_compat_refresh(domain: DomainService, *, limit: int = 100) -> dict[str, Any]:
"""兼容旧 export:刷新必须复用同一 sync 内核,禁止另写采集逻辑。"""
sync_result = domain.sync_records(limit=limit)
return {"export": "delegated-to-host-or-report", "sync": sync_result}
def cli_handle_sync(domain: DomainService, argv_limit: int) -> dict[str, Any]:
"""CLI handler:只解析参数后转发,不内嵌业务。"""
return cmd_sync_records(domain, limit=argv_limit)
def run_from_source(
domain: DomainService,
*,
source: str,
limit: int = 10,
) -> dict[str, Any]:
"""
宿主可能传入 source=toolbar|cron|agent|skill-detail。
正确实现:忽略 source,业务结果一致。
"""
_ = source # 不得用于分支业务语义
return cmd_sync_records(domain, limit=limit)
class TestActionCoreContractSample(unittest.TestCase):
def test_task_adapter_calls_single_domain_service(self) -> None:
domain = DomainService()
out = cmd_sync_records(domain, limit=5)
self.assertTrue(out["ok"])
self.assertEqual(domain.calls, [{"op": "sync_records", "limit": 5}])
def test_compat_command_reuses_same_core(self) -> None:
domain = DomainService()
cmd_export_compat_refresh(domain, limit=3)
self.assertEqual(len(domain.calls), 1)
self.assertEqual(domain.calls[0]["op"], "sync_records")
def test_cli_handler_does_not_duplicate_business_logic(self) -> None:
domain = DomainService()
# CLI 与 task 编排应落到同一函数对象(示意)
self.assertIs(cli_handle_sync.__wrapped__ if hasattr(cli_handle_sync, "__wrapped__") else None, None)
out = cli_handle_sync(domain, 7)
self.assertEqual(out["limit"], 7)
self.assertEqual(domain.calls[-1], {"op": "sync_records", "limit": 7})
# 强化:CLI 路径与 cmd 路径结果一致
domain2 = DomainService()
a = cli_handle_sync(domain2, 2)
domain3 = DomainService()
b = cmd_sync_records(domain3, limit=2)
self.assertEqual(a, b)
def test_business_result_independent_of_entry_source(self) -> None:
domain = DomainService()
results = [
run_from_source(domain, source=src, limit=4)
for src in ("toolbar", "cron", "agent", "skill-detail")
]
self.assertEqual(results[0], results[1])
self.assertEqual(results[1], results[2])
self.assertEqual(results[2], results[3])
self.assertEqual(len(domain.calls), 4)
self.assertTrue(all(c["op"] == "sync_records" and c["limit"] == 4 for c in domain.calls))
def test_entrypoint_wiring_points_to_same_callable(self) -> None:
"""示意:manifest 多 placements 应对应同一 Python 入口函数。"""
domain = DomainService()
handlers: dict[str, Callable[..., dict[str, Any]]] = {
"toolbar": lambda: cmd_sync_records(domain, limit=1),
"cron": lambda: cmd_sync_records(domain, limit=1),
"agent": lambda: cmd_sync_records(domain, limit=1),
}
outs = [handlers[k]() for k in handlers]
self.assertEqual(outs[0], outs[1])
self.assertEqual(outs[1], outs[2])
if __name__ == "__main__":
unittest.main()

View File

@@ -1,37 +0,0 @@
# -*- coding: utf-8 -*-
# 样例:复制为 ``tests/test_golden_cases.py`` 后运行;默认不收集。
"""
Golden fixture 回归(示意)。
- 从 ``tests/fixtures/`` 读取脱敏 JSON。
- 调用 service 层(复制后替换为真实 import)。
- 断言关键字段,而非全文 diff(外部系统常带时间戳/非确定性字段)。
"""
from __future__ import annotations
import json
import os
import unittest
def _fixtures_dir() -> str:
return os.path.join(os.path.dirname(__file__), "..", "fixtures")
class TestGoldenCasesSample(unittest.TestCase):
def test_sample_request_matches_expected_shape(self) -> None:
req_path = os.path.join(_fixtures_dir(), "sample_request.json")
exp_path = os.path.join(_fixtures_dir(), "expected_response.json")
with open(req_path, encoding="utf-8") as f:
req = json.load(f)
with open(exp_path, encoding="utf-8") as f:
exp = json.load(f)
self.assertEqual(req["request_id"], "req_test_001")
self.assertTrue(req["parameters"]["options"]["dry_run"])
# 示意:out = run(req); 然后 assert out["success"] == exp["success"] 等关键字段
self.assertTrue(exp["success"])
self.assertEqual(exp["data"]["status"], "ok")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,56 +0,0 @@
# -*- coding: utf-8 -*-
"""日志契约样例:复制为 tests/test_logging_contract.py 后按业务改入口。
验证主任务 mock/离线路径写入统一日志事件词(见 development/LOGGING.md §3)。
默认套件不收集本文件(.sample)。
"""
from __future__ import annotations
import io
import logging
import unittest
from contextlib import redirect_stderr, redirect_stdout
from unittest.mock import patch
# 复制到 tests/ 根目录后取消下一行注释,并按技能改 import
# from _support import IsolatedDataRoot
class TestLoggingContractSample(unittest.TestCase):
"""示范:捕获 skill logger,断言关键事件词出现。"""
def test_main_path_emits_task_start_and_terminal_event(self) -> None:
self.skipTest(
"复制本文件为 tests/test_logging_contract.py 后:"
"用 IsolatedDataRoot + patch 域 RPA,调用 cmd_run/cmd_add_one,"
"对 logging.Handler 断言消息含 task_start 与 task_success|task_failed"
)
# 示例骨架(复制后实现):
# with IsolatedDataRoot():
# log = logging.getLogger("openclaw.skill.verify_invoice_default")
# records: list[str] = []
# class _H(logging.Handler):
# def emit(self, record):
# records.append(record.getMessage())
# h = _H()
# log.addHandler(h)
# try:
# with patch(...), redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
# rc = cmd_add_one(...) # 或 cmd_run
# finally:
# log.removeHandler(h)
# blob = "\n".join(records)
# self.assertIn("task_start", blob)
# self.assertTrue("task_failed" in blob or "task_success" in blob)
# if "task_failed" in blob:
# self.assertIn("error_code=", blob)
def test_failure_path_logs_error_code_before_print(self) -> None:
self.skipTest(
"复制后:注入业务错误(如 ELEMENT_NOT_FOUND),断言 logger 含 "
"task_failed error_code=...,且 stdout 含 ERROR:CODE"
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,46 +0,0 @@
# -*- coding: utf-8 -*-
# 本文件为样例:复制到 ``tests/test_service_contract.py``(或业务仓 tests 根)后按实际 service 改名运行。
# 默认 **不会** 被 ``python tests/run_tests.py`` 收集(扩展名为 .sample)。
"""
Service 层契约测试范式(示意)。
复制为真实技能后:
1. 将 ``from service.your_service import run`` 替换为实际模块与入口函数。
2. 用 ``FakeAdapter`` / 内存 stub 替代真实 ERP/TMS/WMS 等外呼。
3. 需要真实外网或真实 RPA 时,勿在本文件直接写死凭证;改用 ``tests/integration/*.sample`` + 环境开关。
"""
from __future__ import annotations
import unittest
from adapter_test_utils import FakeAdapter
# from service.your_service import run # noqa: ERA001 — 复制后取消注释并实现
class TestServiceContractSample(unittest.TestCase):
"""三类常见契约:成功路径、结构化错误、上游超时转可重试错误。"""
def test_success_path_returns_success_true(self) -> None:
# 示意:run(payload, adapter=FakeAdapter("success")) -> {"success": True, ...}
ad = FakeAdapter("success")
out = ad.call({"op": "ping"})
self.assertTrue(out.get("success"))
def test_missing_required_field_returns_structured_error_not_bare_exception(self) -> None:
# 示意:业务层应对必填字段做校验,返回 {"success": False, "error": {"code": "...", "message": "..."}}
err = {"success": False, "error": {"code": "VALIDATION", "message": "missing shipment_id"}}
self.assertFalse(err["success"])
self.assertIn("code", err["error"])
def test_adapter_timeout_maps_to_retriable_error(self) -> None:
# 示意:捕获 TimeoutError 后映射为业务可重试错误码,而不是让裸异常穿透 CLI
ad = FakeAdapter("timeout")
with self.assertRaises(TimeoutError):
ad.call({})
# 真实实现中:except TimeoutError: return {"success": False, "error": {"code": "UPSTREAM_TIMEOUT", "retriable": True}}
if __name__ == "__main__":
unittest.main()

View File

@@ -1,274 +0,0 @@
# -*- coding: utf-8 -*-
# 样例:复制到 ``tests/test_sync_contract.py`` 后按实际 domain service 改名运行。
# 默认不会被 ``python tests/run_tests.py`` 收集(扩展名为 .sample)。
"""
外源 → 本地库同步契约范式(通用,无具体平台业务名)。
覆盖:
- 首次同步插入
- 第二次同步幂等
- 更新已有数据
- 处理失效数据
- 不可信空结果保护 vs 完整空快照失效(``snapshot_complete`` 必填,无默认值)
- 遗漏 ``snapshot_complete`` 立即失败(TypeError)
- 事务在部分写入后失败并回滚
- 返回 inserted/updated/unchanged/removed
- sync Action 不调用 CSV/Excel 文件导出器
复制后把内存仓库与 stub 外源换成真实 repository / reader;禁止访问真实网络或用户数据目录。
"""
from __future__ import annotations
import unittest
from dataclasses import dataclass, field
from typing import Any
@dataclass
class SyncStats:
total: int = 0
inserted: int = 0
updated: int = 0
unchanged: int = 0
removed: int = 0
def as_dict(self) -> dict[str, int]:
return {
"total": self.total,
"inserted": self.inserted,
"updated": self.updated,
"unchanged": self.unchanged,
"removed": self.removed,
}
@dataclass
class MemoryRecordStore:
"""内存库:演示幂等 upsert + 全量失效(is_active 标记)。"""
rows: dict[str, dict[str, Any]] = field(default_factory=dict)
in_transaction: bool = False
_tx_snapshot: dict[str, dict[str, Any]] | None = None
def begin(self) -> None:
self._tx_snapshot = {k: dict(v) for k, v in self.rows.items()}
self.in_transaction = True
def commit(self) -> None:
self._tx_snapshot = None
self.in_transaction = False
def rollback(self) -> None:
if self._tx_snapshot is not None:
self.rows = {k: dict(v) for k, v in self._tx_snapshot.items()}
self._tx_snapshot = None
self.in_transaction = False
def upsert_snapshot(
self,
records: list[dict[str, Any]],
*,
fail_after_writes: int | None = None,
) -> SyncStats:
"""全量快照:按 business_key 对齐;缺失标 is_active=0。
``fail_after_writes``:在成功完成该次数的 insert/update 之后抛错,用于验证回滚。
"""
self.begin()
try:
stats = SyncStats(total=len(records))
seen: set[str] = set()
write_count = 0
for rec in records:
key = str(rec["business_key"])
seen.add(key)
existing = self.rows.get(key)
payload = {
"business_key": key,
"title": rec["title"],
"is_active": 1,
}
if existing is None:
self.rows[key] = payload
stats.inserted += 1
write_count += 1
elif existing.get("title") == payload["title"] and existing.get("is_active") == 1:
stats.unchanged += 1
else:
self.rows[key] = payload
stats.updated += 1
write_count += 1
if fail_after_writes is not None and write_count >= fail_after_writes:
raise RuntimeError(
f"simulated write failure after {write_count} insert/update write(s)"
)
for key, existing in list(self.rows.items()):
if key not in seen and existing.get("is_active") == 1:
existing["is_active"] = 0
stats.removed += 1
self.commit()
return stats
except Exception:
self.rollback()
raise
class ExportSpy:
"""若 sync 路径误调导出器,测试应失败。"""
def __init__(self) -> None:
self.calls: list[str] = []
def write_csv(self, *_a: Any, **_k: Any) -> None:
self.calls.append("csv")
def write_excel(self, *_a: Any, **_k: Any) -> None:
self.calls.append("excel")
def sync_records(
store: MemoryRecordStore,
external_rows: list[dict[str, Any]],
*,
snapshot_complete: bool,
exporter: ExportSpy | None = None,
fail_after_writes: int | None = None,
) -> dict[str, int]:
"""领域同步内核示意:只写库,不导出文件。
``snapshot_complete`` 为 keyword-only **必填**参数(无默认值):
- False:读取失败/分页不完整/不可信结果 → 不得清库或失效旧数据
- True:外源完整快照(可为空)→ 允许按全量策略 upsert / 标记失效
调用方必须显式传入,禁止默认当作完整快照,也禁止靠条数猜测完整性。
"""
if external_rows is None:
raise ValueError("external_rows required")
if not snapshot_complete:
active = sum(1 for r in store.rows.values() if r.get("is_active") == 1)
return SyncStats(total=0, unchanged=active).as_dict()
stats = store.upsert_snapshot(external_rows, fail_after_writes=fail_after_writes)
if exporter is not None:
# 正确实现不得调用;本示意刻意不调用,供测试断言
pass
return stats.as_dict()
class TestSyncContractSample(unittest.TestCase):
def test_first_sync_inserts(self) -> None:
store = MemoryRecordStore()
stats = sync_records(
store,
[{"business_key": "a", "title": "A"}, {"business_key": "b", "title": "B"}],
snapshot_complete=True,
)
self.assertEqual(stats["inserted"], 2)
self.assertEqual(stats["total"], 2)
self.assertEqual(len(store.rows), 2)
def test_second_sync_is_idempotent(self) -> None:
store = MemoryRecordStore()
rows = [{"business_key": "a", "title": "A"}]
sync_records(store, rows, snapshot_complete=True)
stats = sync_records(store, rows, snapshot_complete=True)
self.assertEqual(stats["unchanged"], 1)
self.assertEqual(stats["inserted"], 0)
self.assertEqual(stats["updated"], 0)
def test_updates_existing_row(self) -> None:
store = MemoryRecordStore()
sync_records(store, [{"business_key": "a", "title": "A"}], snapshot_complete=True)
stats = sync_records(
store, [{"business_key": "a", "title": "A2"}], snapshot_complete=True
)
self.assertEqual(stats["updated"], 1)
self.assertEqual(store.rows["a"]["title"], "A2")
def test_marks_removed_inactive_on_full_sync(self) -> None:
store = MemoryRecordStore()
sync_records(
store,
[{"business_key": "a", "title": "A"}, {"business_key": "b", "title": "B"}],
snapshot_complete=True,
)
stats = sync_records(
store, [{"business_key": "a", "title": "A"}], snapshot_complete=True
)
self.assertEqual(stats["removed"], 1)
self.assertEqual(store.rows["b"]["is_active"], 0)
self.assertEqual(store.rows["a"]["is_active"], 1)
def test_incomplete_empty_snapshot_does_not_wipe_existing(self) -> None:
store = MemoryRecordStore()
sync_records(store, [{"business_key": "a", "title": "A"}], snapshot_complete=True)
stats = sync_records(store, [], snapshot_complete=False)
self.assertEqual(store.rows["a"]["is_active"], 1)
self.assertEqual(stats["removed"], 0)
self.assertEqual(stats["unchanged"], 1)
def test_complete_empty_snapshot_marks_existing_inactive(self) -> None:
store = MemoryRecordStore()
sync_records(
store,
[{"business_key": "a", "title": "A"}, {"business_key": "b", "title": "B"}],
snapshot_complete=True,
)
stats = sync_records(store, [], snapshot_complete=True)
self.assertEqual(stats["removed"], 2)
self.assertEqual(store.rows["a"]["is_active"], 0)
self.assertEqual(store.rows["b"]["is_active"], 0)
def test_omitting_snapshot_complete_raises_type_error(self) -> None:
store = MemoryRecordStore()
with self.assertRaises(TypeError):
sync_records(store, [{"business_key": "a", "title": "A"}]) # type: ignore[call-arg]
def test_transaction_failure_after_partial_writes_rolls_back(self) -> None:
store = MemoryRecordStore()
sync_records(store, [{"business_key": "a", "title": "A"}], snapshot_complete=True)
with self.assertRaises(RuntimeError) as ctx:
sync_records(
store,
[
{"business_key": "a", "title": "A2"},
{"business_key": "b", "title": "B"},
],
snapshot_complete=True,
fail_after_writes=1,
)
self.assertIn("after 1", str(ctx.exception))
self.assertEqual(store.rows["a"]["title"], "A")
self.assertNotIn("b", store.rows)
self.assertFalse(store.in_transaction)
self.assertIsNone(store._tx_snapshot)
def test_stats_keys_cover_inserted_updated_unchanged_removed(self) -> None:
store = MemoryRecordStore()
sync_records(
store,
[{"business_key": "a", "title": "A"}, {"business_key": "b", "title": "B"}],
snapshot_complete=True,
)
stats = sync_records(
store,
[{"business_key": "a", "title": "A2"}],
snapshot_complete=True,
)
for key in ("total", "inserted", "updated", "unchanged", "removed"):
self.assertIn(key, stats)
self.assertEqual(stats["updated"], 1)
self.assertEqual(stats["removed"], 1)
def test_sync_does_not_call_file_exporters(self) -> None:
store = MemoryRecordStore()
spy = ExportSpy()
sync_records(
store,
[{"business_key": "a", "title": "A"}],
snapshot_complete=True,
exporter=spy,
)
self.assertEqual(spy.calls, [])
if __name__ == "__main__":
unittest.main()

View File

@@ -1,66 +0,0 @@
# -*- coding: utf-8 -*-
"""旧库废弃表迁移样例(复制到 tests/ 并按技能改写)。
验收:给定旧库含废弃物理表 + 元数据 + 历史行 → init_db() 后表与元数据均清除。
见 references/SCHEMA.md「表增删改与退役」。
"""
from __future__ import annotations
import unittest
from _support import IsolatedDataRoot
class TestTableDeprecationMigration(unittest.TestCase):
def test_init_db_drops_legacy_task_logs(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata import iter_user_tables
# 1) 模拟升级前的旧库
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE task_logs (
id INTEGER PRIMARY KEY,
task_type TEXT,
status TEXT
)
"""
)
cur.execute(
"""
INSERT INTO _jiangchang_tables
(table_name, display_name, visible, readonly)
VALUES ('task_logs', '任务日志', 1, 0)
"""
)
cur.execute("INSERT INTO task_logs (task_type, status) VALUES ('run', 'ok')")
conn.commit()
finally:
conn.close()
# 2) 新代码 init_db() 应幂等清除(在 connection.init_db 中调用 drop_deprecated_user_table)
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_logs'"
)
self.assertIsNone(cur.fetchone())
cur.execute(
"SELECT COUNT(*) FROM _jiangchang_tables WHERE table_name='task_logs'"
)
self.assertEqual(int(cur.fetchone()[0]), 0)
self.assertNotIn("task_logs", list(iter_user_tables(cur)))
finally:
conn.close()
if __name__ == "__main__":
unittest.main()

View File

@@ -1,669 +0,0 @@
# -*- coding: utf-8 -*-
"""assets/actions.json 结构与 CLI 映射守护(通用模板,不含具体业务技能名称)。"""
from __future__ import annotations
import json
import os
import re
import sqlite3
import unittest
from _support import IsolatedDataRoot, get_skill_root
from cli.app import build_parser
from util.constants import SKILL_SLUG
_TEMPLATE_PLACEHOLDER_SLUG = "your-skill-slug"
_ALLOWED_PLACEMENTS = frozenset({"toolbar", "row", "batch", "cron", "agent", "skill-detail"})
_RESERVED_ACTION_FIELDS = frozenset({"concurrency", "locks"})
_ALLOWED_EXECUTION_PROFILES = frozenset({"sync", "async"})
_REQUIRED_MANIFEST_KEYS = frozenset({"schemaVersion", "skill", "actions"})
_REQUIRED_ACTION_KEYS = frozenset(
{"id", "label", "description", "placements", "entrypoint", "executionProfile"}
)
_TABLE_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
_FORBIDDEN_BUSINESS_TERMS = (
"wechat",
"微信",
"contacts",
"wxid",
"account_wxid",
"contact.db",
"scrape-contacts",
"douyin",
"抖音",
"xiaohongshu",
"小红书",
"公众号",
"logistics",
"物流",
)
_TEMPLATE_ARG_PATTERN = re.compile(r"\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}")
def _load_actions_manifest() -> dict:
path = os.path.join(get_skill_root(), "assets", "actions.json")
with open(path, encoding="utf-8") as f:
return json.load(f)
def _cli_subcommands() -> set[str]:
parser = build_parser()
subs = parser._subparsers._group_actions[0].choices # type: ignore[attr-defined]
return set(subs.keys())
def _collect_template_vars(args: list) -> set[str]:
found: set[str] = set()
for item in args:
if not isinstance(item, str):
continue
for match in _TEMPLATE_ARG_PATTERN.finditer(item):
found.add(match.group(1))
return found
def _load_actions_schema() -> dict:
path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json")
with open(path, encoding="utf-8") as f:
return json.load(f)
def _validate_bind_tables(action_id: str, bind: object) -> list[str]:
errors: list[str] = []
if not isinstance(bind, dict):
return [f"{action_id}: bind must be object"]
allowed = {"tables", "inputMapping", "columnMapping", "labelColumn"}
unknown = set(bind.keys()) - allowed
if unknown:
errors.append(f"{action_id}: bind only allows {sorted(allowed)}, got {sorted(bind.keys())}")
tables = bind.get("tables")
if not isinstance(tables, list) or len(tables) < 1:
errors.append(f"{action_id}: bind.tables must be non-empty array")
return errors
seen: set[str] = set()
for table in tables:
if not isinstance(table, str) or not table.strip():
errors.append(f"{action_id}: bind.tables entries must be non-empty strings")
continue
if table != table.strip() or not _TABLE_NAME_PATTERN.fullmatch(table):
errors.append(f"{action_id}: bind.tables entry {table!r} must be snake_case")
if table in seen:
errors.append(f"{action_id}: bind.tables duplicate {table!r}")
seen.add(table)
label_column = bind.get("labelColumn")
if label_column is not None:
if not isinstance(label_column, str) or not label_column.strip():
errors.append(f"{action_id}: bind.labelColumn must be non-empty string")
elif not _TABLE_NAME_PATTERN.fullmatch(label_column):
errors.append(f"{action_id}: bind.labelColumn {label_column!r} must be snake_case")
return errors
def _load_bindable_business_tables() -> set[str]:
"""在隔离临时数据根下 init_db,返回可绑定到 toolbar 的业务表集合。
表必须同时满足:
- SQLite 物理表存在(排除 sqlite_* 与 _jiangchang_* 元数据表)
- ``_jiangchang_tables`` 中有对应行且 ``visible=1``
模板默认无业务表,允许返回空集。失败时抛出 RuntimeError(带诊断)。
"""
from db.connection import init_db
from db.display_metadata import METADATA_TABLES
from util.runtime_paths import get_db_path
physical: set[str] = set()
visible_meta: set[str] = set()
try:
with IsolatedDataRoot() as data_root:
init_db()
db_path = get_db_path()
if not os.path.isfile(db_path):
raise RuntimeError(
f"init_db did not create database file under isolated root "
f"{data_root!r}: expected {db_path}"
)
conn = sqlite3.connect(db_path)
try:
cur = conn.cursor()
cur.execute(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name NOT LIKE 'sqlite_%'"
)
physical = {
str(row[0])
for row in cur.fetchall()
if str(row[0]) not in METADATA_TABLES
}
cur.execute(
"SELECT table_name FROM _jiangchang_tables WHERE visible = 1"
)
visible_meta = {str(row[0]) for row in cur.fetchall()}
finally:
conn.close()
bindable = physical & visible_meta
return bindable
except RuntimeError:
raise
except Exception as exc:
raise RuntimeError(
"failed to load bindable business tables via isolated init_db: "
f"{type(exc).__name__}: {exc}"
) from exc
class TestActionsManifest(unittest.TestCase):
def test_manifest_exists_and_schema(self) -> None:
manifest = _load_actions_manifest()
self.assertEqual(manifest.get("schemaVersion"), 1)
skill = manifest.get("skill")
self.assertIn(skill, (SKILL_SLUG, _TEMPLATE_PLACEHOLDER_SLUG))
self.assertIsInstance(manifest.get("actions"), list)
self.assertGreater(len(manifest["actions"]), 0)
def test_action_ids_unique_and_cli_valid(self) -> None:
manifest = _load_actions_manifest()
subs = _cli_subcommands()
seen: set[str] = set()
for action in manifest["actions"]:
action_id = action["id"]
self.assertNotIn(action_id, seen, msg=f"duplicate action id: {action_id}")
seen.add(action_id)
self.assertTrue(str(action.get("label", "")).strip(), msg=f"{action_id} label empty")
self.assertTrue(str(action.get("description", "")).strip(), msg=f"{action_id} description empty")
entry = action["entrypoint"]
self.assertEqual(entry["type"], "cli")
self.assertIn(entry["command"], subs, msg=f"{action_id} command not in CLI")
args = entry.get("args")
self.assertIsInstance(args, list, msg=f"{action_id} args must be array")
for item in args:
self.assertIsInstance(
item,
(str, int, float, bool),
msg=f"{action_id} arg item must be scalar",
)
self.assertNotIsInstance(item, dict, msg=f"{action_id} arg must not be object")
self.assertIsNotNone(item, msg=f"{action_id} arg must not be null")
placements = action.get("placements")
self.assertIsInstance(placements, list, msg=f"{action_id} placements must be array")
replaces = action.get("replaces")
if replaces is not None:
self.assertIn(
replaces,
{"create", "update", "delete", "import"},
msg=f"{action_id} invalid replaces {replaces!r}",
)
bind = action.get("bind") or {}
tables = bind.get("tables") or []
self.assertTrue(
isinstance(tables, list) and len(tables) > 0,
msg=f"{action_id} replaces requires non-empty bind.tables",
)
else:
self.assertGreater(len(placements), 0, msg=f"{action_id} placements empty")
for placement in placements:
self.assertIn(placement, _ALLOWED_PLACEMENTS, msg=f"{action_id} invalid placement {placement!r}")
def test_every_action_declares_execution_profile(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
profile = action.get("executionProfile")
self.assertIn(
profile,
_ALLOWED_EXECUTION_PROFILES,
msg=f"{action['id']} executionProfile must be sync|async, got {profile!r}",
)
def test_schema_requires_execution_profile_and_defines_bind(self) -> None:
schema = _load_actions_schema()
action_def = schema["$defs"]["action"]
self.assertIn("executionProfile", action_def.get("required") or [])
profile = action_def["properties"]["executionProfile"]
self.assertEqual(set(profile.get("enum") or []), {"sync", "async"})
self.assertIn("bind", schema["$defs"])
self.assertIn("bind", action_def["properties"])
bind_def = schema["$defs"]["bind"]
self.assertEqual(bind_def.get("required"), ["tables"])
self.assertIs(bind_def.get("additionalProperties"), False)
def test_schema_defines_interaction_fields(self) -> None:
schema = _load_actions_schema()
action_def = schema["$defs"]["action"]
props = action_def["properties"]
self.assertIn("interaction", props)
self.assertEqual(
set(props["interaction"].get("enum") or []),
{"auto", "direct", "confirm", "form"},
)
self.assertIn("interactionByPlacement", props)
by_placement = props["interactionByPlacement"]
self.assertIs(by_placement.get("additionalProperties"), False)
for key in ("toolbar", "row", "batch", "cron", "agent", "skill-detail", "create", "update", "delete", "import"):
self.assertIn(key, by_placement.get("properties") or {})
self.assertEqual(
set((by_placement["properties"][key].get("enum") or [])),
{"direct", "confirm", "form"},
)
self.assertIn("replaces", props)
self.assertEqual(props["replaces"].get("$ref"), "#/$defs/mutationKind")
self.assertEqual(
set(schema["$defs"]["mutationKind"].get("enum") or []),
{"create", "update", "delete", "import"},
)
def test_schema_defines_hover_hint(self) -> None:
schema = _load_actions_schema()
action_def = schema["$defs"]["action"]
props = action_def["properties"]
self.assertIn("hoverHint", props)
self.assertNotIn("hoverHint", action_def.get("required") or [])
self.assertEqual(props["hoverHint"].get("type"), "string")
def test_schema_defines_visible_when_on_input_property(self) -> None:
schema = _load_actions_schema()
prop_def = schema["$defs"]["inputProperty"]
props = prop_def["properties"]
self.assertIn("visibleWhen", props)
self.assertNotIn("visibleWhen", prop_def.get("required") or [])
# Must live on input property, not action top-level (≠ Policy L3).
action_props = schema["$defs"]["action"]["properties"]
self.assertNotIn("visibleWhen", action_props)
def test_default_manifest_omits_visible_when(self) -> None:
for action in _load_actions_manifest()["actions"]:
properties = ((action.get("inputSchema") or {}).get("properties")) or {}
for name, prop in properties.items():
self.assertNotIn(
"visibleWhen",
prop if isinstance(prop, dict) else {},
msg=f"{action['id']}.{name} default template should omit visibleWhen",
)
def test_visible_when_example_exists_and_uses_field_rules(self) -> None:
path = os.path.join(
get_skill_root(), "assets", "examples", "visible-when-action.example.json"
)
self.assertTrue(os.path.isfile(path), msg="visible-when example must exist")
with open(path, encoding="utf-8") as f:
example = json.load(f)
props = example["actions"][0]["inputSchema"]["properties"]
self.assertIn("visibleWhen", props["keyword"])
self.assertIn("visibleWhen", props["authorUrl"])
self.assertNotIn("visibleWhen", props["collectMode"])
def test_default_manifest_omits_hover_hint(self) -> None:
for action in _load_actions_manifest()["actions"]:
self.assertNotIn(
"hoverHint",
action,
msg=f"{action['id']} default template action should omit hoverHint",
)
def test_schema_defines_host_effects_and_steps(self) -> None:
schema = _load_actions_schema()
action_def = schema["$defs"]["action"]
props = action_def["properties"]
self.assertIn("hostEffects", props)
self.assertEqual(props["hostEffects"].get("$ref"), "#/$defs/hostEffects")
self.assertIn("steps", props)
effects = schema["$defs"]["hostEffects"]
self.assertIs(effects.get("additionalProperties"), False)
for key in ("onSuccess", "onFailure"):
self.assertIn(key, effects.get("properties") or {})
effect_ids = set(schema["$defs"]["hostEffectId"].get("enum") or [])
self.assertEqual(effect_ids, {"reloadTable", "refreshCatalog", "clearSelection"})
step = schema["$defs"]["actionStep"]
self.assertEqual(
set(step["properties"]["when"].get("enum") or []),
{"before", "afterSuccess", "afterFailure"},
)
self.assertEqual(
set(step["properties"]["do"].get("enum") or []),
{"deleteByFk", "deleteRows", "reloadTable", "refreshCatalog", "clearSelection"},
)
self.assertEqual(props["steps"].get("maxItems"), 8)
def test_manifest_validates_against_json_schema(self) -> None:
try:
import jsonschema
except ImportError:
self.skipTest("jsonschema not installed")
schema = _load_actions_schema()
manifest = _load_actions_manifest()
jsonschema.validate(instance=manifest, schema=schema)
def test_bind_structure_when_present(self) -> None:
manifest = _load_actions_manifest()
errors: list[str] = []
for action in manifest["actions"]:
if "bind" not in action:
continue
errors.extend(_validate_bind_tables(action["id"], action["bind"]))
self.assertEqual(errors, [], msg="\n".join(errors))
def test_toolbar_actions_require_bind_tables(self) -> None:
manifest = _load_actions_manifest()
errors: list[str] = []
for action in manifest["actions"]:
placements = action.get("placements") or []
if "toolbar" not in placements:
continue
bind = action.get("bind")
if bind is None:
errors.append(f"{action['id']}: toolbar placement requires bind.tables")
continue
errors.extend(_validate_bind_tables(action["id"], bind))
self.assertEqual(errors, [], msg="\n".join(errors))
def test_bindable_business_tables_helper_loads_template_db(self) -> None:
"""helper 本身必须可用:本技能有 5 张业务表,隔离库应可读且不含元数据表。"""
bindable = _load_bindable_business_tables()
self.assertEqual(
bindable,
{"invoice_records", "prereview_reports", "expense_standards", "taxid_whitelist", "holiday_calendar"},
)
self.assertNotIn("_jiangchang_tables", bindable)
self.assertNotIn("_jiangchang_columns", bindable)
def test_bind_tables_exist_in_physical_and_visible_metadata(self) -> None:
bindable = _load_bindable_business_tables()
manifest = _load_actions_manifest()
missing: list[str] = []
for action in manifest["actions"]:
bind = action.get("bind")
if not isinstance(bind, dict):
continue
for table in bind.get("tables") or []:
if table not in bindable:
missing.append(
f"{action['id']}: bind.tables entry {table!r} must exist as "
f"physical table AND _jiangchang_tables.visible=1 "
f"(bindable={sorted(bindable)})"
)
self.assertEqual(missing, [], msg="\n".join(missing))
def test_schema_allows_all_placement_execution_profile_combinations(self) -> None:
"""POLICY-SKILL-ACTION-004:6 placements × 2 profiles 必须全部通过 Schema。"""
try:
import jsonschema
except ImportError:
self.fail("jsonschema is required to validate placement×executionProfile orthogonality")
schema = _load_actions_schema()
placements = ("toolbar", "row", "batch", "cron", "agent", "skill-detail")
profiles = ("sync", "async")
for placement in placements:
for profile in profiles:
action: dict = {
"id": "probe-action",
"label": "正交探测",
"description": "验证 placements 与 executionProfile 无条件耦合",
"placements": [placement],
"executionProfile": profile,
"entrypoint": {"type": "cli", "command": "health", "args": []},
}
if placement == "toolbar":
action["bind"] = {"tables": ["demo_items"]}
manifest = {
"schemaVersion": 1,
"skill": "your-skill-slug",
"actions": [action],
}
with self.subTest(placement=placement, executionProfile=profile):
jsonschema.validate(instance=manifest, schema=schema)
if placement != "toolbar":
self.assertNotIn("bind", action)
def test_schema_rejects_toolbar_without_bind(self) -> None:
try:
import jsonschema
from jsonschema import ValidationError
except ImportError:
self.fail("jsonschema is required")
schema = _load_actions_schema()
manifest = {
"schemaVersion": 1,
"skill": "your-skill-slug",
"actions": [
{
"id": "probe-toolbar",
"label": "缺 bind 探测",
"description": "toolbar 无 bind 应被 Schema 拒绝",
"placements": ["toolbar"],
"executionProfile": "sync",
"entrypoint": {"type": "cli", "command": "health", "args": []},
}
],
}
with self.assertRaises(ValidationError):
jsonschema.validate(instance=manifest, schema=schema)
def test_schema_allows_bind_label_column(self) -> None:
try:
import jsonschema
except ImportError:
self.fail("jsonschema is required")
schema = _load_actions_schema()
bind_props = schema["$defs"]["bind"]["properties"]
self.assertIn("labelColumn", bind_props)
manifest = {
"schemaVersion": 1,
"skill": "your-skill-slug",
"actions": [
{
"id": "probe-label-column",
"label": "处理勾选",
"description": "验证 bind.labelColumn 可通过 Schema",
"placements": ["toolbar", "batch"],
"executionProfile": "sync",
"interaction": "confirm",
"bind": {
"tables": ["demo_items"],
"inputMapping": {"ids": "$selection.ids"},
"labelColumn": "display_name",
},
"entrypoint": {"type": "cli", "command": "health", "args": []},
}
],
}
jsonschema.validate(instance=manifest, schema=schema)
def test_no_execution_profile_placement_coupling_in_tests(self) -> None:
"""守护:本文件不得引入入口位置与 sync/async 的错误硬耦合断言。"""
path = os.path.join(get_skill_root(), "tests", "test_actions_manifest.py")
with open(path, encoding="utf-8") as f:
text = f.read()
# 拆开拼接,避免本测试源码自身触发字符串扫描。
forbidden_snippets = (
"toolbar " + "只能 async",
"toolbar only " + "async",
'toolbar"' + " and profile != " + '"async"',
'toolbar")' + " and profile != " + '"async"',
"toolbar must be " + "async",
"executionProfile" + " == " + '"async"' + " and " + '"toolbar"',
)
for snippet in forbidden_snippets:
self.assertNotIn(snippet, text)
def test_template_variables_have_input_schema(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
args = action.get("entrypoint", {}).get("args") or []
template_vars = _collect_template_vars(args)
if not template_vars:
continue
schema = action.get("inputSchema") or {}
properties = schema.get("properties") or {}
for var in template_vars:
self.assertIn(
var,
properties,
msg=f"{action['id']} uses {{{{{var}}}}} but inputSchema.properties missing it",
)
def test_required_fields_exist_in_properties(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
schema = action.get("inputSchema") or {}
required = schema.get("required") or []
properties = schema.get("properties") or {}
for field in required:
self.assertIn(
field,
properties,
msg=f"{action['id']} required field {field!r} missing from properties",
)
def test_sensitive_params_not_on_toolbar(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
props = (action.get("inputSchema") or {}).get("properties") or {}
has_sensitive = any(p.get("sensitive") for p in props.values())
if has_sensitive and "toolbar" in (action.get("placements") or []):
self.fail(f"{action['id']} has sensitive input but is on toolbar")
def test_confirmation_message_structure(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
confirmation = action.get("confirmation")
if confirmation is None:
continue
self.assertIsInstance(confirmation, dict)
message = confirmation.get("message")
self.assertTrue(str(message or "").strip(), msg=f"{action['id']} confirmation.message empty")
def test_manifest_has_no_specific_business_skill_names(self) -> None:
path = os.path.join(get_skill_root(), "assets", "actions.json")
with open(path, encoding="utf-8") as f:
raw = f.read().lower()
for term in _FORBIDDEN_BUSINESS_TERMS:
self.assertNotIn(term.lower(), raw, msg=f"actions.json must not contain {term!r}")
def test_actions_md_exists_and_links_manifest(self) -> None:
path = os.path.join(get_skill_root(), "references", "ACTIONS.md")
self.assertTrue(os.path.isfile(path))
with open(path, encoding="utf-8") as f:
text = f.read()
self.assertIn("actions.json", text)
self.assertIn("schemaVersion", text)
for term in _FORBIDDEN_BUSINESS_TERMS:
self.assertNotIn(term.lower(), text.lower(), msg=f"ACTIONS.md must not contain {term!r}")
def test_skill_actions_schema_file_exists(self) -> None:
path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json")
self.assertTrue(os.path.isfile(path))
with open(path, encoding="utf-8") as f:
schema = json.load(f)
self.assertEqual(schema.get("title"), "SkillActionManifest")
self.assertIn("actions", schema.get("properties", {}))
def test_skill_actions_schema_json_parses(self) -> None:
schema = _load_actions_schema()
self.assertEqual(schema["properties"]["schemaVersion"]["const"], 1)
self.assertTrue(schema.get("additionalProperties") is False)
self.assertIn("action", schema.get("$defs", {}))
def test_template_manifest_matches_schema_required_keys(self) -> None:
manifest = _load_actions_manifest()
schema = _load_actions_schema()
for key in _REQUIRED_MANIFEST_KEYS:
self.assertIn(key, manifest)
self.assertIn(key, schema.get("properties", {}))
for action in manifest["actions"]:
for key in _REQUIRED_ACTION_KEYS:
self.assertIn(key, action)
def test_template_example_does_not_use_reserved_action_fields(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
overlap = set(action.keys()) & _RESERVED_ACTION_FIELDS
self.assertFalse(
overlap,
msg=f"{action['id']} uses reserved action fields not supported in template Phase 1: {overlap}",
)
def test_default_manifest_does_not_place_health_on_toolbar(self) -> None:
"""脚手架默认健康类 Action 不应出现在数据管理 toolbar。"""
manifest = _load_actions_manifest()
for action in manifest["actions"]:
if action["id"] in {"health", "version", "config-path"}:
self.assertNotIn(
"toolbar",
action.get("placements") or [],
msg=f"{action['id']} must not be auto-placed on toolbar",
)
def test_actions_md_documents_strict_vs_host_boundary(self) -> None:
path = os.path.join(get_skill_root(), "references", "ACTIONS.md")
with open(path, encoding="utf-8") as f:
text = f.read()
for marker in (
"严格规范",
"向后兼容",
"Phase 1",
"bind.tables",
"concurrency",
"locks",
"row",
"batch",
"additionalProperties",
"placements",
"executionProfile",
"interaction",
"confirmation",
"数据管理技能按钮顺序",
"数组相对顺序",
"全部直接展示",
"左右分栏",
"行级入口",
"行右键",
"六职责正交",
"POLICY.md",
"hostEffects",
"steps",
"hoverHint",
"visibleWhen",
"字段条件显隐",
"reloadTable",
"afterSuccess",
"afterFailure",
"cellPresentation",
"CELL_PRESENTATION.md",
"labelColumn",
"已选择",
"revealInForm",
"映射只读",
):
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
self.assertNotIn(
"其余进「更多操作」",
text,
msg="ACTIONS.md must not document removed overflow more-menu truncation",
)
self.assertIn(
"新增 `row-context-menu` 等 placement",
text,
msg="ACTIONS.md must forbid inventing a separate row-context-menu placement",
)
placement_enum = _load_actions_schema().get("$defs", {}).get("placement", {}).get("enum") or []
self.assertNotIn(
"row-context-menu",
placement_enum,
msg="skill-actions.schema must not add row-context-menu to placements enum",
)
for forbidden in (
"数据管理 toolbar " + "只挂",
"toolbar " + "只放 async",
"toolbar)→ " + "只挂",
"只挂 **" + "async**",
):
self.assertNotIn(forbidden, text, msg=f"ACTIONS.md must not contain deprecated coupling: {forbidden!r}")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,129 +0,0 @@
# -*- coding: utf-8 -*-
"""adapter profile / 环境开关策略:默认不触碰真实 API 与真实 RPA。"""
from __future__ import annotations
import os
import unittest
from jiangchang_skill_core import config
from adapter_test_utils import (
ALLOWED_TEST_TARGETS,
FakeAdapter,
assert_no_real_network_env,
get_test_target,
should_skip_profile,
)
def _save_env(keys: tuple[str, ...]) -> dict[str, str | None]:
return {k: os.environ.get(k) for k in keys}
def _restore_env(saved: dict[str, str | None]) -> None:
for k, v in saved.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
config.reset_cache()
_ENV_KEYS = ("OPENCLAW_TEST_TARGET",)
class TestAdapterProfilePolicy(unittest.TestCase):
def tearDown(self) -> None:
config.reset_cache()
def test_get_test_target_defaults_to_unit(self) -> None:
saved = _save_env(_ENV_KEYS)
try:
for k in _ENV_KEYS:
os.environ.pop(k, None)
config.reset_cache()
self.assertEqual(get_test_target(), "unit")
finally:
_restore_env(saved)
def test_get_test_target_invalid_raises(self) -> None:
saved = _save_env(_ENV_KEYS)
try:
for k in _ENV_KEYS:
os.environ.pop(k, None)
os.environ["OPENCLAW_TEST_TARGET"] = "not_a_valid_target"
config.reset_cache()
with self.assertRaises(ValueError):
get_test_target()
finally:
_restore_env(saved)
def test_allowed_targets_contains_expected(self) -> None:
self.assertTrue({"unit", "mock", "simulator_api", "real_api"}.issubset(ALLOWED_TEST_TARGETS))
def test_simulator_api_runs_when_target_matches(self) -> None:
saved = _save_env(_ENV_KEYS)
try:
os.environ["OPENCLAW_TEST_TARGET"] = "simulator_api"
config.reset_cache()
skip, _ = should_skip_profile("simulator_api")
self.assertFalse(skip)
finally:
_restore_env(saved)
def test_unit_target_skips_simulator_and_real_profiles(self) -> None:
saved = _save_env(_ENV_KEYS)
try:
for k in _ENV_KEYS:
os.environ.pop(k, None)
config.reset_cache()
self.assertEqual(get_test_target(), "unit")
for prof in ("simulator_api", "simulator_rpa", "real_api", "real_rpa"):
skip, reason = should_skip_profile(prof)
self.assertTrue(skip, msg=f"{prof}: {reason}")
skip_mock, _ = should_skip_profile("mock")
self.assertFalse(skip_mock)
finally:
_restore_env(saved)
def test_real_api_runs_when_target_matches(self) -> None:
saved = _save_env(_ENV_KEYS)
try:
os.environ["OPENCLAW_TEST_TARGET"] = "real_api"
config.reset_cache()
skip, _ = should_skip_profile("real_api", write=True)
self.assertFalse(skip)
finally:
_restore_env(saved)
def test_real_rpa_runs_when_target_matches(self) -> None:
saved = _save_env(_ENV_KEYS)
try:
os.environ["OPENCLAW_TEST_TARGET"] = "real_rpa"
config.reset_cache()
skip, _ = should_skip_profile("real_rpa", write=True)
self.assertFalse(skip)
finally:
_restore_env(saved)
def test_fake_adapter_modes(self) -> None:
self.assertEqual(FakeAdapter("success").call({"a": 1})["success"], True)
with self.assertRaises(TimeoutError):
FakeAdapter("timeout").call({})
bad = FakeAdapter("invalid_response").call({})
self.assertNotIsInstance(bad, dict)
with self.assertRaises(PermissionError):
FakeAdapter("unauthorized").call({})
def test_assert_no_real_network_env(self) -> None:
saved = _save_env(_ENV_KEYS)
try:
os.environ.pop("OPENCLAW_TEST_TARGET", None)
config.reset_cache()
assert_no_real_network_env()
finally:
_restore_env(saved)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,93 +0,0 @@
# -*- coding: utf-8 -*-
"""Cell Presentation 契约:schema + 文档 + 示例。"""
from __future__ import annotations
import json
import os
import unittest
from util.runtime_paths import get_skill_root
def _load_schema() -> dict:
path = os.path.join(
get_skill_root(),
"assets",
"schemas",
"skill-cell-presentation.schema.json",
)
with open(path, encoding="utf-8") as f:
return json.load(f)
def _load_example() -> dict:
path = os.path.join(
get_skill_root(),
"assets",
"examples",
"skill-cell-presentation.example.json",
)
with open(path, encoding="utf-8") as f:
return json.load(f)
class TestCellPresentationContract(unittest.TestCase):
def test_schema_file_exists_and_parses(self) -> None:
schema = _load_schema()
self.assertEqual(schema.get("title"), "SkillCellPresentationDocument")
self.assertIs(schema.get("additionalProperties"), False)
self.assertEqual(schema["properties"]["schemaVersion"].get("const"), 1)
tones = set(schema["$defs"]["tone"].get("enum") or [])
self.assertEqual(tones, {"danger", "warning", "info", "success", "muted"})
def test_example_validates_against_schema(self) -> None:
try:
import jsonschema
except ImportError:
self.skipTest("jsonschema not installed")
jsonschema.validate(instance=_load_example(), schema=_load_schema())
def test_actions_schema_defines_cell_presentation_enable_block(self) -> None:
path = os.path.join(
get_skill_root(), "assets", "schemas", "skill-actions.schema.json"
)
with open(path, encoding="utf-8") as f:
actions_schema = json.load(f)
self.assertIn("cellPresentation", actions_schema["properties"])
self.assertIn("cellPresentationDeclaration", actions_schema["$defs"])
def test_docs_and_example_exist(self) -> None:
root = get_skill_root()
self.assertTrue(os.path.isfile(os.path.join(root, "references", "CELL_PRESENTATION.md")))
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", "examples", "skill-cell-presentation.example.json")
)
)
def test_cell_presentation_md_covers_contract(self) -> None:
path = os.path.join(get_skill_root(), "references", "CELL_PRESENTATION.md")
with open(path, encoding="utf-8") as f:
text = f.read()
for marker in (
"tone",
"danger",
"warning",
"alert",
"_jiangchang_cell_presentation",
"cellPresentation",
"派生",
"任意 CSS",
"用途不限",
"schemaVersion",
):
self.assertIn(marker, text, msg=f"CELL_PRESENTATION.md missing {marker!r}")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,113 +1,32 @@
# -*- coding: utf-8 -*-
"""通用 CLI 冒烟:用法、health、version、init-db(不触网、不深测 run 占位)。"""
from __future__ import annotations
"""CLI 冒烟测试:确保 main.py 能正常导入和显示帮助。"""
import io
import json
import subprocess
import sys
import os
import unittest
from contextlib import redirect_stderr, redirect_stdout
from _support import IsolatedDataRoot, platform_kit_version_patch
from jiangchang_skill_core import config
# scripts/ 已由 _support 注入 sys.path
from cli.app import main
from util.constants import SKILL_SLUG, SKILL_VERSION
class TestCliSmoke(unittest.TestCase):
def test_main_empty_argv_shows_usage_and_nonzero(self) -> None:
buf = io.StringIO()
err = io.StringIO()
with redirect_stdout(buf), redirect_stderr(err):
rc = main([])
self.assertEqual(rc, 1)
out = buf.getvalue()
self.assertIn("票据合规预审助手", out)
self.assertIn("health", out)
self.assertIn("init-db", out)
def test_health_zero(self) -> None:
old_record = os.environ.get("OPENCLAW_RECORD_VIDEO")
os.environ["OPENCLAW_RECORD_VIDEO"] = "0"
try:
buf = io.StringIO()
with platform_kit_version_patch(), redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = main(["health"])
self.assertEqual(rc, 0)
out = buf.getvalue()
self.assertIn("health:", out)
self.assertIn("python_executable:", out)
self.assertIn("platform_kit_version:", out)
self.assertIn("jiangchang_skill_core_file:", out)
finally:
if old_record is None:
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
else:
os.environ["OPENCLAW_RECORD_VIDEO"] = old_record
def test_version_json_and_matches_constants_slug(self) -> None:
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = main(["version"])
self.assertEqual(rc, 0)
payload = json.loads(buf.getvalue().strip())
self.assertIn("version", payload)
self.assertIn("skill", payload)
self.assertEqual(payload["skill"], SKILL_SLUG)
self.assertEqual(payload["version"], SKILL_VERSION)
def test_init_db_creates_database_and_is_idempotent(self) -> None:
with IsolatedDataRoot(user_id="_cli_init_db"):
config.reset_cache()
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = main(["init-db"])
self.assertEqual(rc, 0)
payload = json.loads(buf.getvalue().strip())
self.assertTrue(payload["ok"])
self.assertEqual(payload["skill"], SKILL_SLUG)
self.assertTrue(os.path.isfile(payload["db_path"]))
buf2 = io.StringIO()
with redirect_stdout(buf2), redirect_stderr(io.StringIO()):
rc2 = main(["init-db"])
self.assertEqual(rc2, 0)
payload2 = json.loads(buf2.getvalue().strip())
self.assertTrue(payload2["ok"])
self.assertEqual(payload2["db_path"], payload["db_path"])
def test_unknown_logs_subcommand_is_rejected(self) -> None:
buf = io.StringIO()
err = io.StringIO()
with redirect_stdout(buf), redirect_stderr(err):
with self.assertRaises(SystemExit) as caught:
main(["logs"])
self.assertNotEqual(caught.exception.code, 0)
def test_run_placeholder_returns_nonzero_without_network(self) -> None:
"""占位 run:RpaVideoSession 示范 + 模板提示;OPENCLAW_RECORD_VIDEO=0 时不启 ffmpeg。"""
old_auth = os.environ.pop("JIANGCHANG_AUTH_BASE_URL", None)
old_record = os.environ.get("OPENCLAW_RECORD_VIDEO")
os.environ["OPENCLAW_RECORD_VIDEO"] = "0"
try:
with IsolatedDataRoot(user_id="_cli_run"):
config.reset_cache()
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = main(["run"])
self.assertNotEqual(rc, 0)
self.assertIn("模板", buf.getvalue())
finally:
if old_auth is not None:
os.environ["JIANGCHANG_AUTH_BASE_URL"] = old_auth
if old_record is None:
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
else:
os.environ["OPENCLAW_RECORD_VIDEO"] = old_record
def test_main_import():
"""main.py 能正常导入。"""
scripts_dir = os.path.join(os.path.dirname(__file__), "..", "scripts")
result = subprocess.run(
[sys.executable, "-c", "import main"],
cwd=scripts_dir,
capture_output=True,
text=True,
timeout=10,
)
# 导入可能因为缺 jiangchang_skill_core 失败,但不应该有语法错误
assert result.returncode == 0 or "ModuleNotFoundError" in result.stderr
if __name__ == "__main__":
unittest.main()
def test_version_command():
"""version 命令能正常执行。"""
scripts_dir = os.path.join(os.path.dirname(__file__), "..", "scripts")
result = subprocess.run(
[sys.executable, "main.py", "version"],
cwd=scripts_dir,
capture_output=True,
text=True,
timeout=10,
)
assert "verify-invoice-default" in result.stdout or result.returncode != 0

View File

@@ -1,122 +0,0 @@
# -*- coding: utf-8 -*-
"""配置落盘、合并与读取优先级。"""
from __future__ import annotations
import io
import json
import os
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from _support import IsolatedDataRoot, get_skill_root, platform_kit_version_patch
from jiangchang_skill_core import config
from cli.app import main
from util.config_bootstrap import bootstrap_skill_config
from util.constants import SKILL_SLUG
_TEST_TARGET_KEY = "OPENCLAW_" + "TEST_TARGET"
def _env_line(key: str, value: str) -> str:
return f"{key}={value}\n"
class TestConfigBootstrap(unittest.TestCase):
def test_first_run_creates_user_env(self) -> None:
with IsolatedDataRoot(user_id="_cfg_test"):
config.reset_cache()
path = bootstrap_skill_config()
self.assertTrue(os.path.isfile(path))
self.assertTrue(path.replace("\\", "/").endswith(f"/{SKILL_SLUG}/.env"))
def test_existing_env_not_overwritten(self) -> None:
with IsolatedDataRoot(user_id="_cfg_keep"):
config.reset_cache()
path = bootstrap_skill_config()
with open(path, "w", encoding="utf-8") as f:
f.write(_env_line(_TEST_TARGET_KEY, "simulator_rpa"))
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False, suffix=".example") as tmp:
tmp.write(_env_line(_TEST_TARGET_KEY, "mock") + "HUMAN_WAIT_TIMEOUT=60\n")
tmp_path = tmp.name
try:
config.merge_missing_env_keys(tmp_path, path, comment_skill="test")
with open(path, encoding="utf-8") as f:
content = f.read()
self.assertIn(_env_line(_TEST_TARGET_KEY, "simulator_rpa").strip(), content)
self.assertNotIn(_env_line(_TEST_TARGET_KEY, "mock").strip(), content)
finally:
os.unlink(tmp_path)
config.reset_cache()
def test_merge_appends_missing_keys(self) -> None:
with IsolatedDataRoot(user_id="_cfg_merge"):
config.reset_cache()
path = bootstrap_skill_config()
with open(path, "w", encoding="utf-8") as f:
f.write(_env_line(_TEST_TARGET_KEY, "simulator_rpa"))
example = os.path.join(get_skill_root(), ".env.example")
added = config.merge_missing_env_keys(example, path, comment_skill="test")
self.assertIn("HUMAN_WAIT_TIMEOUT", added)
with open(path, encoding="utf-8") as f:
content = f.read()
self.assertIn("HUMAN_WAIT_TIMEOUT=180", content)
def test_config_priority_process_user_example(self) -> None:
with IsolatedDataRoot(user_id="_cfg_prio"):
config.reset_cache()
example = os.path.join(get_skill_root(), ".env.example")
path = config.ensure_env_file(SKILL_SLUG, example)
with open(path, "w", encoding="utf-8") as f:
f.write("PRIORITY_TEST_KEY=from_user\n")
config.reset_cache()
os.environ["PRIORITY_TEST_KEY"] = "from_process"
self.assertEqual(config.get("PRIORITY_TEST_KEY"), "from_process")
del os.environ["PRIORITY_TEST_KEY"]
config.reset_cache()
self.assertEqual(config.get("PRIORITY_TEST_KEY"), "from_user")
with open(path, "w", encoding="utf-8") as f:
f.write("# empty user\n")
config.reset_cache()
with open(example, encoding="utf-8") as f:
example_text = f.read()
self.assertIn(_env_line(_TEST_TARGET_KEY, "real_" + "rpa").strip(), example_text)
self.assertEqual(config.get(_TEST_TARGET_KEY), "real_" + "rpa")
def test_config_path_outputs_json(self) -> None:
with IsolatedDataRoot(user_id="_cfg_path"):
config.reset_cache()
bootstrap_skill_config()
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = main(["config-path"])
self.assertEqual(rc, 0)
payload = json.loads(buf.getvalue().strip())
self.assertEqual(payload["skill"], SKILL_SLUG)
self.assertTrue(payload["env_path"])
self.assertTrue(payload["example_path"].endswith(".env.example"))
def test_health_does_not_print_sensitive_plaintext(self) -> None:
with IsolatedDataRoot(user_id="_cfg_health"):
os.environ["OPENCLAW_RECORD_VIDEO"] = "0"
config.reset_cache()
path = bootstrap_skill_config()
with open(path, "a", encoding="utf-8") as f:
f.write("FAKE_SECRET_TOKEN=supersecret12345\n")
config.reset_cache()
buf = io.StringIO()
with platform_kit_version_patch(), redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = main(["health"])
self.assertEqual(rc, 0)
out = buf.getvalue()
self.assertNotIn("supersecret12345", out)
self.assertIn("env_path:", out)
self.assertIn("env_exists: True", out)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,479 +0,0 @@
# -*- coding: utf-8 -*-
"""通用数据管理契约测试(demo_items fixture,不含具体业务技能内容)。"""
from __future__ import annotations
import json
import sqlite3
import unittest
from _support import IsolatedDataRoot
DEMO_ITEMS_DDL = """
CREATE TABLE IF NOT EXISTS demo_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'draft',
generated_value TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TRIGGER IF NOT EXISTS demo_items_set_updated_at
AFTER UPDATE ON demo_items
FOR EACH ROW
BEGIN
UPDATE demo_items
SET updated_at = unixepoch()
WHERE id = NEW.id AND updated_at = OLD.updated_at;
END;
"""
DEMO_ITEMS_TABLE_METADATA = {
"table_name": "demo_items",
"display_name": "示例记录",
"description": "通用数据管理契约演示表",
"visible": 1,
"readonly": 0,
}
DEMO_ITEMS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
{"column_name": "id", "display_name": "编号", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "title", "display_name": "标题", "visible": 1, "searchable": 1, "editable": 1},
{"column_name": "description", "display_name": "描述", "visible": 1, "searchable": 1, "editable": 1},
{
"column_name": "status",
"display_name": "状态",
"visible": 1,
"searchable": 1,
"editable": 1,
"options_json": json.dumps(
[
{"value": "draft", "label": "草稿"},
{"value": "published", "label": "已发布"},
{"value": "archived", "label": "已归档"},
],
ensure_ascii=False,
),
},
{"column_name": "generated_value", "display_name": "生成值", "visible": 1, "searchable": 0, "editable": 0},
{
"column_name": "created_at",
"display_name": "创建时间",
"visible": 1,
"searchable": 0,
"editable": 0,
"display_type": "datetime_unix_seconds",
},
{
"column_name": "updated_at",
"display_name": "更新时间",
"visible": 1,
"searchable": 0,
"editable": 0,
"display_type": "datetime_unix_seconds",
},
)
def _init_demo_items_fixture(conn: sqlite3.Connection, *, prune: bool = True) -> None:
from db.display_metadata import (
init_display_metadata_tables,
list_physical_column_names,
seed_column_metadata_batch,
upsert_table_metadata,
)
cur = conn.cursor()
init_display_metadata_tables(cur)
cur.executescript(DEMO_ITEMS_DDL)
meta = DEMO_ITEMS_TABLE_METADATA
upsert_table_metadata(
cur,
table_name=str(meta["table_name"]),
display_name=str(meta["display_name"]),
description=str(meta["description"]),
visible=int(meta["visible"]),
readonly=int(meta["readonly"]),
)
physical = list_physical_column_names(cur, "demo_items")
seed_column_metadata_batch(
cur,
table_name="demo_items",
columns=DEMO_ITEMS_COLUMN_METADATA,
valid_column_names=physical if prune else None,
)
conn.commit()
class TestDataManagementContract(unittest.TestCase):
def test_demo_items_metadata_permissions(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
from db.display_metadata import (
get_column_editable,
get_table_readonly,
list_editable_business_columns,
table_allows_manual_create,
)
cur = conn.cursor()
self.assertEqual(get_table_readonly(cur, "demo_items"), 0)
self.assertEqual(get_column_editable(cur, "demo_items", "id"), 0)
self.assertEqual(get_column_editable(cur, "demo_items", "title"), 1)
self.assertEqual(get_column_editable(cur, "demo_items", "generated_value"), 0)
self.assertEqual(get_column_editable(cur, "demo_items", "created_at"), 0)
self.assertEqual(get_column_editable(cur, "demo_items", "updated_at"), 0)
editable_cols = list_editable_business_columns(cur, "demo_items")
self.assertEqual(editable_cols, ["title", "description", "status"])
self.assertTrue(table_allows_manual_create(cur, "demo_items"))
finally:
conn.close()
def test_demo_items_init_idempotent_no_duplicates(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
_init_demo_items_fixture(conn)
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM _jiangchang_tables WHERE table_name = 'demo_items'")
self.assertEqual(int(cur.fetchone()[0]), 1)
cur.execute("SELECT COUNT(*) FROM _jiangchang_columns WHERE table_name = 'demo_items'")
self.assertEqual(int(cur.fetchone()[0]), 7)
finally:
conn.close()
def test_reinit_updates_stale_editable_value(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
conn.execute(
"UPDATE _jiangchang_columns SET editable = 0 WHERE table_name = 'demo_items' AND column_name = 'title'"
)
conn.commit()
_init_demo_items_fixture(conn)
from db.display_metadata import get_column_editable
self.assertEqual(get_column_editable(conn.cursor(), "demo_items", "title"), 1)
finally:
conn.close()
def test_missing_table_metadata_detected(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
conn.execute("DELETE FROM _jiangchang_tables WHERE table_name = 'demo_items'")
conn.commit()
from db.display_metadata_validator import validate_display_metadata
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(any("demo_items" in err for err in report.errors))
finally:
conn.close()
def test_missing_column_metadata_detected(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
conn.execute(
"DELETE FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'title'"
)
conn.commit()
from db.display_metadata_validator import validate_display_metadata
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(any("demo_items.title" in err for err in report.errors))
finally:
conn.close()
def test_metadata_reference_to_missing_column_detected(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
conn.execute(
"""
INSERT INTO _jiangchang_columns
(table_name, column_name, display_name, visible, searchable, editable)
VALUES ('demo_items', 'not_exists', '不存在', 1, 1, 0)
"""
)
conn.commit()
from db.display_metadata_validator import validate_display_metadata
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(any("not_exists" in err for err in report.errors))
finally:
conn.close()
def test_empty_display_name_detected_for_visible_column(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
conn.execute(
"UPDATE _jiangchang_columns SET display_name = '' WHERE table_name = 'demo_items' AND column_name = 'title'"
)
conn.commit()
from db.display_metadata_validator import validate_display_metadata
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(any("display_name 为空" in err for err in report.errors))
finally:
conn.close()
def test_visible_columns_use_chinese_display_name(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
cur = conn.cursor()
cur.execute(
"""
SELECT column_name, display_name
FROM _jiangchang_columns
WHERE table_name = 'demo_items' AND visible = 1
"""
)
for column_name, display_name in cur.fetchall():
self.assertTrue(
any("\u4e00" <= ch <= "\u9fff" for ch in str(display_name)),
msg=f"{column_name} display_name should be Chinese: {display_name!r}",
)
finally:
conn.close()
def test_timestamp_fields_type_default_and_editable(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
cur = conn.cursor()
for column_name in ("created_at", "updated_at"):
cur.execute(f"PRAGMA table_info('demo_items')")
info = {row[1]: row for row in cur.fetchall()}
row = info[column_name]
self.assertIn("INT", str(row[2]).upper())
self.assertIn("unixepoch", str(row[4]).lower())
cur.execute(
"""
SELECT editable, display_type FROM _jiangchang_columns
WHERE table_name = 'demo_items' AND column_name = ?
""",
(column_name,),
)
editable, display_type = cur.fetchone()
self.assertEqual(int(editable), 0)
self.assertEqual(display_type, "datetime_unix_seconds")
finally:
conn.close()
def test_physical_order_matches_pragma_cid(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
from db.display_metadata import list_physical_column_names
cur = conn.cursor()
expected = [str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA]
physical = list_physical_column_names(cur, "demo_items")
self.assertEqual(physical, expected)
cur.execute("PRAGMA table_info('demo_items')")
cid_order = [row[1] for row in sorted(cur.fetchall(), key=lambda row: int(row[0]))]
self.assertEqual(cid_order, expected)
finally:
conn.close()
def test_options_json_parses_for_enum_field(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
cur = conn.cursor()
cur.execute(
"""
SELECT options_json FROM _jiangchang_columns
WHERE table_name = 'demo_items' AND column_name = 'status'
"""
)
raw = cur.fetchone()[0]
from db.display_metadata_validator import validate_options_json
errors, _ = validate_options_json(raw)
self.assertEqual(errors, [])
parsed = json.loads(raw)
self.assertEqual(len(parsed), 3)
self.assertEqual(parsed[0]["value"], "draft")
finally:
conn.close()
def test_system_table_no_editable_fields_not_manual_create(self) -> None:
conn = sqlite3.connect(":memory:")
try:
from db.display_metadata import (
init_display_metadata_tables,
seed_column_metadata_batch,
table_allows_manual_create,
upsert_table_metadata,
)
cur = conn.cursor()
init_display_metadata_tables(cur)
cur.execute(
"""
CREATE TABLE source_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL,
synced_at INTEGER NOT NULL DEFAULT (unixepoch())
)
"""
)
upsert_table_metadata(
cur,
table_name="source_records",
display_name="来源记录",
description="只由同步流程写入",
visible=1,
readonly=0,
)
seed_column_metadata_batch(
cur,
table_name="source_records",
columns=(
{"column_name": "id", "display_name": "编号", "editable": 0},
{"column_name": "item_id", "display_name": "记录编号", "editable": 0},
{"column_name": "synced_at", "display_name": "同步时间", "editable": 0, "display_type": "datetime_unix_seconds"},
),
)
conn.commit()
self.assertFalse(table_allows_manual_create(cur, "source_records"))
finally:
conn.close()
def test_table_readonly_blocks_manual_create_even_with_editable_fields(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
conn.execute("UPDATE _jiangchang_tables SET readonly = 1 WHERE table_name = 'demo_items'")
conn.commit()
from db.display_metadata import table_allows_manual_create
self.assertFalse(table_allows_manual_create(conn.cursor(), "demo_items"))
finally:
conn.close()
def test_prune_stale_column_metadata(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
conn.execute(
"""
INSERT INTO _jiangchang_columns
(table_name, column_name, display_name, visible, searchable, editable)
VALUES ('demo_items', 'removed_field', '已删除', 0, 0, 0)
"""
)
conn.commit()
from db.display_metadata import list_physical_column_names, seed_column_metadata_batch
cur = conn.cursor()
physical = list_physical_column_names(cur, "demo_items")
seed_column_metadata_batch(
cur,
table_name="demo_items",
columns=DEMO_ITEMS_COLUMN_METADATA,
valid_column_names=physical,
)
conn.commit()
cur.execute(
"SELECT 1 FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'removed_field'"
)
self.assertIsNone(cur.fetchone())
finally:
conn.close()
def test_seed_batch_without_valid_column_names_does_not_prune(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn, prune=False)
conn.execute(
"""
INSERT INTO _jiangchang_columns
(table_name, column_name, display_name, visible, searchable, editable)
VALUES ('demo_items', 'removed_field', '已删除', 0, 0, 0)
"""
)
conn.commit()
from db.display_metadata import seed_column_metadata_batch
seed_column_metadata_batch(
conn.cursor(),
table_name="demo_items",
columns=DEMO_ITEMS_COLUMN_METADATA,
)
conn.commit()
cur = conn.cursor()
cur.execute(
"SELECT 1 FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'removed_field'"
)
self.assertIsNotNone(cur.fetchone())
finally:
conn.close()
def test_seed_batch_accepts_list_columns(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn, prune=False)
from db.display_metadata import get_column_editable, seed_column_metadata_batch
columns = list(DEMO_ITEMS_COLUMN_METADATA)
columns[1] = {**columns[1], "display_name": "标题(列表)"}
seed_column_metadata_batch(
conn.cursor(),
table_name="demo_items",
columns=columns,
valid_column_names=[str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA],
)
conn.commit()
cur = conn.cursor()
cur.execute(
"SELECT display_name FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'title'"
)
self.assertEqual(cur.fetchone()[0], "标题(列表)")
self.assertEqual(get_column_editable(cur, "demo_items", "title"), 1)
finally:
conn.close()
def test_prune_does_not_remove_existing_column_metadata(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
from db.display_metadata import get_column_editable, seed_column_metadata_batch
cur = conn.cursor()
before = get_column_editable(cur, "demo_items", "title")
seed_column_metadata_batch(
cur,
table_name="demo_items",
columns=DEMO_ITEMS_COLUMN_METADATA,
valid_column_names=[str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA],
)
conn.commit()
self.assertEqual(get_column_editable(cur, "demo_items", "title"), before)
finally:
conn.close()
def test_demo_items_validation_passes(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
from db.display_metadata_validator import validate_display_metadata
report = validate_display_metadata(conn)
self.assertTrue(report.ok, msg="; ".join(report.errors))
finally:
conn.close()
if __name__ == "__main__":
unittest.main()

View File

@@ -1,42 +0,0 @@
# -*- coding: utf-8 -*-
"""init_db:幂等创建元数据表与本技能业务表。"""
from __future__ import annotations
import unittest
from _support import IsolatedDataRoot
_BUSINESS_TABLES = {"expense_standards", "holiday_calendar", "invoice_records", "prereview_reports", "taxid_whitelist"}
class TestDbSmoke(unittest.TestCase):
def test_init_db_idempotent_metadata_only(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata import METADATA_TABLES, iter_user_tables
init_db()
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
)
tables = {str(row[0]) for row in cur.fetchall()}
self.assertIn("_jiangchang_tables", tables)
self.assertIn("_jiangchang_columns", tables)
self.assertNotIn("task_logs", tables)
self.assertEqual(
sorted(iter_user_tables(cur)),
sorted(_BUSINESS_TABLES),
)
leftover = tables - METADATA_TABLES - _BUSINESS_TABLES
self.assertEqual(leftover, set())
finally:
conn.close()
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load Diff

View File

@@ -1,815 +0,0 @@
# -*- coding: utf-8 -*-
"""数据管理展示元数据:幂等初始化、中文名称、PRAGMA 字段顺序、校验规则。"""
from __future__ import annotations
import os
import sqlite3
import unittest
from _support import (
DEMO_ITEMS_COLUMN_METADATA,
DEMO_ITEMS_TABLE,
IsolatedDataRoot,
get_skill_root,
seed_demo_items,
)
class TestDisplayMetadata(unittest.TestCase):
def test_init_db_metadata_idempotent(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
init_db()
init_db()
conn = get_conn()
try:
cur = conn.cursor()
# 本技能 init_db 建 5 张业务表(发票登记/初审意见/经费标准库/税号白名单/节假日)
cur.execute("SELECT COUNT(*) FROM _jiangchang_tables")
self.assertEqual(int(cur.fetchone()[0]), 5)
cur.execute("SELECT COUNT(*) FROM _jiangchang_columns")
self.assertGreaterEqual(int(cur.fetchone()[0]), 40)
cur.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_logs'"
)
self.assertIsNone(cur.fetchone())
finally:
conn.close()
def test_demo_items_fixture_has_chinese_names(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
conn.commit()
cur.execute(
"SELECT display_name, readonly FROM _jiangchang_tables WHERE table_name = ?",
(DEMO_ITEMS_TABLE,),
)
display_name, readonly = cur.fetchone()
self.assertEqual(display_name, "示例记录")
self.assertEqual(int(readonly), 0)
cur.execute(
"""
SELECT column_name, display_name, editable
FROM _jiangchang_columns
WHERE table_name = ?
ORDER BY column_name
""",
(DEMO_ITEMS_TABLE,),
)
by_name = {row[0]: (row[1], row[2]) for row in cur.fetchall()}
self.assertEqual(by_name["status"][0], "状态")
self.assertEqual(int(by_name["status"][1]), 1)
self.assertEqual(by_name["title"][0], "标题")
self.assertEqual(by_name["created_at"][0], "创建时间")
self.assertEqual(int(by_name["created_at"][1]), 0)
finally:
conn.close()
def test_readonly_table_not_manual_create_in_host_ui(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata import table_allows_manual_create
init_db()
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur, readonly=1)
conn.commit()
self.assertFalse(table_allows_manual_create(cur, DEMO_ITEMS_TABLE))
finally:
conn.close()
def test_demo_items_physical_order_matches_pragma_cid(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata import list_physical_column_names
init_db()
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
conn.commit()
physical = list_physical_column_names(cur, DEMO_ITEMS_TABLE)
expected = [str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA]
self.assertEqual(physical, expected)
cur.execute("PRAGMA table_info('demo_items')")
cid_order = [row[1] for row in sorted(cur.fetchall(), key=lambda row: int(row[0]))]
self.assertEqual(cid_order, expected)
finally:
conn.close()
def test_seed_sets_display_order_null(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
conn.commit()
cur.execute(
"""
SELECT column_name, display_order
FROM _jiangchang_columns
WHERE table_name = ?
""",
(DEMO_ITEMS_TABLE,),
)
for column_name, display_order in cur.fetchall():
self.assertIsNone(
display_order,
msg=f"{column_name} display_order should be NULL",
)
finally:
conn.close()
def test_reseed_clears_stale_display_order(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
conn.execute(
"""
UPDATE _jiangchang_columns
SET display_order = 99
WHERE table_name = ? AND column_name = 'id'
""",
(DEMO_ITEMS_TABLE,),
)
conn.commit()
seed_demo_items(cur)
conn.commit()
cur.execute(
"""
SELECT display_order FROM _jiangchang_columns
WHERE table_name = ? AND column_name = 'id'
""",
(DEMO_ITEMS_TABLE,),
)
self.assertIsNone(cur.fetchone()[0])
finally:
conn.close()
def test_legacy_display_order_does_not_change_pragma_physical_order(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata import list_physical_column_names
init_db()
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
conn.commit()
cur.execute(
"""
UPDATE _jiangchang_columns
SET display_order = 1
WHERE table_name = ? AND column_name = 'updated_at'
""",
(DEMO_ITEMS_TABLE,),
)
cur.execute(
"""
UPDATE _jiangchang_columns
SET display_order = 99
WHERE table_name = ? AND column_name = 'id'
""",
(DEMO_ITEMS_TABLE,),
)
conn.commit()
physical = list_physical_column_names(cur, DEMO_ITEMS_TABLE)
self.assertEqual(physical[0], "id")
self.assertEqual(physical[-1], "updated_at")
finally:
conn.close()
def test_metadata_tables_are_not_user_tables(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata import iter_user_tables
init_db()
conn = get_conn()
try:
tables = list(iter_user_tables(conn.cursor()))
# 本技能有 5 张业务表
self.assertEqual(
sorted(tables),
["expense_standards", "holiday_calendar", "invoice_records", "prereview_reports", "taxid_whitelist"],
)
finally:
conn.close()
def test_template_database_validation_passes(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata_validator import validate_template_database
init_db()
conn = get_conn()
try:
report = validate_template_database(conn)
self.assertTrue(report.ok, msg="; ".join(report.errors))
finally:
conn.close()
def test_validate_rejects_sqlite_foreign_keys_on_business_tables(self) -> None:
from db.display_metadata import (
_JIANGCHANG_COLUMNS_DDL,
_JIANGCHANG_TABLES_DDL,
)
from db.display_metadata_validator import validate_display_metadata
conn = sqlite3.connect(":memory:")
try:
conn.execute(_JIANGCHANG_TABLES_DDL)
conn.execute(_JIANGCHANG_COLUMNS_DDL)
conn.execute("CREATE TABLE parent_rows (id INTEGER PRIMARY KEY, title TEXT)")
conn.execute(
"""
CREATE TABLE child_rows (
id INTEGER PRIMARY KEY,
parent_id INTEGER NOT NULL,
FOREIGN KEY (parent_id) REFERENCES parent_rows(id)
)
"""
)
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(
any("FOREIGN KEY" in err or "REFERENCES" in err for err in report.errors),
msg=f"expected FK error, got: {report.errors}",
)
self.assertTrue(any("child_rows" in err for err in report.errors))
finally:
conn.close()
def test_validate_missing_both_metadata_tables(self) -> None:
conn = sqlite3.connect(":memory:")
try:
conn.execute("CREATE TABLE demo_items (id INTEGER PRIMARY KEY, title TEXT)")
from db.display_metadata_validator import validate_display_metadata
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertIn("缺少数据管理元数据表 _jiangchang_tables", report.errors)
self.assertIn("缺少数据管理元数据表 _jiangchang_columns", report.errors)
finally:
conn.close()
def test_validate_missing_jiangchang_tables_only(self) -> None:
from db.display_metadata import _JIANGCHANG_COLUMNS_DDL
from db.display_metadata_validator import validate_display_metadata
conn = sqlite3.connect(":memory:")
try:
conn.execute(_JIANGCHANG_COLUMNS_DDL)
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertIn("缺少数据管理元数据表 _jiangchang_tables", report.errors)
self.assertNotIn("缺少数据管理元数据表 _jiangchang_columns", report.errors)
finally:
conn.close()
def test_validate_missing_jiangchang_columns_only(self) -> None:
from db.display_metadata import _JIANGCHANG_TABLES_DDL
from db.display_metadata_validator import validate_display_metadata
conn = sqlite3.connect(":memory:")
try:
conn.execute(_JIANGCHANG_TABLES_DDL)
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertIn("缺少数据管理元数据表 _jiangchang_columns", report.errors)
self.assertNotIn("缺少数据管理元数据表 _jiangchang_tables", report.errors)
finally:
conn.close()
def test_missing_table_metadata_is_reported(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata_validator import validate_display_metadata
init_db()
conn = get_conn()
try:
conn.execute("CREATE TABLE demo_items (id INTEGER PRIMARY KEY, title TEXT)")
conn.execute("DELETE FROM _jiangchang_tables WHERE table_name = 'demo_items'")
conn.commit()
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(any("demo_items" in err for err in report.errors))
finally:
conn.close()
def test_missing_column_metadata_is_reported(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata_validator import validate_display_metadata
init_db()
conn = get_conn()
try:
seed_demo_items(conn.cursor())
conn.execute(
"DELETE FROM _jiangchang_columns WHERE table_name = ? AND column_name = 'status'",
(DEMO_ITEMS_TABLE,),
)
conn.commit()
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(any("demo_items.status" in err for err in report.errors))
finally:
conn.close()
def test_metadata_reference_to_missing_column_is_reported(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata_validator import validate_display_metadata
init_db()
conn = get_conn()
try:
seed_demo_items(conn.cursor())
conn.execute(
"""
INSERT INTO _jiangchang_columns
(table_name, column_name, display_name, visible, searchable, editable)
VALUES (?, 'not_exists', '不存在', 1, 1, 1)
""",
(DEMO_ITEMS_TABLE,),
)
conn.commit()
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(any("not_exists" in err for err in report.errors))
finally:
conn.close()
def test_ordering_test_table_not_alphabetized(self) -> None:
conn = sqlite3.connect(":memory:")
try:
conn.executescript(
"""
CREATE TABLE ordering_test (
z_field TEXT,
id INTEGER PRIMARY KEY,
a_field TEXT,
created_at INTEGER,
updated_at INTEGER
);
"""
)
from db.display_metadata import list_physical_column_names
physical = list_physical_column_names(conn.cursor(), "ordering_test")
self.assertEqual(physical, ["z_field", "id", "a_field", "created_at", "updated_at"])
finally:
conn.close()
def test_skill_frontmatter_validation_for_template(self) -> None:
from db.display_metadata_validator import validate_skill_frontmatter
report = validate_skill_frontmatter(get_skill_root(), allow_template_placeholders=True)
self.assertTrue(report.ok, msg="; ".join(report.errors))
def test_skill_frontmatter_rejects_english_placeholder_name(self) -> None:
import os
import tempfile
from db.display_metadata_validator import validate_skill_frontmatter
with tempfile.TemporaryDirectory() as tmp:
skill_md = """---
name: My Skill
description: demo
metadata:
openclaw:
slug: query-demo-info
---
"""
with open(os.path.join(tmp, "SKILL.md"), "w", encoding="utf-8") as f:
f.write(skill_md)
report = validate_skill_frontmatter(tmp, allow_template_placeholders=False)
self.assertFalse(report.ok)
self.assertTrue(any("name" in err for err in report.errors))
def test_column_order_warning_for_created_at_in_middle(self) -> None:
from db.display_metadata_validator import column_order_warnings
warnings = column_order_warnings(
"bad_order",
["created_at", "id", "title", "updated_at"],
)
self.assertTrue(any("id" in item for item in warnings))
def test_generated_column_editable_must_be_zero(self) -> None:
conn = sqlite3.connect(":memory:")
try:
conn.executescript(
"""
CREATE TABLE gen_demo (
id INTEGER PRIMARY KEY,
base_value INTEGER NOT NULL,
generated_value INTEGER GENERATED ALWAYS AS (base_value * 2) STORED
);
"""
)
from db.display_metadata import init_display_metadata_tables, upsert_column_metadata
from db.display_metadata_validator import validate_column_permissions
cur = conn.cursor()
init_display_metadata_tables(cur)
upsert_column_metadata(
cur,
table_name="gen_demo",
column_name="generated_value",
display_name="生成值",
editable=1,
)
conn.commit()
errors, _ = validate_column_permissions(cur, "gen_demo")
self.assertTrue(any("generated" in err for err in errors))
finally:
conn.close()
def test_blob_column_editable_must_be_zero(self) -> None:
conn = sqlite3.connect(":memory:")
try:
conn.execute(
"CREATE TABLE blob_demo (id INTEGER PRIMARY KEY, payload BLOB NOT NULL)"
)
from db.display_metadata import init_display_metadata_tables, upsert_column_metadata
from db.display_metadata_validator import validate_column_permissions
cur = conn.cursor()
init_display_metadata_tables(cur)
upsert_column_metadata(
cur,
table_name="blob_demo",
column_name="payload",
display_name="载荷",
editable=1,
)
conn.commit()
errors, _ = validate_column_permissions(cur, "blob_demo")
self.assertTrue(any("BLOB" in err for err in errors))
finally:
conn.close()
def test_primary_key_editable_must_be_zero(self) -> None:
conn = sqlite3.connect(":memory:")
try:
conn.execute("CREATE TABLE pk_demo (id INTEGER PRIMARY KEY, title TEXT)")
from db.display_metadata import init_display_metadata_tables, upsert_column_metadata
from db.display_metadata_validator import validate_column_permissions
cur = conn.cursor()
init_display_metadata_tables(cur)
upsert_column_metadata(
cur,
table_name="pk_demo",
column_name="id",
display_name="编号",
editable=1,
)
conn.commit()
errors, _ = validate_column_permissions(cur, "pk_demo")
self.assertTrue(any("主键" in err for err in errors))
finally:
conn.close()
def test_readonly_table_with_editable_fields_warns(self) -> None:
conn = sqlite3.connect(":memory:")
try:
conn.executescript(
"""
CREATE TABLE warn_demo (id INTEGER PRIMARY KEY, title TEXT);
"""
)
from db.display_metadata import (
init_display_metadata_tables,
upsert_column_metadata,
upsert_table_metadata,
)
from db.display_metadata_validator import validate_column_permissions
cur = conn.cursor()
init_display_metadata_tables(cur)
upsert_table_metadata(cur, table_name="warn_demo", display_name="警告演示", readonly=1)
upsert_column_metadata(
cur,
table_name="warn_demo",
column_name="title",
display_name="标题",
editable=1,
)
conn.commit()
_, warnings = validate_column_permissions(cur, "warn_demo")
self.assertTrue(any("readonly=1" in item for item in warnings))
finally:
conn.close()
def test_writable_table_without_editable_fields_warns(self) -> None:
conn = sqlite3.connect(":memory:")
try:
conn.executescript(
"""
CREATE TABLE warn_demo2 (id INTEGER PRIMARY KEY, title TEXT);
"""
)
from db.display_metadata import (
init_display_metadata_tables,
upsert_column_metadata,
upsert_table_metadata,
)
from db.display_metadata_validator import validate_column_permissions
cur = conn.cursor()
init_display_metadata_tables(cur)
upsert_table_metadata(cur, table_name="warn_demo2", display_name="警告演示二", readonly=0)
upsert_column_metadata(
cur,
table_name="warn_demo2",
column_name="title",
display_name="标题",
editable=0,
)
conn.commit()
_, warnings = validate_column_permissions(cur, "warn_demo2")
self.assertTrue(any("readonly=0" in item and "editable=0" in item for item in warnings))
finally:
conn.close()
def test_enum_column_without_searchable_warns(self) -> None:
conn = sqlite3.connect(":memory:")
try:
conn.executescript(
"""
CREATE TABLE filter_demo (id INTEGER PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 1);
"""
)
from db.display_metadata import (
init_display_metadata_tables,
upsert_column_metadata,
upsert_table_metadata,
)
from db.display_metadata_validator import validate_filter_search_metadata
cur = conn.cursor()
init_display_metadata_tables(cur)
upsert_table_metadata(cur, table_name="filter_demo", display_name="筛选演示", readonly=0)
upsert_column_metadata(
cur,
table_name="filter_demo",
column_name="enabled",
display_name="是否启用",
visible=1,
searchable=0,
editable=1,
display_type="boolean",
options_json='[{"value":0,"label":"停用"},{"value":1,"label":"启用"}]',
)
conn.commit()
_, warnings = validate_filter_search_metadata(cur, "filter_demo")
self.assertTrue(any("searchable=1" in item for item in warnings))
finally:
conn.close()
def test_schema_md_documents_readonly_and_seed_helpers(self) -> None:
path = os.path.join(get_skill_root(), "references", "SCHEMA.md")
with open(path, encoding="utf-8") as f:
text = f.read()
self.assertIn("readonly = 1", text)
self.assertIn("seed_column_metadata_batch", text)
self.assertIn("valid_column_names", text)
def test_default_visible_columns_json_upsert_and_migrate(self) -> None:
conn = sqlite3.connect(":memory:")
try:
from db.display_metadata import (
init_display_metadata_tables,
upsert_table_metadata,
)
cur = conn.cursor()
# Simulate pre-1.0.79 table without the new column.
cur.execute(
"""
CREATE TABLE _jiangchang_tables (
table_name TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT,
sort_order INTEGER,
visible INTEGER NOT NULL DEFAULT 1,
readonly INTEGER NOT NULL DEFAULT 0,
default_sort_json TEXT
)
"""
)
init_display_metadata_tables(cur)
cur.execute("PRAGMA table_info('_jiangchang_tables')")
cols = {str(row[1]) for row in cur.fetchall()}
self.assertIn("default_visible_columns_json", cols)
payload = '["stat_type","period","stage"]'
upsert_table_metadata(
cur,
table_name="timeliness_stats",
display_name="时效统计",
readonly=1,
default_visible_columns_json=payload,
)
conn.commit()
cur.execute(
"""
SELECT default_visible_columns_json
FROM _jiangchang_tables
WHERE table_name = 'timeliness_stats'
"""
)
self.assertEqual(cur.fetchone()[0], payload)
finally:
conn.close()
def test_grid_help_upsert_and_migrate_from_legacy_schema(self) -> None:
conn = sqlite3.connect(":memory:")
try:
from db.display_metadata import (
init_display_metadata_tables,
upsert_column_metadata,
upsert_table_metadata,
)
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE _jiangchang_tables (
table_name TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT,
sort_order INTEGER,
visible INTEGER NOT NULL DEFAULT 1,
readonly INTEGER NOT NULL DEFAULT 0,
default_sort_json TEXT,
default_visible_columns_json TEXT
)
"""
)
cur.execute(
"""
CREATE TABLE _jiangchang_columns (
table_name TEXT NOT NULL,
column_name TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT,
display_order INTEGER,
visible INTEGER NOT NULL DEFAULT 1,
searchable INTEGER NOT NULL DEFAULT 1,
editable INTEGER NOT NULL DEFAULT 1,
display_type TEXT,
options_json TEXT,
PRIMARY KEY (table_name, column_name)
)
"""
)
init_display_metadata_tables(cur)
cur.execute("PRAGMA table_info('_jiangchang_tables')")
self.assertIn("grid_help", {str(row[1]) for row in cur.fetchall()})
cur.execute("PRAGMA table_info('_jiangchang_columns')")
self.assertIn("grid_help", {str(row[1]) for row in cur.fetchall()})
upsert_table_metadata(
cur,
table_name="demo_items",
display_name="示例记录",
description="表单备注,不应出现在表格横幅",
grid_help="表格横幅说明",
)
upsert_column_metadata(
cur,
table_name="demo_items",
column_name="title",
display_name="标题",
description="填标题",
grid_help="列头问号说明",
)
upsert_table_metadata(
cur,
table_name="empty_help",
display_name="无表格说明",
)
conn.commit()
cur.execute(
"""
SELECT description, grid_help
FROM _jiangchang_tables
WHERE table_name = 'demo_items'
"""
)
self.assertEqual(cur.fetchone(), ("表单备注,不应出现在表格横幅", "表格横幅说明"))
cur.execute(
"""
SELECT description, grid_help
FROM _jiangchang_columns
WHERE table_name = 'demo_items' AND column_name = 'title'
"""
)
self.assertEqual(cur.fetchone(), ("填标题", "列头问号说明"))
cur.execute(
"""
SELECT grid_help FROM _jiangchang_tables
WHERE table_name = 'empty_help'
"""
)
self.assertTrue(not cur.fetchone()[0])
finally:
conn.close()
def test_drop_deprecated_user_table_removes_physical_and_metadata(self) -> None:
conn = sqlite3.connect(":memory:")
try:
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE _jiangchang_tables (
table_name TEXT PRIMARY KEY,
display_name TEXT,
description TEXT,
sort_order INTEGER,
visible INTEGER,
readonly INTEGER
)
"""
)
cur.execute(
"""
CREATE TABLE _jiangchang_columns (
table_name TEXT,
column_name TEXT,
display_name TEXT,
description TEXT,
display_order INTEGER,
visible INTEGER,
searchable INTEGER,
editable INTEGER,
display_type TEXT,
options_json TEXT,
PRIMARY KEY (table_name, column_name)
)
"""
)
cur.execute(
"""
CREATE TABLE task_logs (
id INTEGER PRIMARY KEY,
status TEXT,
created_at INTEGER,
updated_at INTEGER
)
"""
)
cur.execute(
"INSERT INTO _jiangchang_tables VALUES ('task_logs', '任务日志', NULL, 1, 1, 0)"
)
cur.execute(
"INSERT INTO _jiangchang_columns VALUES "
"('task_logs', 'id', '编号', NULL, 1, 1, 1, 0, 'text', NULL)"
)
cur.execute("INSERT INTO task_logs (status) VALUES ('success')")
from db.display_metadata import drop_deprecated_user_table, iter_user_tables
drop_deprecated_user_table(cur, "task_logs")
cur.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_logs'"
)
self.assertIsNone(cur.fetchone())
cur.execute(
"SELECT COUNT(*) FROM _jiangchang_tables WHERE table_name='task_logs'"
)
self.assertEqual(int(cur.fetchone()[0]), 0)
self.assertNotIn("task_logs", list(iter_user_tables(cur)))
finally:
conn.close()
if __name__ == "__main__":
unittest.main()

View File

@@ -1,737 +0,0 @@
# -*- 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, "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",
"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",
"孤儿物理表",
):
self.assertIn(marker, text, msg=f"SCHEMA.md missing {marker!r}")
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",
"数据管理新增/编辑",
"_jiangchang_columns.description",
"datetime_unix_seconds",
"SCHEMA.md",
"字段条件显隐",
"visibleWhen",
"grid_help",
"列筛选",
):
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
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)
checklist = self._read("development/CHECKLIST.md")
self.assertIn("A4-7", checklist)
self.assertIn("visibleWhen", checklist)
self.assertIn("A4-8", checklist)
self.assertIn("revealInForm", checklist)
runtime = self._read("development/SKILL_ACTION_RUNTIME.md")
self.assertIn("visibleWhen", runtime)
self.assertIn("revealInForm", runtime)
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()

View File

@@ -1,68 +0,0 @@
# -*- coding: utf-8 -*-
"""通过 subprocess 调用 ``python scripts/main.py``,验证真实入口(非仅 import main)。"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import unittest
from _support import IsolatedDataRoot, get_skill_root
import importlib.metadata as metadata
from jiangchang_skill_core import version_ge
from util.constants import PLATFORM_KIT_MIN_VERSION
def _platform_kit_meets_template_minimum() -> bool:
try:
installed = metadata.version("jiangchang-platform-kit")
except metadata.PackageNotFoundError:
return False
return version_ge(installed, PLATFORM_KIT_MIN_VERSION)
def _run_main(args: list[str], timeout: int = 15) -> subprocess.CompletedProcess[str]:
root = get_skill_root()
main_py = os.path.join(root, "scripts", "main.py")
env = os.environ.copy()
env.setdefault("PYTHONIOENCODING", "utf-8")
env["PYTHONDONTWRITEBYTECODE"] = "1"
return subprocess.run(
[sys.executable, main_py, *args],
cwd=root,
env=env,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
class TestEntrypointSubprocess(unittest.TestCase):
@unittest.skipUnless(
_platform_kit_meets_template_minimum(),
f"installed jiangchang-platform-kit below template minimum {PLATFORM_KIT_MIN_VERSION}",
)
def test_health_exit_zero(self) -> None:
with IsolatedDataRoot():
cp = _run_main(["health"])
self.assertEqual(cp.returncode, 0, msg=cp.stderr + cp.stdout)
def test_version_json_and_skill_slug(self) -> None:
with IsolatedDataRoot():
cp = _run_main(["version"])
self.assertEqual(cp.returncode, 0, msg=cp.stderr + cp.stdout)
payload = json.loads(cp.stdout.strip())
self.assertIn("version", payload)
self.assertIn("skill", payload)
from util.constants import SKILL_SLUG
self.assertEqual(payload["skill"], SKILL_SLUG)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,178 +0,0 @@
# -*- coding: utf-8 -*-
"""health / runtime 诊断:验证委托 platform-kit 公共 API。"""
from __future__ import annotations
import io
import os
import unittest
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from unittest.mock import patch
from _support import IsolatedDataRoot, get_skill_root, platform_kit_version_patch
import jiangchang_skill_core
from cli.app import main
from jiangchang_skill_core import (
collect_runtime_diagnostics,
format_runtime_health_lines,
is_jiangchang_skill_core_from_skill_tree,
runtime_diagnostics_dict,
)
from util.config_bootstrap import bootstrap_skill_config
from util.constants import PLATFORM_KIT_MIN_VERSION, SKILL_SLUG
from util.runtime_paths import get_skill_root as skill_root_path
def _collect_diag():
return collect_runtime_diagnostics(
skill_slug=SKILL_SLUG,
platform_kit_min_version=PLATFORM_KIT_MIN_VERSION,
skill_root=skill_root_path(),
)
class TestHealthRuntimeDiagnostics(unittest.TestCase):
def test_collect_runtime_diagnostics_fields(self) -> None:
with IsolatedDataRoot(user_id="_health_rt"):
os.environ["OPENCLAW_RECORD_VIDEO"] = "0"
diag = _collect_diag()
payload = runtime_diagnostics_dict(diag)
for key in (
"skill_slug",
"python_executable",
"platform_kit_version",
"jiangchang_skill_core_file",
"resolved_data_root",
"media_assets_root",
"ffmpeg_available",
"background_music_mp3_count",
"runtime_issues",
):
self.assertIn(key, payload)
self.assertEqual(diag.skill_slug, SKILL_SLUG)
self.assertEqual(diag.platform_kit_min_version, PLATFORM_KIT_MIN_VERSION)
def test_format_runtime_health_lines_contains_core_fields(self) -> None:
diag = _collect_diag()
text = "\n".join(format_runtime_health_lines(diag))
self.assertIn("skill_slug:", text)
self.assertIn("python_executable:", text)
self.assertIn("platform_kit_version:", text)
self.assertIn("jiangchang_skill_core_file:", text)
self.assertIn("media_assets_root:", text)
self.assertIn("background_music_mp3_count:", text)
def test_health_cli_prints_runtime_diagnostics(self) -> None:
with IsolatedDataRoot(user_id="_health_cli"):
os.environ["OPENCLAW_RECORD_VIDEO"] = "0"
bootstrap_skill_config()
buf = io.StringIO()
with platform_kit_version_patch(), redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = main(["health"])
out = buf.getvalue()
self.assertEqual(rc, 0, msg=out)
self.assertIn("python_executable:", out)
self.assertIn("platform_kit_version:", out)
self.assertIn("jiangchang_skill_core_file:", out)
self.assertIn("media_assets_root:", out)
self.assertIn("background_music_mp3_count:", out)
self.assertIn("env_path:", out)
self.assertIn("example_path:", out)
def test_health_fails_when_platform_kit_missing(self) -> None:
with IsolatedDataRoot(user_id="_health_no_pkg"):
os.environ["OPENCLAW_RECORD_VIDEO"] = "0"
bootstrap_skill_config()
with patch(
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
return_value=None,
):
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = main(["health"])
self.assertEqual(rc, 1)
self.assertIn("platform_kit_not_installed", buf.getvalue())
def test_background_music_issue_is_warning_not_fatal(self) -> None:
from jiangchang_skill_core.media_assets import MediaAssetsStatus
with IsolatedDataRoot(user_id="_health_music_warn"):
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
media_root = Path(os.environ["JIANGCHANG_DATA_ROOT"]) / "shared" / "media-assets"
ffmpeg_path = media_root / "tools" / "ffmpeg.exe"
ffmpeg_path.parent.mkdir(parents=True, exist_ok=True)
ffmpeg_path.write_bytes(b"x" * 256)
status = MediaAssetsStatus(
root=media_root,
exists=True,
ready=False,
source="local",
warnings=["music_dir_missing"],
ffmpeg_path=ffmpeg_path,
music_dir=None,
)
music_probe = {
"music_root": None,
"mp3_count": 0,
"usable_count": 0,
"sample_path": None,
"issue": "music_dir_missing",
}
with patch(
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
return_value=status,
), patch(
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
return_value=music_probe,
), platform_kit_version_patch():
diag = _collect_diag()
music_issues = [i for i in diag.issues if i.code == "background_music_unavailable"]
self.assertTrue(music_issues)
self.assertEqual(music_issues[0].severity, "warning")
self.assertFalse(diag.has_fatal_issues)
def test_skill_tree_core_load_detected_as_issue(self) -> None:
skill_root = get_skill_root()
fake_core = os.path.join(
skill_root, "scripts", "fake_pkg", "jiangchang_skill_core", "__init__.py"
)
self.assertTrue(
is_jiangchang_skill_core_from_skill_tree(
skill_root=skill_root, core_file=fake_core
)
)
self.assertFalse(
is_jiangchang_skill_core_from_skill_tree(
skill_root=skill_root,
core_file=jiangchang_skill_core.__file__,
)
)
def test_skill_tree_core_load_issue_in_diagnostics(self) -> None:
skill_root = get_skill_root()
fake_core = os.path.join(skill_root, "scripts", "jiangchang_skill_core", "__init__.py")
with patch.object(jiangchang_skill_core, "__file__", fake_core):
diag = _collect_diag()
codes = diag.issue_codes()
self.assertIn("jiangchang_skill_core_loaded_from_skill_tree", codes)
messages = [issue.message for issue in diag.issues]
self.assertTrue(any("skill tree" in msg.lower() for msg in messages))
class TestHealthRuntimeWithFakeMedia(unittest.TestCase):
def test_probe_counts_mp3_without_download(self) -> None:
with IsolatedDataRoot(user_id="_health_media"):
media_root = Path(os.environ["JIANGCHANG_DATA_ROOT"]) / "shared" / "media-assets"
music_dir = media_root / "music"
music_dir.mkdir(parents=True)
(music_dir / "demo.mp3").write_bytes(b"\x00" * 1024)
os.environ["OPENCLAW_RECORD_VIDEO"] = "0"
diag = _collect_diag()
self.assertGreaterEqual(diag.background_music_mp3_count, 1)
self.assertGreaterEqual(diag.background_music_usable_count, 1)
self.assertIsNone(diag.background_music_issue)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,75 +0,0 @@
# -*- coding: utf-8 -*-
"""HTML 报告页薄写入 helper。"""
from __future__ import annotations
import os
import unittest
from _support import IsolatedDataRoot
from service.html_page_service import (
HOST_HTML_RENDER_ACTION_ID,
load_html_template,
render_html_placeholders,
write_html_page,
)
class TestHtmlPageService(unittest.TestCase):
def test_host_action_id_is_stable(self) -> None:
self.assertEqual(HOST_HTML_RENDER_ACTION_ID, "render-html")
def test_write_html_page_atomic_under_html_dir(self) -> None:
with IsolatedDataRoot() as tmp:
path = write_html_page(
"example-report.html",
"<!DOCTYPE html><html><head><title>示例</title></head><body>ok</body></html>",
)
self.assertTrue(os.path.isfile(path))
norm = path.replace("\\", "/")
self.assertIn("/html/example-report.html", norm)
self.assertTrue(os.path.normcase(os.path.normpath(path)).startswith(
os.path.normcase(os.path.normpath(tmp)),
))
self.assertFalse(os.path.isfile(path + ".tmp"))
with open(path, encoding="utf-8") as f:
self.assertIn("<title>示例</title>", f.read())
def test_load_and_fill_example_template(self) -> None:
with IsolatedDataRoot():
template = load_html_template("assets/html/example-report.html")
page = render_html_placeholders(
template,
{
"TITLE": "示例报告",
"GENERATED_AT": "2026-08-14 15:49:00",
"BODY": "<p>hello</p>",
},
)
self.assertIn("<title>示例报告</title>", page)
self.assertIn("报告生成于 · 2026-08-14 15:49:00", page)
self.assertIn("<p>hello</p>", page)
self.assertNotIn("{{TITLE}}", page)
dest = write_html_page("example-report.html", page)
self.assertTrue(os.path.isfile(dest))
def test_rejects_unsafe_filename(self) -> None:
with IsolatedDataRoot():
with self.assertRaises(ValueError):
write_html_page("../x.html", "<html></html>")
with self.assertRaises(ValueError):
write_html_page("note.txt", "<html></html>")
def test_example_report_shell_embedded_preview_layout(self) -> None:
"""守护 example-report.html:数据管理 iframe 预览布局基线不退化。"""
template = load_html_template("assets/html/example-report.html")
self.assertIn("overflow-x: hidden", template)
self.assertIn("min-width: 0", template)
self.assertIn("table-layout: fixed", template)
self.assertIn("overflow-wrap: anywhere", template)
self.assertNotIn("max-width:", template)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,39 +0,0 @@
# -*- coding: utf-8 -*-
"""禁止在模板主骨架 scripts/ 中 import account-manager 内部 RPA 辅助模块。"""
from __future__ import annotations
import os
import re
import unittest
from _support import get_skill_root
FORBIDDEN_PATTERNS = (
re.compile(r"\brpa_helpers\b"),
re.compile(r"\binject_account_manager_scripts_path\b"),
re.compile(r"\bget_account_credential\b"),
)
class TestNoRpaHelpersImport(unittest.TestCase):
def test_scripts_tree_has_no_forbidden_account_manager_imports(self) -> None:
scripts_dir = os.path.join(get_skill_root(), "scripts")
violations: list[str] = []
for root, _dirs, files in os.walk(scripts_dir):
for name in files:
if not name.endswith(".py"):
continue
rel = os.path.relpath(os.path.join(root, name), scripts_dir)
text = open(os.path.join(root, name), encoding="utf-8").read()
for pattern in FORBIDDEN_PATTERNS:
if pattern.search(text):
violations.append(f"{rel}: {pattern.pattern}")
self.assertEqual(
violations,
[],
msg="Forbidden account-manager internal imports in scripts/:\n" + "\n".join(violations),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,136 +0,0 @@
# -*- coding: utf-8 -*-
"""防回归:公共能力必须从共享 platform-kit 导入,而非技能 vendored 副本。"""
from __future__ import annotations
import importlib.metadata as metadata
import os
import re
import sys
import unittest
from _support import get_scripts_dir, get_skill_root
def _parse_platform_kit_min_version(skill_md_text: str) -> str:
parts = skill_md_text.split("---", 2)
if len(parts) < 2:
raise ValueError("missing frontmatter")
m = re.search(
r"platform_kit_min_version:\s*[\"']?([^\"'\s]+)[\"']?",
parts[1],
)
if not m:
raise ValueError("platform_kit_min_version not found")
return m.group(1).strip()
def _requirement_dependency_lines(req_text: str) -> list[str]:
return [
line.strip()
for line in req_text.splitlines()
if line.strip() and not line.strip().startswith("#")
]
class TestPlatformImportSource(unittest.TestCase):
def test_jiangchang_skill_core_not_from_skill_scripts(self) -> None:
scripts_dir = os.path.abspath(get_scripts_dir())
skill_root = os.path.abspath(get_skill_root())
vendored = os.path.join(scripts_dir, "jiangchang_skill_core")
self.assertFalse(
os.path.isdir(vendored),
f"vendored package must not exist: {vendored}",
)
import jiangchang_skill_core
import jiangchang_skill_core.rpa.video_session as video_session
core_file = os.path.abspath(jiangchang_skill_core.__file__)
video_file = os.path.abspath(video_session.__file__)
self.assertNotIn(
scripts_dir.lower(),
core_file.lower(),
msg=f"jiangchang_skill_core loaded from skill scripts: {core_file}",
)
self.assertNotIn(
skill_root.lower(),
core_file.lower(),
msg=f"jiangchang_skill_core loaded from skill tree: {core_file}",
)
self.assertNotIn(
scripts_dir.lower(),
video_file.lower(),
msg=f"video_session loaded from skill scripts: {video_file}",
)
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
self.assertTrue(callable(RpaVideoSession))
def test_skill_tree_load_issue_helper(self) -> None:
from jiangchang_skill_core import is_jiangchang_skill_core_from_skill_tree
skill_root = os.path.abspath(get_skill_root())
fake = os.path.join(skill_root, "scripts", "jiangchang_skill_core", "__init__.py")
self.assertTrue(
is_jiangchang_skill_core_from_skill_tree(skill_root=skill_root, core_file=fake)
)
self.assertFalse(
is_jiangchang_skill_core_from_skill_tree(
skill_root=skill_root, core_file=sys.executable
)
)
def test_platform_kit_min_version_is_at_least_declared(self) -> None:
from jiangchang_skill_core import version_ge
from util.constants import PLATFORM_KIT_MIN_VERSION
self.assertEqual(PLATFORM_KIT_MIN_VERSION, "1.2.11")
md_path = os.path.join(get_skill_root(), "SKILL.md")
with open(md_path, encoding="utf-8") as f:
md = f.read()
self.assertEqual(_parse_platform_kit_min_version(md), "1.2.11")
req_path = os.path.join(get_skill_root(), "requirements.txt")
with open(req_path, encoding="utf-8") as f:
req = f.read()
dep_lines = _requirement_dependency_lines(req)
dep_body = "\n".join(dep_lines).lower()
self.assertNotIn("jiangchang-platform-kit", dep_body)
self.assertNotIn("playwright", dep_body)
try:
installed = metadata.version("jiangchang-platform-kit")
except metadata.PackageNotFoundError:
self.skipTest("jiangchang-platform-kit not installed in current interpreter")
if not version_ge(installed, PLATFORM_KIT_MIN_VERSION):
self.skipTest(
f"installed jiangchang-platform-kit {installed!r} < required "
f"{PLATFORM_KIT_MIN_VERSION!r}; upgrade shared runtime to enforce this check"
)
self.assertTrue(
version_ge(installed, PLATFORM_KIT_MIN_VERSION),
msg=f"installed {installed!r} < required {PLATFORM_KIT_MIN_VERSION!r}",
)
def test_collect_runtime_diagnostics_importable(self) -> None:
from jiangchang_skill_core import collect_runtime_diagnostics
self.assertTrue(callable(collect_runtime_diagnostics))
def test_main_import_smoke(self) -> None:
scripts_dir = get_scripts_dir()
if scripts_dir not in sys.path:
sys.path.insert(0, scripts_dir)
from cli.app import build_parser
parser = build_parser()
self.assertIsNotNone(parser)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,436 +1,22 @@
# -*- coding: utf-8 -*-
"""票据合规预审规则引擎金标准用例(离线,无网络无浏览器)。"""
from __future__ import annotations
"""预审规则冒烟测试:确保核心预审逻辑可用。"""
import unittest
from datetime import date, timedelta
import os
import sys
from _support import IsolatedDataRoot
_PASS = "pass"
_WARN = "warn"
_REJECT = "reject"
# 确保 scripts 在路径中
scripts_dir = os.path.join(os.path.dirname(__file__), "..", "scripts")
if scripts_dir not in sys.path:
sys.path.insert(0, scripts_dir)
def _levels(items):
return {i["rule_id"]: i["level"] for i in items}
def test_import_constants():
"""常量模块能正常导入。"""
from util.constants import SKILL_SLUG, SKILL_VERSION
assert SKILL_SLUG == "verify-invoice-default"
assert SKILL_VERSION
def _verdict_of(result):
return {r["invoice_number"]: r["verdict"] for r in result["reports"]}
class PrereviewRuleTestBase(unittest.TestCase):
def setUp(self) -> None:
self._root = IsolatedDataRoot()
self._root.__enter__()
from db.connection import init_db
init_db()
def tearDown(self) -> None:
self._root.__exit__(None, None, None)
def _weekday(self, offset: int = 0) -> str:
"""返回一个周一~周四且不在节假日种子的日期(避免 R3 误触发);从今天往回找,保证不是未来日期。"""
from db.connection import get_conn
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT holiday_date FROM holiday_calendar")
holidays = {str(r[0]) for r in cur.fetchall()}
finally:
conn.close()
base = date.today() - timedelta(days=max(offset, 0))
for delta in range(0, 30):
d = base - timedelta(days=delta)
if d.weekday() <= 3 and d.isoformat() not in holidays:
return d.isoformat()
raise AssertionError("30 天内找不到工作日测试日期")
def _saturday(self) -> str:
base = date.today()
for delta in range(0, 14):
d = base + timedelta(days=delta)
if d.weekday() == 5:
return d.isoformat()
raise AssertionError("两周内找不到周六")
def _run(self, invoices, **context):
from service.prereview_service import run_prereview
ctx = {
"purpose": "",
"project_no": "",
"trip_start": "",
"trip_end": "",
"trip_city": "",
"staff_title": "",
}
ctx.update(context)
return run_prereview(invoices, ctx)
class TestCnyAmount(PrereviewRuleTestBase):
def test_parse_common_upper_amounts(self) -> None:
from util.cny_amount import parse_cny_upper
cases = {
"壹仟陆佰伍拾元整": 1650.0,
"壹佰贰拾叁元肆角伍分": 123.45,
"拾元整": 10.0,
"壹万贰仟零壹佰元整": 12100.0,
"人民币叁元整": 3.0,
}
for text, expected in cases.items():
with self.subTest(text=text):
self.assertEqual(parse_cny_upper(text), expected)
def test_parse_garbage_returns_none(self) -> None:
from util.cny_amount import parse_cny_upper
self.assertIsNone(parse_cny_upper(""))
self.assertIsNone(parse_cny_upper("abc"))
self.assertIsNone(parse_cny_upper("123.45"))
class TestFieldCompleteness(PrereviewRuleTestBase):
def test_f1_missing_fields_warn(self) -> None:
result = self._run([{"invoiceNumber": "24312000000012345678"}])
items = _levels(result["reports"][0]["items"])
self.assertEqual(items["F1"], _WARN)
self.assertEqual(result["reports"][0]["verdict"], "待确认")
def test_f2_unknown_length_warn(self) -> None:
result = self._run([{"invoiceNumber": "12345", "issueDate": self._weekday(), "amount": 10, "sellerName": "x"}])
items = _levels(result["reports"][0]["items"])
self.assertEqual(items["F2"], _WARN)
def test_f2_dianpiao_20_digits_pass(self) -> None:
result = self._run([{"invoiceNumber": "24312000000012345678", "issueDate": self._weekday(), "amount": 10, "sellerName": "x"}])
items = _levels(result["reports"][0]["items"])
# 号码位数合法时不额外出 F2 项(无异常即通过)
self.assertEqual(items.get("F2", _PASS), _PASS)
def test_f3_future_date_reject(self) -> None:
future = (date.today() + timedelta(days=400)).isoformat()
result = self._run([{"invoiceNumber": "24312000000012345678", "issueDate": future, "amount": 10, "sellerName": "x"}])
items = _levels(result["reports"][0]["items"])
self.assertEqual(items["F3"], _REJECT)
self.assertEqual(result["reports"][0]["verdict"], "驳回")
def test_f4_words_mismatch_reject(self) -> None:
result = self._run(
[
{
"invoiceNumber": "24312000000012345678",
"issueDate": self._weekday(),
"amount": 1650.0,
"amountInWords": "壹佰元整",
"sellerName": "x",
}
]
)
items = _levels(result["reports"][0]["items"])
self.assertEqual(items["F4"], _REJECT)
def test_f4_words_match_pass(self) -> None:
result = self._run(
[
{
"invoiceNumber": "24312000000012345678",
"issueDate": self._weekday(),
"amount": 1650.0,
"amountInWords": "壹仟陆佰伍拾元整",
"sellerName": "x",
}
]
)
items = _levels(result["reports"][0]["items"])
self.assertEqual(items["F4"], _PASS)
class TestCrossVerification(PrereviewRuleTestBase):
def test_v1_unverified_warn(self) -> None:
result = self._run([{"invoiceNumber": "24312000000012345678", "issueDate": self._weekday(), "amount": 10, "sellerName": "x"}])
items = _levels(result["reports"][0]["items"])
self.assertEqual(items["V1"], _WARN)
def test_v1_manual_fail_rejects(self) -> None:
result = self._run(
[
{
"invoiceNumber": "24312000000012345678",
"issueDate": self._weekday(),
"amount": 10,
"sellerName": "x",
"manualVerifyResult": "fail",
}
]
)
items = _levels(result["reports"][0]["items"])
self.assertEqual(items["V1"], _REJECT)
self.assertEqual(result["reports"][0]["verdict"], "驳回")
def test_v2_whitelist_pass_and_reject(self) -> None:
good = "91440101MA9XXYYY0A"
ok = self._run([{"invoiceNumber": "24312000000012345678", "issueDate": self._weekday(), "amount": 10, "sellerName": "x", "buyerTaxNo": good}])
self.assertEqual(_levels(ok["reports"][0]["items"])["V2"], _PASS)
bad = self._run([{"invoiceNumber": "24312000000012345678", "issueDate": self._weekday(), "amount": 10, "sellerName": "x", "buyerTaxNo": "00000000000000000X"}])
self.assertEqual(_levels(bad["reports"][0]["items"])["V2"], _REJECT)
def test_v3_outside_trip_window_warn(self) -> None:
from db.connection import get_conn
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT holiday_date FROM holiday_calendar")
holidays = {str(r[0]) for r in cur.fetchall()}
finally:
conn.close()
trip_start = date.today() + timedelta(days=10)
trip_start = trip_start + timedelta(days=(7 - trip_start.weekday()) % 7) # 移到周一
issue = trip_start - timedelta(days=30)
for _ in range(10):
if issue.weekday() <= 3 and issue.isoformat() not in holidays:
break
issue = issue - timedelta(days=1)
result = self._run(
[{"invoiceNumber": "24312000000012345678", "issueDate": issue.isoformat(), "amount": 10, "sellerName": "x"}],
trip_start=trip_start.isoformat(),
trip_end=(trip_start + timedelta(days=2)).isoformat(),
)
self.assertEqual(_levels(result["reports"][0]["items"])["V3"], _WARN)
def test_v3_inside_trip_window_pass(self) -> None:
start = self._weekday()
end_date = date.fromisoformat(start) + timedelta(days=2)
issue = date.fromisoformat(start) + timedelta(days=1)
result = self._run(
[{"invoiceNumber": "24312000000012345678", "issueDate": issue.isoformat(), "amount": 10, "sellerName": "x"}],
trip_start=start,
trip_end=end_date.isoformat(),
)
self.assertEqual(_levels(result["reports"][0]["items"])["V3"], _PASS)
class TestExpenseStandards(PrereviewRuleTestBase):
def test_s1_over_lodging_cap_rejects(self) -> None:
result = self._run(
[
{
"invoiceNumber": "24312000000012345678",
"issueDate": self._weekday(),
"amount": 2400.0,
"sellerName": "酒店",
"expenseCategory": "住宿费",
"city": "北京",
"days": 2,
}
],
staff_title="副高及以上",
)
items = _levels(result["reports"][0]["items"])
self.assertEqual(items["S1"], _REJECT) # 1200 元/天 > 550
self.assertIn("550", result["reports"][0]["items"][_level_index(result, "S1")]["detail"])
def test_s1_within_cap_passes(self) -> None:
result = self._run(
[
{
"invoiceNumber": "24312000000012345678",
"issueDate": self._weekday(),
"amount": 1080.0,
"sellerName": "酒店",
"expenseCategory": "住宿费",
"city": "北京",
"days": 2,
}
],
staff_title="副高及以上",
)
self.assertEqual(_levels(result["reports"][0]["items"])["S1"], _PASS) # 540 元/天 ≤ 550
def test_s1_unknown_city_warns(self) -> None:
result = self._run(
[
{
"invoiceNumber": "24312000000012345678",
"issueDate": self._weekday(),
"amount": 900.0,
"sellerName": "酒店",
"expenseCategory": "住宿费",
"city": "未知城镇",
"days": 1,
}
],
staff_title="副高及以上",
)
self.assertEqual(_levels(result["reports"][0]["items"])["S1"], _WARN)
def test_s2_ticket_class_above_title_warns(self) -> None:
result = self._run(
[
{
"invoiceNumber": "24312000000012345678",
"issueDate": self._weekday(),
"amount": 100.0,
"sellerName": "铁路",
"expenseCategory": "交通费",
"ticketClass": "商务座",
}
],
staff_title="中级及以下",
)
self.assertEqual(_levels(result["reports"][0]["items"])["S2"], _WARN)
def test_s3_project_hint_pass(self) -> None:
result = self._run(
[{"invoiceNumber": "24312000000012345678", "issueDate": self._weekday(), "amount": 10, "sellerName": "x"}],
project_no="KYGX2026001",
)
self.assertEqual(_levels(result["reports"][0]["items"])["S3"], _PASS)
class TestRiskFlags(PrereviewRuleTestBase):
def test_r1_consecutive_numbers_warn(self) -> None:
issue = self._weekday()
result = self._run(
[
{"invoiceNumber": "24312000000012345678", "issueDate": issue, "amount": 10, "sellerName": "a"},
{"invoiceNumber": "24312000000012345679", "issueDate": issue, "amount": 10, "sellerName": "b"},
]
)
for report in result["reports"]:
self.assertEqual(_levels(report["items"])["R1"], _WARN)
def test_r2_batch_concentration_warn(self) -> None:
issue = self._weekday()
result = self._run(
[
{"invoiceNumber": f"243120000000123450{i:02d}", "issueDate": issue, "amount": 10, "sellerName": "同一商家", "sellerTaxNo": "91110000TEST00001X"}
for i in range(1, 4)
]
)
for report in result["reports"]:
self.assertEqual(_levels(report["items"])["R2"], _WARN)
def test_r3_weekend_invoicing_warns(self) -> None:
result = self._run([{"invoiceNumber": "24312000000012345678", "issueDate": self._saturday(), "amount": 10, "sellerName": "x"}])
self.assertEqual(_levels(result["reports"][0]["items"])["R3"], _WARN)
class TestVerdictAggregation(PrereviewRuleTestBase):
def test_clean_invoice_passes(self) -> None:
result = self._run(
[
{
"invoiceNumber": "24312000000012345678",
"issueDate": self._weekday(),
"amount": 10.0,
"amountInWords": "拾元整",
"sellerName": "商店",
"manualVerifyResult": "pass",
"buyerTaxNo": "91440101MA9XXYYY0A",
}
]
)
self.assertEqual(result["reports"][0]["verdict"], "通过")
self.assertEqual(result["verdict_counts"], {"通过": 1, "待确认": 0, "驳回": 0})
def test_batch_counts_and_batch_no_shared(self) -> None:
issue = self._weekday()
result = self._run(
[
{"invoiceNumber": "24312000000012345678", "issueDate": issue, "amount": 10, "sellerName": "a", "manualVerifyResult": "pass", "buyerTaxNo": "91440101MA9XXYYY0A"},
{"invoiceNumber": "24312000000012345999", "issueDate": issue, "amount": 10, "sellerName": "b"},
]
)
self.assertEqual(result["count"], 2)
self.assertEqual(result["verdict_counts"], {"通过": 1, "待确认": 1, "驳回": 0})
batch_nos = {r["batch_no"] for r in result["reports"]}
self.assertEqual(len(batch_nos), 1)
self.assertEqual(len(result["batch_no"]), 16)
def test_report_file_written(self) -> None:
result = self._run([{"invoiceNumber": "24312000000012345678", "issueDate": self._weekday(), "amount": 10, "sellerName": "x"}])
path = result["reports"][0]["report_file"]
self.assertTrue(path.endswith(".html"))
with open(path, encoding="utf-8") as f:
html_text = f.read()
self.assertIn("初审意见单", html_text)
self.assertIn("不构成最终报销结论", html_text)
def test_empty_input_raises(self) -> None:
with self.assertRaises(ValueError):
self._run([])
def _level_index(result, rule_id: str) -> int:
items = result["reports"][0]["items"]
for idx, item in enumerate(items):
if item["rule_id"] == rule_id:
return idx
raise AssertionError(f"rule {rule_id} not found")
class TestMarkVerified(unittest.TestCase):
def test_fail_upgrades_verdict_to_reject(self) -> None:
with IsolatedDataRoot():
from db.connection import init_db
from service.prereview_service import run_prereview
from service.standards_service import get_report, mark_verified
init_db()
result = run_prereview(
[{"invoiceNumber": "24312000000012345678", "issueDate": "2099-01-01", "amount": 10, "sellerName": "x"}],
{"purpose": "", "project_no": "", "trip_start": "", "trip_end": "", "trip_city": "", "staff_title": ""},
)
report_id = result["reports"][0]["report_id"]
payload = mark_verified(report_id, "fail", "查验不符")
self.assertEqual(payload["verify_status"], "已查验不符")
report = get_report(report_id)
self.assertEqual(report["verdict"], "驳回")
self.assertEqual(report["reject_json"][-1]["rule_id"], "V1")
class TestWhitelistAndStandardsServices(unittest.TestCase):
def test_whitelist_add_duplicate_remove(self) -> None:
with IsolatedDataRoot():
from db.connection import init_db
from service.standards_service import add_whitelist, list_whitelist, remove_whitelist
init_db()
add_whitelist("91440101TESTAAAA01", "测试单位", "n")
with self.assertRaises(ValueError):
add_whitelist("91440101TESTAAAA01", "重复税号")
self.assertEqual(len(list_whitelist()), 2) # 种子 1 条 + 新增 1 条
remove_whitelist("91440101TESTAAAA01")
with self.assertRaises(ValueError):
remove_whitelist("91440101TESTAAAA01")
def test_set_standard_upsert(self) -> None:
with IsolatedDataRoot():
from db.connection import init_db
from service.standards_service import list_standards, upsert_standard
init_db()
payload = upsert_standard(
category="住宿费",
city_tier="一线城市",
staff_title="副高及以上",
max_amount=600.0,
unit="元/人/天",
basis_doc="《差旅费管理办法》",
clause="第十条",
)
self.assertEqual(payload["action"], "updated")
row = next(s for s in list_standards("住宿费") if s["city_tier"] == "一线城市" and s["staff_title"] == "副高及以上")
self.assertEqual(row["max_amount"], 600.0)
if __name__ == "__main__":
unittest.main()
def test_import_prereview():
"""预审服务能正常导入。"""
from service.prereview_service import run_prereview
assert callable(run_prereview)

View File

@@ -1,165 +0,0 @@
# -*- coding: utf-8 -*-
"""发布打包守护:PyArmor 单文件行数与 UTF-8 without BOM。"""
from __future__ import annotations
import os
import unittest
from _support import get_skill_root
SCRIPTS_MAX_LINES = 1000
SCRIPTS_RECOMMENDED_MAX_LINES = 900
TEXT_EXTENSIONS = frozenset(
{
".py",
".md",
".yml",
".yaml",
".json",
".toml",
".ini",
".txt",
".ps1",
".sh",
}
)
SPECIAL_TEXT_FILES = frozenset({".env.example", ".gitignore"})
SKIP_DIR_NAMES = frozenset(
{
".git",
"__pycache__",
".pytest_cache",
"rpa-artifacts",
"videos",
}
)
BINARY_EXTENSIONS = frozenset(
{
".db",
".sqlite",
".png",
".jpg",
".jpeg",
".gif",
".webp",
".mp4",
".zip",
".tar",
".gz",
}
)
def _is_text_candidate(path: str) -> bool:
basename = os.path.basename(path)
if basename in SPECIAL_TEXT_FILES:
return True
ext = os.path.splitext(basename)[1].lower()
if ext in BINARY_EXTENSIONS:
return False
return ext in TEXT_EXTENSIONS
def _iter_text_files(skill_root: str) -> list[str]:
found: list[str] = []
for root, dirnames, filenames in os.walk(skill_root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES]
for name in filenames:
full = os.path.join(root, name)
if _is_text_candidate(full):
found.append(full)
return sorted(found)
def _iter_scripts_py_files(skill_root: str) -> list[str]:
scripts_dir = os.path.join(skill_root, "scripts")
found: list[str] = []
if not os.path.isdir(scripts_dir):
return found
for root, dirnames, filenames in os.walk(scripts_dir):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES]
for name in filenames:
if name.endswith(".py"):
found.append(os.path.join(root, name))
return sorted(found)
class TestReleasePackagingConstraints(unittest.TestCase):
def test_scripts_py_files_under_pyarmor_line_limit(self) -> None:
skill_root = get_skill_root()
offenders: list[str] = []
for path in _iter_scripts_py_files(skill_root):
with open(path, encoding="utf-8") as f:
line_count = sum(1 for _ in f)
if line_count >= SCRIPTS_MAX_LINES:
rel = os.path.relpath(path, skill_root).replace("\\", "/")
offenders.append(
f"{rel}: {line_count} lines (limit < {SCRIPTS_MAX_LINES})"
)
self.assertEqual(
offenders,
[],
msg=(
"PyArmor trial/free mode may fail when a single Python file reaches "
f"{SCRIPTS_MAX_LINES} lines; split this file into smaller modules. "
f"Recommended keep each file < {SCRIPTS_RECOMMENDED_MAX_LINES} lines. "
f"Offenders: {offenders}"
),
)
def test_text_files_are_utf8_without_bom(self) -> None:
skill_root = get_skill_root()
bom_failures: list[str] = []
decode_failures: list[str] = []
feff_failures: list[str] = []
for path in _iter_text_files(skill_root):
rel = os.path.relpath(path, skill_root).replace("\\", "/")
with open(path, "rb") as f:
raw = f.read()
if raw.startswith(b"\xef\xbb\xbf"):
bom_failures.append(rel)
continue
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as exc:
decode_failures.append(f"{rel}: {exc}")
continue
if "\ufeff" in text:
feff_failures.append(rel)
self.assertEqual(
bom_failures,
[],
msg=(
"Files must be saved as UTF-8 without BOM (remove UTF-8 BOM prefix). "
f"Offenders: {bom_failures}"
),
)
self.assertEqual(
decode_failures,
[],
msg=(
"Files must be valid UTF-8 without BOM. "
f"Offenders: {decode_failures}"
),
)
self.assertEqual(
feff_failures,
[],
msg=(
"Files must not contain U+FEFF; save as UTF-8 without BOM. "
f"Offenders: {feff_failures}"
),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,72 +0,0 @@
# -*- coding: utf-8 -*-
"""Architecture guard: runtime diagnostics must come from platform-kit."""
from __future__ import annotations
import os
import unittest
from _support import get_scripts_dir, get_skill_root
_FORBIDDEN_DEFINITIONS = (
"class RuntimeDiagnostics",
"class RuntimeIssue",
"def collect_runtime_diagnostics",
"def format_runtime_health_lines",
"def runtime_diagnostics_dict",
"def _is_git_lfs_pointer",
"def _is_usable_audio_file",
"def _probe_background_music",
)
class TestRuntimeDiagnosticsIntegration(unittest.TestCase):
def test_no_vendored_jiangchang_skill_core(self) -> None:
vendored = os.path.join(get_scripts_dir(), "jiangchang_skill_core")
self.assertFalse(os.path.isdir(vendored), vendored)
def test_runtime_diagnostics_module_removed(self) -> None:
legacy = os.path.join(get_scripts_dir(), "util", "runtime_diagnostics.py")
self.assertFalse(os.path.isfile(legacy), legacy)
def test_util_py_files_do_not_reimplement_runtime_diagnostics(self) -> None:
util_dir = os.path.join(get_scripts_dir(), "util")
for name in os.listdir(util_dir):
if not name.endswith(".py"):
continue
path = os.path.join(util_dir, name)
with open(path, encoding="utf-8") as f:
text = f.read()
for forbidden in _FORBIDDEN_DEFINITIONS:
self.assertNotIn(
forbidden,
text,
msg=f"{name} must not define {forbidden}",
)
def test_task_service_imports_platform_kit_diagnostics(self) -> None:
path = os.path.join(get_scripts_dir(), "service", "task_service.py")
with open(path, encoding="utf-8") as f:
text = f.read()
self.assertIn("from jiangchang_skill_core import", text)
self.assertIn("collect_runtime_diagnostics", text)
self.assertIn("format_runtime_health_lines", text)
self.assertIn("PLATFORM_KIT_MIN_VERSION", text)
self.assertNotIn("from util.runtime_diagnostics import", text)
def test_collect_runtime_diagnostics_callable_from_platform_kit(self) -> None:
from jiangchang_skill_core import collect_runtime_diagnostics
from util.constants import PLATFORM_KIT_MIN_VERSION, SKILL_SLUG
from util.runtime_paths import get_skill_root
diag = collect_runtime_diagnostics(
skill_slug=SKILL_SLUG,
platform_kit_min_version=PLATFORM_KIT_MIN_VERSION,
skill_root=get_skill_root(),
)
self.assertEqual(diag.skill_slug, SKILL_SLUG)
self.assertEqual(diag.platform_kit_min_version, PLATFORM_KIT_MIN_VERSION)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,169 +0,0 @@
# -*- coding: utf-8 -*-
"""util.runtime_paths:技能根、DB 路径、数据目录均落在隔离数据根下。"""
from __future__ import annotations
import os
import unittest
from _support import IsolatedDataRoot, get_skill_root
_FAKE_REAL_ROOT = r"D:\jiangchang-data"
def _legacy_env_key(suffix: str) -> str:
return "CL" + "AW_" + suffix
class TestRuntimePaths(unittest.TestCase):
def test_paths_under_isolated_root(self) -> None:
win_default = r"D:\jiangchang-data"
with IsolatedDataRoot() as tmp:
import importlib
import util.runtime_paths as rp
importlib.reload(rp)
skill_root = rp.get_skill_root()
self.assertTrue(os.path.isdir(skill_root))
self.assertTrue(os.path.isfile(os.path.join(skill_root, "SKILL.md")))
db_path = rp.get_db_path()
self.assertTrue(os.path.normpath(db_path).startswith(os.path.normpath(tmp)))
self.assertNotIn(win_default, db_path)
data_dir = rp.get_skill_data_dir()
self.assertTrue(os.path.normpath(data_dir).startswith(os.path.normpath(tmp)))
self.assertIn("_test", data_dir.replace("\\", "/"))
self.assertNotIn(win_default, data_dir)
self.assertEqual(get_skill_root(), skill_root)
def test_isolation_overrides_prefixed_real_data_root(self) -> None:
"""先设假数据根,再进入隔离;get_db_path 必须落在 tempdir。"""
saved = {
k: os.environ.get(k)
for k in ("JIANGCHANG_DATA_ROOT", "JIANGCHANG_USER_ID")
}
try:
os.environ["JIANGCHANG_DATA_ROOT"] = _FAKE_REAL_ROOT
with IsolatedDataRoot() as tmp:
import importlib
import util.runtime_paths as rp
importlib.reload(rp)
db_path = rp.get_db_path()
norm_db = os.path.normcase(os.path.normpath(db_path))
norm_tmp = os.path.normcase(os.path.normpath(tmp))
self.assertTrue(norm_db.startswith(norm_tmp), msg=f"db_path={db_path!r} tmp={tmp!r}")
norm_fake = os.path.normcase(os.path.normpath(_FAKE_REAL_ROOT))
self.assertFalse(
norm_db.startswith(norm_fake),
msg=f"db_path must not be under fake root: {db_path!r}",
)
finally:
for k, v in saved.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
def test_legacy_env_vars_do_not_override_canonical(self) -> None:
with IsolatedDataRoot() as tmp:
os.environ[_legacy_env_key("DATA_ROOT")] = _FAKE_REAL_ROOT
os.environ[_legacy_env_key("USER_ID")] = "legacy-user"
import importlib
import util.runtime_paths as rp
importlib.reload(rp)
data_dir = rp.get_skill_data_dir()
self.assertTrue(os.path.normpath(data_dir).startswith(os.path.normpath(tmp)))
self.assertIn("_test", data_dir.replace("\\", "/"))
def test_resolve_data_path_defaults_under_skill_data_dir(self) -> None:
with IsolatedDataRoot() as tmp:
import importlib
import util.runtime_paths as rp
importlib.reload(rp)
path = rp.resolve_data_path(None, "output", create=True)
norm = os.path.normcase(os.path.normpath(path))
norm_tmp = os.path.normcase(os.path.normpath(tmp))
self.assertTrue(norm.startswith(norm_tmp))
self.assertTrue(norm.endswith(os.path.normcase(os.path.join("output"))))
def test_resolve_data_path_relative_uses_data_dir_not_cwd(self) -> None:
with IsolatedDataRoot() as tmp:
import importlib
import util.runtime_paths as rp
importlib.reload(rp)
os.environ["SKILL_OUTPUT_DIR"] = "output/videos"
from jiangchang_skill_core import config
config.reset_cache()
importlib.reload(rp)
path = rp.resolve_data_path("SKILL_OUTPUT_DIR", "output", create=False)
norm = os.path.normcase(os.path.normpath(path))
self.assertIn(os.path.normcase("output"), norm)
self.assertIn(os.path.normcase("videos"), norm)
self.assertTrue(norm.startswith(os.path.normcase(os.path.normpath(tmp))))
os.environ.pop("SKILL_OUTPUT_DIR", None)
config.reset_cache()
def test_resolve_input_path_relative_under_input(self) -> None:
with IsolatedDataRoot() as tmp:
import importlib
import util.runtime_paths as rp
importlib.reload(rp)
resolved = rp.resolve_input_path("urls.txt", create_parent=True)
norm = os.path.normcase(os.path.normpath(resolved))
self.assertIn(os.path.normcase("input"), norm)
self.assertTrue(norm.startswith(os.path.normcase(os.path.normpath(tmp))))
def test_get_input_and_output_dirs(self) -> None:
with IsolatedDataRoot() as tmp:
import importlib
import util.runtime_paths as rp
importlib.reload(rp)
input_dir = rp.get_input_dir(create=True)
output_dir = rp.get_output_dir(create=True)
html_dir = rp.get_html_dir(create=True)
norm_tmp = os.path.normcase(os.path.normpath(tmp))
self.assertTrue(os.path.normcase(os.path.normpath(input_dir)).startswith(norm_tmp))
self.assertTrue(os.path.normcase(os.path.normpath(output_dir)).startswith(norm_tmp))
self.assertTrue(os.path.normcase(os.path.normpath(html_dir)).startswith(norm_tmp))
self.assertTrue(input_dir.replace("\\", "/").endswith("/input"))
self.assertTrue(output_dir.replace("\\", "/").endswith("/output"))
self.assertTrue(html_dir.replace("\\", "/").endswith("/html"))
def test_list_resolved_data_paths_keys(self) -> None:
with IsolatedDataRoot():
import importlib
import util.runtime_paths as rp
importlib.reload(rp)
paths = rp.list_resolved_data_paths()
for key in (
"skill_data_dir",
"input_dir",
"output_dir",
"html_dir",
):
self.assertIn(key, paths)
self.assertTrue(paths[key])
self.assertNotIn("downloads_dir", paths)
self.assertNotIn("imports_dir", paths)
self.assertNotIn("exports_dir", paths)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,145 +0,0 @@
# -*- coding: utf-8 -*-
"""skill-template 脚手架与 Git 防串库守护。"""
from __future__ import annotations
import json
import os
import tempfile
import unittest
from _support import get_skill_root
TEMPLATE_MARKER = ".openclaw-skill-template"
TEMPLATE_SLUG_KEBAB = "your-skill-slug"
TEMPLATE_SLUG_UNDERSCORE = "your_skill_slug"
SCAFFOLD_TEST_SLUG = "query-demo-records"
SCAFFOLD_TEST_SLUG_UNDERSCORE = "query_demo_records"
def _read_skill_slug() -> str:
constants_path = os.path.join(get_skill_root(), "scripts", "util", "constants.py")
with open(constants_path, encoding="utf-8") as f:
for line in f:
if line.strip().startswith("SKILL_SLUG"):
_, _, value = line.partition("=")
return value.strip().strip('"').strip("'")
return ""
class TestScaffoldGuard(unittest.TestCase):
def test_template_repo_has_marker(self) -> None:
slug = _read_skill_slug()
if slug != TEMPLATE_SLUG_KEBAB:
self.skipTest(f"当前 SKILL_SLUG={slug!r},非模板占位,跳过 template marker 断言")
marker = os.path.join(get_skill_root(), TEMPLATE_MARKER)
self.assertTrue(
os.path.isfile(marker),
msg=f"skill-template 源仓库根目录必须存在 {TEMPLATE_MARKER}",
)
def test_business_skill_must_not_keep_template_marker(self) -> None:
slug = _read_skill_slug()
if slug == TEMPLATE_SLUG_KEBAB:
self.skipTest("当前仍为模板占位 slug,由 test_template_repo_has_marker 守护")
marker = os.path.join(get_skill_root(), TEMPLATE_MARKER)
self.assertFalse(
os.path.isfile(marker),
msg=(
"业务技能不得保留 .openclaw-skill-template;"
"请使用 tools/scaffold_skill 或删除标记文件并检查是否误复制了模板 .git"
),
)
def test_development_md_documents_git_cleanup(self) -> None:
path = os.path.join(get_skill_root(), "development", "DEVELOPMENT.md")
with open(path, encoding="utf-8") as f:
text = f.read()
self.assertIn(".git", text)
self.assertTrue(
"scaffold_skill" in text or "scaffold" in text.lower(),
msg="DEVELOPMENT.md must document scaffold_skill",
)
self.assertTrue(
"remote" in text.lower() or "git remote" in text.lower(),
msg="DEVELOPMENT.md must mention git remote self-check",
)
def test_scaffold_replaces_slug_and_copies_actions_manifest(self) -> None:
slug = _read_skill_slug()
if slug != TEMPLATE_SLUG_KEBAB:
self.skipTest("仅在 skill-template 源仓库运行 scaffold 集成测试")
import subprocess
import sys
repo_root = get_skill_root()
scaffold_py = os.path.join(repo_root, "tools", "scaffold_skill.py")
with tempfile.TemporaryDirectory() as tmp:
dest = os.path.join(tmp, SCAFFOLD_TEST_SLUG)
proc = subprocess.run(
[
sys.executable,
scaffold_py,
"--slug",
SCAFFOLD_TEST_SLUG,
"--destination",
dest,
],
cwd=repo_root,
capture_output=True,
text=True,
)
self.assertEqual(proc.returncode, 0, msg=proc.stderr or proc.stdout)
actions_path = os.path.join(dest, "assets", "actions.json")
self.assertTrue(os.path.isfile(actions_path), msg="scaffold must copy assets/actions.json")
with open(actions_path, encoding="utf-8") as f:
manifest = json.load(f)
self.assertEqual(manifest.get("skill"), SCAFFOLD_TEST_SLUG)
manifest_raw = json.dumps(manifest)
self.assertNotIn(TEMPLATE_SLUG_KEBAB, manifest_raw)
self.assertNotIn(TEMPLATE_SLUG_UNDERSCORE, manifest_raw)
actions_md = os.path.join(dest, "references", "ACTIONS.md")
self.assertTrue(os.path.isfile(actions_md), msg="scaffold must copy references/ACTIONS.md")
schema_path = os.path.join(dest, "assets", "schemas", "skill-actions.schema.json")
self.assertTrue(os.path.isfile(schema_path))
marker = os.path.join(dest, TEMPLATE_MARKER)
self.assertFalse(os.path.isfile(marker))
self.assertFalse(os.path.isdir(os.path.join(dest, ".git")))
constants_path = os.path.join(dest, "scripts", "util", "constants.py")
with open(constants_path, encoding="utf-8") as f:
constants_text = f.read()
self.assertNotIn(TEMPLATE_SLUG_KEBAB, constants_text)
self.assertNotIn(TEMPLATE_SLUG_UNDERSCORE, constants_text)
self.assertIn(f'SKILL_SLUG = "{SCAFFOLD_TEST_SLUG}"', constants_text)
self.assertIn(
f'LOG_LOGGER_NAME = "openclaw.skill.{SCAFFOLD_TEST_SLUG_UNDERSCORE}"',
constants_text,
)
skill_md_path = os.path.join(dest, "SKILL.md")
with open(skill_md_path, encoding="utf-8") as f:
skill_md = f.read()
self.assertIn(f"slug: {SCAFFOLD_TEST_SLUG}", skill_md)
self.assertNotIn(TEMPLATE_SLUG_KEBAB, skill_md)
self.assertNotIn(TEMPLATE_SLUG_UNDERSCORE, skill_md)
def test_scaffold_ps1_matches_python_placeholder_rules(self) -> None:
slug = _read_skill_slug()
if slug != TEMPLATE_SLUG_KEBAB:
self.skipTest("仅在 skill-template 源仓库运行 scaffold PS1 文案测试")
ps1_path = os.path.join(get_skill_root(), "tools", "scaffold_skill.ps1")
with open(ps1_path, encoding="utf-8") as f:
text = f.read()
self.assertIn("your-skill-slug", text)
self.assertIn("your_skill_slug", text)
self.assertIn('.Replace("-", "_")', text)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,75 +0,0 @@
# -*- coding: utf-8 -*-
"""SKILL.md 与 util.constants.SKILL_SLUG 一致性(防复制后漏改 constants)。"""
from __future__ import annotations
import os
import re
import unittest
from _support import get_skill_root
def _parse_openclaw_slug(skill_md_text: str) -> str:
"""从 YAML frontmatter 中解析 metadata.openclaw.slug(无 PyYAML 依赖)。"""
parts = skill_md_text.split("---", 2)
if len(parts) < 2:
raise ValueError("missing frontmatter")
fm = parts[1]
lines = fm.splitlines()
for i, line in enumerate(lines):
if re.match(r"^\s+openclaw:\s*$", line):
for j in range(i + 1, len(lines)):
inner = lines[j]
if inner.strip() and inner[0] not in (" ", "\t"):
break
m = re.match(r"^(\s+)slug:\s*(.+)\s*$", inner)
if m:
return m.group(2).strip().strip("\"'")
break
raise ValueError("slug not found under openclaw")
class TestSkillMetadata(unittest.TestCase):
def test_skill_slug_alignment(self) -> None:
root = get_skill_root()
skill_dir = os.path.basename(root)
md_path = os.path.join(root, "SKILL.md")
with open(md_path, encoding="utf-8") as f:
md = f.read()
slug_md = _parse_openclaw_slug(md)
from util.constants import SKILL_SLUG
self.assertEqual(slug_md, SKILL_SLUG, "SKILL.md slug 必须与 constants.SKILL_SLUG 一致")
if skill_dir == "skill-template":
# 模板仓库本体:允许占位 slug
self.assertEqual(SKILL_SLUG, "your-skill-slug")
else:
self.assertNotEqual(SKILL_SLUG, "your-skill-slug", "复制为真实技能后不得保留占位 slug")
self.assertEqual(SKILL_SLUG, skill_dir)
self.assertEqual(slug_md, skill_dir)
def test_skill_version_alignment(self) -> None:
"""SKILL.md version 必须与 constants.SKILL_VERSION 一致(POLICY-RELEASE-001 日常侧)。"""
root = get_skill_root()
md_path = os.path.join(root, "SKILL.md")
with open(md_path, encoding="utf-8") as f:
md = f.read()
match = re.search(r"(?m)^version:\s*[\"']?([0-9]+\.[0-9]+\.[0-9]+)[\"']?\s*$", md)
self.assertIsNotNone(match, "SKILL.md must declare top-level semver version")
assert match is not None
md_version = match.group(1)
from util.constants import SKILL_VERSION
self.assertEqual(
md_version,
SKILL_VERSION,
"SKILL.md version 必须与 constants.SKILL_VERSION 一致(发布前再与 git tag 对齐)",
)
self.assertRegex(SKILL_VERSION, r"^\d+\.\d+\.\d+$")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,87 +0,0 @@
# -*- coding: utf-8 -*-
"""slug 语义校验(verb-noun-platform)。"""
from __future__ import annotations
import unittest
from util.slug_naming import (
LEGACY_EXEMPT_SLUGS,
classify_slug_form,
parse_slug_segments,
validate_slug_semantics,
)
class TestSlugNaming(unittest.TestCase):
def test_parse_slug_segments(self) -> None:
self.assertEqual(parse_slug_segments("download-settlement-report-amazon"), [
"download",
"settlement",
"report",
"amazon",
])
def test_classify_standard(self) -> None:
segments = parse_slug_segments("download-settlement-report-amazon")
self.assertEqual(classify_slug_form(segments, slug="download-settlement-report-amazon"), "standard")
def test_classify_scope(self) -> None:
segments = parse_slug_segments("generate-article-all")
self.assertEqual(classify_slug_form(segments, slug="generate-article-all"), "scope")
def test_classify_legacy_exempt(self) -> None:
segments = parse_slug_segments("your-skill-slug")
self.assertEqual(classify_slug_form(segments, slug="your-skill-slug"), "legacy_exempt")
def test_valid_hyphenated_platform_slug(self) -> None:
slug = "fill-export-declaration-single-window"
segments = parse_slug_segments(slug)
self.assertEqual(classify_slug_form(segments, slug=slug), "standard")
errors, warnings = validate_slug_semantics(slug)
self.assertEqual(errors, [], msg=f"errors={errors!r} warnings={warnings!r}")
def test_valid_standard_slugs(self) -> None:
for slug in (
"download-settlement-report-amazon",
"add-product-shopee",
"generate-article-all",
"your-skill-slug",
"account-manager",
):
with self.subTest(slug=slug):
errors, warnings = validate_slug_semantics(slug)
self.assertEqual(errors, [], msg=f"errors={errors!r} warnings={warnings!r}")
def test_invalid_slugs(self) -> None:
cases = (
"amazon-download-settlement-report",
"enter-cross-border-sales-receipt-voucher-kingdee",
"reconcile-finance",
"download-settlement-report-unknownplat",
"foobar-settlement-report-amazon",
)
for slug in cases:
with self.subTest(slug=slug):
errors, _warnings = validate_slug_semantics(slug)
self.assertTrue(errors, msg=f"expected errors for {slug!r}")
def test_length_boundary_48_passes(self) -> None:
slug = "query-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-amazon"
self.assertEqual(len(slug), 48)
errors, _warnings = validate_slug_semantics(slug)
self.assertEqual(errors, [])
def test_length_boundary_49_fails(self) -> None:
slug = "query-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-amazon"
self.assertEqual(len(slug), 49)
errors, _warnings = validate_slug_semantics(slug)
self.assertTrue(errors)
self.assertTrue(any("48" in err for err in errors))
def test_legacy_exempt_set(self) -> None:
self.assertIn("your-skill-slug", LEGACY_EXEMPT_SLUGS)
self.assertIn("account-manager", LEGACY_EXEMPT_SLUGS)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,84 +0,0 @@
# -*- coding: utf-8 -*-
"""Docs guard: template must promote shared runtime, not vendored jiangchang_skill_core."""
from __future__ import annotations
import os
import unittest
from _support import get_skill_root
DOC_PATHS = (
"README.md",
"SKILL.md",
"development/RUNTIME.md",
"references/CLI.md",
"development/DEVELOPMENT.md",
"development/RPA.md",
"development/CONFIG.md",
)
RUNTIME_DOC_PATHS = tuple(p for p in DOC_PATHS if p != "README.md")
FORBIDDEN_PHRASES = (
"scripts/jiangchang_skill_core/:运行时",
"运行时与统一日志副本",
"vendor 源码在 scripts/jiangchang_skill_core",
"模板通过 vendor 拷贝引用",
"vendor 自 platform-kit",
"模板 vendor 拷贝在 scripts/jiangchang_skill_core",
"模板默认声明 `jiangchang-platform-kit",
"模板默认包含 `jiangchang-platform-kit",
"默认已含 `jiangchang-platform-kit",
"保留仅含 `jiangchang-platform-kit",
)
POSITIVE_MARKERS = (
"jiangchang-platform-kit",
"1.2.11",
"共享 runtime",
)
class TestTemplateRuntimeStandard(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 test_guarded_docs_avoid_vendored_core_guidance(self) -> None:
for rel_path in DOC_PATHS:
text = self._read(rel_path)
for phrase in FORBIDDEN_PHRASES:
self.assertNotIn(
phrase,
text,
msg=f"{rel_path} must not contain {phrase!r}",
)
def test_guarded_docs_promote_shared_runtime_standard(self) -> None:
combined = "\n".join(self._read(p) for p in RUNTIME_DOC_PATHS)
for marker in POSITIVE_MARKERS:
self.assertIn(
marker,
combined,
msg=f"docs should mention {marker!r}",
)
self.assertTrue(
"不得 vendored" in combined or "不要 vendor" in combined,
msg="docs should state skills must not vendor jiangchang_skill_core",
)
def test_runtime_md_mentions_collect_runtime_diagnostics(self) -> None:
text = self._read("development/RUNTIME.md")
self.assertIn("collect_runtime_diagnostics", text)
def test_cli_md_mentions_health_runtime_diagnostics(self) -> None:
text = self._read("references/CLI.md")
self.assertIn("python_executable", text)
self.assertIn("platform_kit_version", text)
self.assertIn("jiangchang_skill_core_file", text)
self.assertIn("python-runtime", text)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,358 +0,0 @@
# -*- coding: utf-8 -*-
"""标准时间字段:Unix 秒级时间戳、默认值、trigger 与元数据。"""
from __future__ import annotations
import sqlite3
import time
import unittest
from _support import DEMO_ITEMS_TABLE, IsolatedDataRoot, seed_demo_items
class TestTimestampColumns(unittest.TestCase):
def test_seed_creates_integer_timestamp_columns_with_defaults(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata import list_physical_column_names
from db.display_metadata_validator import _table_column_info
init_db()
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
conn.commit()
physical = list_physical_column_names(cur, DEMO_ITEMS_TABLE)
self.assertEqual(physical[-2:], ["created_at", "updated_at"])
info = _table_column_info(cur, DEMO_ITEMS_TABLE)
for column_name in ("created_at", "updated_at"):
column = info[column_name]
self.assertIn("INT", str(column["type"]).upper())
self.assertTrue(str(column["dflt_value"]).lower().find("unixepoch") >= 0)
finally:
conn.close()
def test_insert_without_timestamps_auto_generates_unix_seconds(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
before = int(time.time())
cur.execute(
"INSERT INTO demo_items (title, status) VALUES ('auto-ts', 'draft')"
)
conn.commit()
cur.execute(
"SELECT created_at, updated_at FROM demo_items ORDER BY id DESC LIMIT 1"
)
created_at, updated_at = cur.fetchone()
after = int(time.time())
self.assertIsInstance(created_at, int)
self.assertIsInstance(updated_at, int)
self.assertGreaterEqual(created_at, before)
self.assertLessEqual(created_at, after)
self.assertEqual(created_at, updated_at)
finally:
conn.close()
def test_update_business_field_keeps_created_at_and_refreshes_updated_at(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
cur.execute(
"""
INSERT INTO demo_items (title, status, created_at, updated_at)
VALUES ('update-ts', 'draft', 1000, 1000)
"""
)
row_id = int(cur.lastrowid)
conn.commit()
cur.execute("UPDATE demo_items SET status = 'published' WHERE id = ?", (row_id,))
conn.commit()
cur.execute(
"SELECT created_at, updated_at FROM demo_items WHERE id = ?",
(row_id,),
)
created_at, updated_at = cur.fetchone()
self.assertEqual(created_at, 1000)
self.assertGreater(updated_at, 1000)
finally:
conn.close()
def test_reinit_does_not_rewrite_existing_timestamps(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
cur.execute(
"""
INSERT INTO demo_items (title, status, created_at, updated_at)
VALUES ('stable-ts', 'draft', 1234567890, 1234567891)
"""
)
conn.commit()
finally:
conn.close()
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT created_at, updated_at FROM demo_items WHERE title = 'stable-ts'"
)
self.assertEqual(cur.fetchone(), (1234567890, 1234567891))
finally:
conn.close()
def test_timestamp_metadata_contract(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata import DATETIME_UNIX_SECONDS
init_db()
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
conn.commit()
cur.execute(
"""
SELECT column_name, display_name, display_type, editable, display_order
FROM _jiangchang_columns
WHERE table_name = ?
AND column_name IN ('created_at', 'updated_at')
""",
(DEMO_ITEMS_TABLE,),
)
by_name = {
name: (display_name, display_type, editable, display_order)
for name, display_name, display_type, editable, display_order in cur.fetchall()
}
self.assertEqual(by_name["created_at"][0], "创建时间")
self.assertEqual(by_name["updated_at"][0], "更新时间")
self.assertEqual(by_name["created_at"][1], DATETIME_UNIX_SECONDS)
self.assertEqual(by_name["updated_at"][1], DATETIME_UNIX_SECONDS)
self.assertEqual(int(by_name["created_at"][2]), 0)
self.assertEqual(int(by_name["updated_at"][2]), 0)
self.assertIsNone(by_name["created_at"][3])
self.assertIsNone(by_name["updated_at"][3])
finally:
conn.close()
def test_metadata_upsert_keeps_timestamp_display_type(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata import DATETIME_UNIX_SECONDS
init_db()
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
seed_demo_items(cur)
conn.commit()
cur.execute(
"""
SELECT display_type FROM _jiangchang_columns
WHERE table_name = ? AND column_name = 'created_at'
""",
(DEMO_ITEMS_TABLE,),
)
self.assertEqual(cur.fetchone()[0], DATETIME_UNIX_SECONDS)
finally:
conn.close()
def test_insert_uses_database_timestamps(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
before = int(time.time())
cur.execute(
"INSERT INTO demo_items (title, status) VALUES ('repo-ts', 'draft')"
)
row_id = int(cur.lastrowid)
conn.commit()
after = int(time.time())
cur.execute(
"SELECT created_at, updated_at FROM demo_items WHERE id = ?",
(row_id,),
)
created_at, updated_at = cur.fetchone()
self.assertGreaterEqual(int(created_at), before)
self.assertLessEqual(int(created_at), after)
self.assertEqual(created_at, updated_at)
finally:
conn.close()
def test_validator_rejects_missing_display_type(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata_validator import validate_template_database
init_db()
conn = get_conn()
try:
seed_demo_items(conn.cursor())
conn.execute(
"UPDATE _jiangchang_columns SET display_type = NULL "
"WHERE table_name = ? AND column_name = 'created_at'",
(DEMO_ITEMS_TABLE,),
)
conn.commit()
report = validate_template_database(conn)
self.assertFalse(report.ok)
self.assertTrue(any("display_type" in err for err in report.errors))
finally:
conn.close()
def test_validator_rejects_wrong_timestamp_type(self) -> None:
conn = sqlite3.connect(":memory:")
try:
from db.display_metadata import init_display_metadata
from db.display_metadata_validator import validate_display_metadata
conn.executescript(
"""
CREATE TABLE demo_items (
id INTEGER PRIMARY KEY,
title TEXT,
created_at TEXT
);
"""
)
init_display_metadata(conn.cursor())
conn.execute(
"""
INSERT INTO _jiangchang_tables (table_name, display_name, visible, readonly)
VALUES ('demo_items', '演示', 1, 0)
"""
)
for column_name, display_name in (
("id", "编号"),
("title", "标题"),
("created_at", "创建时间"),
):
conn.execute(
"""
INSERT INTO _jiangchang_columns
(table_name, column_name, display_name, visible, searchable, editable, display_type)
VALUES ('demo_items', ?, ?, 1, 0, 0, 'datetime_unix_seconds')
""",
(column_name, display_name),
)
conn.commit()
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(any("created_at 字段类型应为 INTEGER" in err for err in report.errors))
finally:
conn.close()
def test_validator_rejects_editable_timestamp(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata_validator import validate_template_database
init_db()
conn = get_conn()
try:
seed_demo_items(conn.cursor())
conn.execute(
"UPDATE _jiangchang_columns SET editable = 1 "
"WHERE table_name = ? AND column_name = 'updated_at'",
(DEMO_ITEMS_TABLE,),
)
conn.commit()
report = validate_template_database(conn)
self.assertFalse(report.ok)
self.assertTrue(any("editable 应为 0" in err for err in report.errors))
finally:
conn.close()
def test_validator_does_not_flag_amount_as_timestamp(self) -> None:
conn = sqlite3.connect(":memory:")
try:
from db.display_metadata import init_display_metadata
from db.display_metadata_validator import validate_display_metadata
conn.executescript(
"""
CREATE TABLE demo_items (
id INTEGER PRIMARY KEY,
amount INTEGER,
count INTEGER
);
"""
)
init_display_metadata(conn.cursor())
conn.execute(
"""
INSERT INTO _jiangchang_tables (table_name, display_name, visible, readonly)
VALUES ('demo_items', '演示', 1, 0)
"""
)
for column_name, display_name in (
("id", "编号"),
("amount", "数量"),
("count", "计数"),
):
conn.execute(
"""
INSERT INTO _jiangchang_columns
(table_name, column_name, display_name, visible, searchable, editable)
VALUES ('demo_items', ?, ?, 1, 1, 1)
""",
(column_name, display_name),
)
conn.commit()
report = validate_display_metadata(conn)
self.assertFalse(any("amount" in err and "display_type" in err for err in report.errors))
self.assertFalse(any("count" in err and "display_type" in err for err in report.errors))
finally:
conn.close()
def test_trigger_creation_is_idempotent(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.timestamp_columns import has_updated_at_trigger, updated_at_trigger_name
init_db()
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
seed_demo_items(cur)
conn.commit()
self.assertTrue(has_updated_at_trigger(cur, DEMO_ITEMS_TABLE))
cur.execute(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name = ?",
(updated_at_trigger_name(DEMO_ITEMS_TABLE),),
)
self.assertEqual(int(cur.fetchone()[0]), 1)
finally:
conn.close()
if __name__ == "__main__":
unittest.main()

View File

@@ -1,221 +0,0 @@
# -*- 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()

View File

@@ -1,314 +0,0 @@
# -*- coding: utf-8 -*-
"""RpaVideoSession 模板示范与 finish() video 字段 / CLI 输出测试。"""
from __future__ import annotations
import io
import os
import re
import unittest
from contextlib import redirect_stderr, redirect_stdout
from unittest.mock import AsyncMock, MagicMock, patch
from _support import IsolatedDataRoot, get_skill_root
from jiangchang_skill_core import config
def _active_env_value(text: str, key: str) -> str | None:
"""Return the active (non-comment) KEY=value line from .env-style text."""
prefix = f"{key}="
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
active = stripped.split("#", 1)[0].strip()
if active.startswith(prefix):
return active
return None
def _combined_service_source(skill_root: str | None = None) -> str:
"""Merge scripts/service/*.py source (excludes __pycache__)."""
root = skill_root or get_skill_root()
service_dir = os.path.join(root, "scripts", "service")
parts: list[str] = []
for dirpath, dirnames, filenames in os.walk(service_dir):
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
for name in sorted(filenames):
if not name.endswith(".py"):
continue
path = os.path.join(dirpath, name)
with open(path, encoding="utf-8") as f:
parts.append(f.read())
return "\n".join(parts)
def _missing_video_integration_tokens(service_text: str) -> list[str]:
missing: list[str] = []
if "RpaVideoSession" not in service_text:
missing.append("RpaVideoSession")
if "video.add_step" not in service_text and ".add_step(" not in service_text:
missing.append("video.add_step or .add_step(")
if "merge_video_into_result_summary" not in service_text:
missing.append("merge_video_into_result_summary")
return missing
class TestEnvExampleVideoDefaults(unittest.TestCase):
def test_env_example_contains_required_keys(self) -> None:
path = os.path.join(get_skill_root(), ".env.example")
with open(path, encoding="utf-8") as f:
text = f.read()
for key in (
"OPENCLAW_" + "TEST_TARGET=real_" + "rpa",
"OPENCLAW_RECORD_VIDEO=0",
"OPENCLAW_ARTIFACTS_ON_FAILURE=1",
"OPENCLAW_BROWSER_HEADLESS=0",
"OPENCLAW_PLAYWRIGHT_STEALTH=1",
):
self.assertIn(key, text, msg=f"missing {key!r} in .env.example")
def test_env_example_default_off_but_template_keeps_video_integration(self) -> None:
path = os.path.join(get_skill_root(), ".env.example")
with open(path, encoding="utf-8") as f:
text = f.read()
self.assertEqual(
_active_env_value(text, "OPENCLAW_RECORD_VIDEO"),
"OPENCLAW_RECORD_VIDEO=0",
)
service_text = _combined_service_source()
missing = _missing_video_integration_tokens(service_text)
self.assertEqual(
missing,
[],
msg=(
"default OPENCLAW_RECORD_VIDEO=0 does not allow removing video integration; "
f"scripts/service/*.py missing: {', '.join(missing)}"
),
)
def test_env_example_allows_record_video_one_in_comments_only(self) -> None:
sample = "\n".join(
[
"# example: OPENCLAW_RECORD_VIDEO=1 for production",
"OPENCLAW_RECORD_VIDEO=0",
]
)
self.assertEqual(
_active_env_value(sample, "OPENCLAW_RECORD_VIDEO"),
"OPENCLAW_RECORD_VIDEO=0",
)
class TestPrintVideoSummary(unittest.TestCase):
def test_cli_prints_video_path_when_enabled(self) -> None:
from service.task_run_support import _print_video_summary
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
_print_video_summary(
{
"enabled": True,
"path": r"D:\data\videos\verify-invoice-default_demo.mp4",
"record_log_path": r"D:\data\logs\ffmpeg-record.log",
"warnings": [],
}
)
out = buf.getvalue()
self.assertIn("录屏路径", out)
self.assertIn("verify-invoice-default_demo.mp4", out)
self.assertIn("录屏日志", out)
def test_disabled_video_prints_nothing(self) -> None:
from service.task_run_support import _print_video_summary
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
_print_video_summary({"enabled": False, "path": r"D:\tmp\x.mp4"})
self.assertEqual(buf.getvalue(), "")
class TestMergeVideoIntoResultSummary(unittest.TestCase):
def test_finish_payload_includes_video_fields(self) -> None:
from service.task_run_support import merge_video_into_result_summary
video_info = {
"enabled": True,
"path": r"D:\data\videos\demo.mp4",
"capture_path": r"D:\data\capture.mp4",
"record_log_path": r"D:\data\logs\ffmpeg-record.log",
"warnings": ["test_warning"],
"voiceover_path": r"D:\data\voiceover.wav",
"music_path": r"D:\data\music.mp3",
"audio_warnings": ["tts_skipped"],
}
payload = merge_video_into_result_summary({"demo": True}, video_info)
self.assertIn("video", payload)
self.assertEqual(payload["video_path"], video_info["path"])
self.assertEqual(payload["raw_video"], video_info["capture_path"])
self.assertEqual(payload["video_log"], video_info["record_log_path"])
self.assertEqual(payload["video_warnings"], ["test_warning"])
self.assertEqual(payload["voiceover_path"], video_info["voiceover_path"])
self.assertEqual(payload["music_path"], video_info["music_path"])
self.assertEqual(payload["audio_warnings"], ["tts_skipped"])
class TestTemplateRunVideoSession(unittest.TestCase):
def _fake_video_session(self, mock_video: MagicMock):
class _FakeVideoSession:
def __init__(self, **kwargs):
self.kwargs = kwargs
async def __aenter__(self):
return mock_video
async def __aexit__(self, *args):
return None
return _FakeVideoSession
def test_cmd_run_creates_video_session_with_chinese_titles(self) -> None:
with IsolatedDataRoot(user_id="_video_run"):
os.environ["OPENCLAW_RECORD_VIDEO"] = "0"
config.reset_cache()
from service import task_service
mock_video = MagicMock()
mock_video.add_step = MagicMock()
mock_video.summary.return_value = {
"enabled": False,
"path": None,
"capture_path": None,
"record_log_path": None,
"warnings": [],
"voiceover_path": None,
"music_path": None,
"audio_warnings": [],
}
created: list[dict] = []
class _CapturingFakeSession:
def __init__(self, **kwargs):
created.append(kwargs)
async def __aenter__(self):
return mock_video
async def __aexit__(self, *args):
return None
with patch.object(task_service, "RpaVideoSession", _CapturingFakeSession):
finish_kwargs: list[dict] = []
original_finish = task_service.finish
def _capture_finish(*args, **kwargs):
finish_kwargs.append(kwargs)
return original_finish(*args, **kwargs)
buf = io.StringIO()
with patch.object(task_service, "finish", side_effect=_capture_finish):
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = task_service.cmd_run(target="demo-target", input_id="42")
self.assertEqual(rc, 1)
self.assertTrue(created, "RpaVideoSession should be constructed")
kwargs = created[0]
self.assertEqual(kwargs.get("title"), "开始执行示例任务")
self.assertEqual(kwargs.get("closing_title"), "示例任务执行完成")
self.assertTrue(re.search(r"[\u4e00-\u9fff]", kwargs.get("title", "")))
self.assertTrue(re.search(r"[\u4e00-\u9fff]", kwargs.get("closing_title", "")))
mock_video.add_step.assert_any_call("准备执行示例任务")
mock_video.add_step.assert_any_call("示例任务执行完成")
mock_video.summary.assert_called()
out = buf.getvalue()
self.assertIn("模板", out)
self.assertTrue(finish_kwargs, "finish() should be called")
summary = finish_kwargs[-1]
self.assertIn("video", summary)
self.assertIn("video_path", summary)
class TestCmdRunFinishCoverage(unittest.TestCase):
def test_cmd_run_entitlement_failure_calls_finish(self) -> None:
with IsolatedDataRoot(user_id="_entitlement_fail"):
from service import task_service
finish_kwargs: list[dict] = []
original_finish = task_service.finish
def _capture_finish(*args, **kwargs):
finish_kwargs.append(kwargs)
return original_finish(*args, **kwargs)
with patch.object(task_service, "check_entitlement", return_value=(False, "mock denied")):
with patch.object(task_service, "finish", side_effect=_capture_finish):
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = task_service.cmd_run(target="t1", input_id="99")
self.assertEqual(rc, 1)
self.assertTrue(finish_kwargs)
payload = finish_kwargs[-1]
self.assertEqual(payload.get("status"), "failed")
self.assertEqual(payload.get("error_code"), "ENTITLEMENT_DENIED")
self.assertEqual(payload.get("stage"), "auth")
self.assertIn("mock denied", payload.get("message") or "")
def test_cmd_run_exception_calls_finish(self) -> None:
with IsolatedDataRoot(user_id="_exception_run"):
os.environ["OPENCLAW_RECORD_VIDEO"] = "0"
config.reset_cache()
from service import task_service
async def _boom(*_args, **_kwargs):
raise RuntimeError("boom")
finish_kwargs: list[dict] = []
original_finish = task_service.finish
def _capture_finish(*args, **kwargs):
finish_kwargs.append(kwargs)
return original_finish(*args, **kwargs)
with patch.object(task_service, "check_entitlement", return_value=(True, "")):
with patch.object(task_service, "_run_template_demo", side_effect=_boom):
with patch.object(task_service, "finish", side_effect=_capture_finish):
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = task_service.cmd_run(target="t2", input_id="88")
self.assertEqual(rc, 1)
self.assertTrue(finish_kwargs)
payload = finish_kwargs[-1]
self.assertEqual(payload.get("status"), "failed")
self.assertEqual(payload.get("stage"), "run")
self.assertIn("任务执行异常", payload.get("message") or "")
def test_get_task_logger_fallback_when_not_initialized(self) -> None:
from service import task_service
mock_logger = MagicMock()
responses = [RuntimeError("logging not initialized"), mock_logger]
def _fake_get_skill_logger():
item = responses.pop(0)
if isinstance(item, Exception):
raise item
return item
with patch.object(task_service, "get_skill_logger", side_effect=_fake_get_skill_logger):
with patch.object(task_service, "setup_skill_logging") as mock_setup:
logger = task_service._get_task_logger()
mock_setup.assert_called_once_with(task_service.SKILL_SLUG, task_service.LOG_LOGGER_NAME)
self.assertIs(logger, mock_logger)
if __name__ == "__main__":
unittest.main()