feat(tester): add catalog-driven black-box test skill
Maintain and run project test catalogs under .pouch/tester, filtered by category, id, and risk (safe/act/harm). Scripts generate the human CSV from cases YAML.
This commit is contained in:
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/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",
|
||||
"suite",
|
||||
"risk",
|
||||
"gate",
|
||||
"surface",
|
||||
"session",
|
||||
"status",
|
||||
"requires",
|
||||
"setup",
|
||||
"how",
|
||||
"expected",
|
||||
"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),
|
||||
str(case.get("id") or ""),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def export_cases_csv(root: Path, out: Path | None = None) -> Path:
|
||||
cases = sort_cases(root, load_cases(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:
|
||||
writer.writerow(
|
||||
{
|
||||
"category": case.get("category") or "",
|
||||
"id": case.get("id") or "",
|
||||
"maps_to": case.get("maps_to") or "",
|
||||
"title": case.get("title") or "",
|
||||
"what": one_line(case.get("what")),
|
||||
"suite": case.get("suite") or "",
|
||||
"risk": case.get("risk") or "",
|
||||
"gate": case.get("gate") or "",
|
||||
"surface": case.get("surface") or "",
|
||||
"session": case.get("session") or "",
|
||||
"status": case.get("status") or "active",
|
||||
"requires": one_line(case.get("requires")),
|
||||
"setup": one_line(case.get("setup")),
|
||||
"how": how_brief(case.get("how")),
|
||||
"expected": one_line(case.get("expected")),
|
||||
"notes": one_line(case.get("notes")),
|
||||
}
|
||||
)
|
||||
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())
|
||||
Reference in New Issue
Block a user