cc9af04a40
字段改名(每项取舍的理由见新增的 references/concepts.md):
- 用例 status -> lifecycle, suite -> tag, surface -> driver,
cleanup -> teardown, skip_unless -> assume;删除 requires
- risk 取值 safe/act/harm -> readonly/mutating/destructive(单调三级,
与 gate 正交)
- catalog auth_env -> authorize;授权变量统一为
AUTHORIZE_MUTATING / AUTHORIZE_DESTRUCTIVE
新增用例字段 automation(automated/assisted/manual),默认 automated,
并在 validate 里机器校验:manual 禁用 driver 与 how.run 且必须有
how.steps;assisted 至少一步以 [人工] 开头。select 新增 --automation。
结果词新增 not-run,区分「主动不跑」(skipped:retired/pending/超风险
上限/未授权/assume 不成立)与「没轮到」(失败即停后、被分类前置挡住整组、
需人工而无人值守)。
status_probe 定为分类级前置条件:catalog 顶层为默认,categories[] 同名键
覆盖(缺省继承 / false 关闭 / mapping 整体替换)。select 按分类返回生效值,
并把 probe.url 用 vars 展开(solve Agent 无法自行解析 ${VAR} 的问题),
同批返回 vars 供 how 里的 ${...} 替换。
新增 references/concepts.md:术语表(每词一句唯一定义 + 业界锚点 +
反例)、v1->v2 映射、以及 risk/automation/not-run/status_probe 的设计理由。
其余文档(SKILL/README/schema/levels/run/maintain)与三个脚本(catalog、
generate_cases_csv、generate_run_csv)同步更新。
225 lines
7.2 KiB
Python
Executable File
225 lines
7.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""从 .pouch/tester/cases/*.yaml 生成给人看的 cases.csv。不要手改 CSV。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
try:
|
|
import yaml
|
|
except ImportError: # pragma: no cover
|
|
print("需要 PyYAML: python3 -m pip install pyyaml", file=sys.stderr)
|
|
raise SystemExit(2)
|
|
|
|
FIELDS = (
|
|
"category",
|
|
"id",
|
|
"maps_to",
|
|
"title",
|
|
"what",
|
|
"tag",
|
|
"risk",
|
|
"gate",
|
|
"driver",
|
|
"automation",
|
|
"session",
|
|
"lifecycle",
|
|
"setup",
|
|
"how",
|
|
"expected",
|
|
"notes",
|
|
"last_run",
|
|
"last_result",
|
|
"last_executed",
|
|
"last_evidence",
|
|
"last_notes",
|
|
)
|
|
|
|
|
|
def tester_dir(root: Path) -> Path:
|
|
return root / ".pouch" / "tester"
|
|
|
|
|
|
def git_root(start: Path) -> Path | None:
|
|
cur = start.resolve()
|
|
if cur.is_file():
|
|
cur = cur.parent
|
|
for path in [cur, *cur.parents]:
|
|
if (path / ".git").exists() and (path / ".pouch" / "tester" / "cases").is_dir():
|
|
return path
|
|
return None
|
|
|
|
|
|
def resolve_root(explicit: str) -> Path:
|
|
path = Path(explicit).resolve()
|
|
if (path / ".pouch" / "tester" / "cases").is_dir():
|
|
return path
|
|
found = git_root(Path.cwd())
|
|
if found is not None:
|
|
return found
|
|
return path
|
|
|
|
|
|
def load_yaml(path: Path) -> Any:
|
|
return yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def one_line(value: Any) -> str:
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, list):
|
|
return " | ".join(part for part in (one_line(item) for item in value) if part)
|
|
if isinstance(value, dict):
|
|
if "kind" in value and "value" in value:
|
|
return f"{value['kind']}: {value['value']}"
|
|
return " | ".join(f"{key}={one_line(item)}" for key, item in value.items())
|
|
text = str(value).replace("\r\n", "\n").replace("\r", "\n")
|
|
return " | ".join(line.strip() for line in text.split("\n") if line.strip())
|
|
|
|
|
|
def how_brief(how: Any) -> str:
|
|
if not isinstance(how, dict):
|
|
return one_line(how)
|
|
bits: list[str] = []
|
|
if how.get("url"):
|
|
bits.append(f"url={how['url']}")
|
|
if how.get("run"):
|
|
bits.append("run: " + one_line(how["run"]))
|
|
if how.get("steps"):
|
|
bits.append(one_line(how["steps"]))
|
|
return " | ".join(bits)
|
|
|
|
|
|
def category_order(root: Path) -> list[str]:
|
|
catalog = tester_dir(root) / "catalog.yaml"
|
|
if not catalog.is_file():
|
|
return []
|
|
data = load_yaml(catalog) or {}
|
|
if not isinstance(data, dict):
|
|
return []
|
|
order: list[str] = []
|
|
for item in data.get("categories") or []:
|
|
if isinstance(item, dict) and item.get("id"):
|
|
order.append(str(item["id"]))
|
|
return order
|
|
|
|
|
|
def load_cases(root: Path) -> list[dict[str, Any]]:
|
|
cases_dir = tester_dir(root) / "cases"
|
|
if not cases_dir.is_dir():
|
|
raise SystemExit(f"找不到用例目录: {cases_dir}")
|
|
files = sorted(path for path in cases_dir.glob("*.yaml") if path.is_file())
|
|
if not files:
|
|
raise SystemExit(f"{cases_dir} 里没有 yaml")
|
|
out: list[dict[str, Any]] = []
|
|
for path in files:
|
|
data = load_yaml(path)
|
|
if not isinstance(data, dict):
|
|
raise SystemExit(f"{path} 必须是 mapping")
|
|
file_cat = data.get("category")
|
|
rows = data.get("cases") or []
|
|
if not isinstance(rows, list):
|
|
raise SystemExit(f"{path}: cases 必须是列表")
|
|
for index, case in enumerate(rows):
|
|
if not isinstance(case, dict):
|
|
raise SystemExit(f"{path}: cases[{index}] 必须是 mapping")
|
|
item = dict(case)
|
|
if not item.get("category") and file_cat:
|
|
item["category"] = file_cat
|
|
item["_file"] = str(path)
|
|
out.append(item)
|
|
return out
|
|
|
|
|
|
def sort_cases(root: Path, cases: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
order = category_order(root)
|
|
for case in cases:
|
|
cat = str(case.get("category") or "")
|
|
if cat and cat not in order:
|
|
order.append(cat)
|
|
rank = {name: index for index, name in enumerate(order)}
|
|
return sorted(
|
|
cases,
|
|
key=lambda case: rank.get(str(case.get("category") or ""), 999),
|
|
)
|
|
|
|
|
|
def last_run_lookup(root: Path) -> tuple[str, dict[str, dict[str, Any]]]:
|
|
from generate_run_csv import latest_run_yaml, load_run, results_by_id
|
|
|
|
run_path = latest_run_yaml(root)
|
|
if run_path is None:
|
|
return "", {}
|
|
try:
|
|
run = load_run(run_path)
|
|
except SystemExit:
|
|
return "", {}
|
|
try:
|
|
rel = str(run_path.relative_to(root))
|
|
except ValueError:
|
|
rel = str(run_path)
|
|
return rel, results_by_id(run)
|
|
|
|
|
|
def export_cases_csv(root: Path, out: Path | None = None) -> Path:
|
|
from generate_run_csv import executed_cell
|
|
|
|
cases = sort_cases(root, load_cases(root))
|
|
last_run, last_by_id = last_run_lookup(root)
|
|
path = out or (tester_dir(root) / "cases.csv")
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.open("w", encoding="utf-8-sig", newline="") as handle:
|
|
writer = csv.DictWriter(handle, fieldnames=list(FIELDS), extrasaction="ignore")
|
|
writer.writeheader()
|
|
for case in cases:
|
|
cid = str(case.get("id") or "")
|
|
last = last_by_id.get(cid) or {}
|
|
last_result = str(last.get("result") or "") if last else ""
|
|
writer.writerow(
|
|
{
|
|
"category": case.get("category") or "",
|
|
"id": cid,
|
|
"maps_to": case.get("maps_to") or "",
|
|
"title": case.get("title") or "",
|
|
"what": one_line(case.get("what")),
|
|
"tag": case.get("tag") or "",
|
|
"risk": case.get("risk") or "",
|
|
"gate": case.get("gate") or "",
|
|
"driver": case.get("driver") or "",
|
|
"automation": case.get("automation") or "automated",
|
|
"session": case.get("session") or "",
|
|
"lifecycle": case.get("lifecycle") or "active",
|
|
"setup": one_line(case.get("setup")),
|
|
"how": how_brief(case.get("how")),
|
|
"expected": one_line(case.get("expected")),
|
|
"notes": one_line(case.get("notes")),
|
|
"last_run": last_run if last else "",
|
|
"last_result": last_result,
|
|
"last_executed": executed_cell(last_result) if last else "",
|
|
"last_evidence": one_line(last.get("evidence")) if last else "",
|
|
"last_notes": one_line(last.get("notes")) if last else "",
|
|
}
|
|
)
|
|
return path
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description="从 cases/*.yaml 生成 cases.csv")
|
|
parser.add_argument("--root", default=".", help="项目根")
|
|
parser.add_argument("--out", help="输出路径,默认 <root>/.pouch/tester/cases.csv")
|
|
args = parser.parse_args(argv)
|
|
root = resolve_root(args.root)
|
|
out = Path(args.out).resolve() if args.out else None
|
|
path = export_cases_csv(root, out)
|
|
n = sum(1 for _ in path.open(encoding="utf-8-sig")) - 1
|
|
print(f"wrote {path} rows={n}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|