229 lines
7.9 KiB
Python
229 lines
7.9 KiB
Python
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = ["pytest>=8.0"]
|
|
# ///
|
|
"""Tests for pick_methods.py.
|
|
|
|
Run: uv run scripts/tests/test_pick_methods.py
|
|
or: uv run --with pytest -m pytest scripts/tests/test_pick_methods.py
|
|
"""
|
|
import json
|
|
import random
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
import pick_methods # noqa: E402
|
|
|
|
CSV = """num,category,method_name,description,output_pattern
|
|
1,risk,Pre-mortem Analysis,Imagine future failure then work backwards,failure → causes → prevention
|
|
2,risk,Assumption Audit,List and stress-test every assumption,list → rate → stress-test
|
|
3,core,First Principles Analysis,Rebuild from fundamental truths,assumptions → truths → new approach
|
|
4,core,Socratic Questioning,Targeted questions reveal hidden assumptions,questions → revelations → understanding
|
|
5,creative,SCAMPER Method,Seven creativity lenses,S→C→A→M→P→E→R
|
|
"""
|
|
|
|
EXTRA = [
|
|
{
|
|
"code": "reg-inversion",
|
|
"category": "domain",
|
|
"method_name": "Regulatory Inversion",
|
|
"description": "Start from the compliance constraint",
|
|
"output_pattern": "constraint → possibility",
|
|
},
|
|
{
|
|
"code": "premortem-lite",
|
|
"category": "risk",
|
|
"method_name": "Pre-mortem Analysis",
|
|
"description": "RETUNED pre-mortem",
|
|
"output_pattern": "failure → prevention",
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def lib(tmp_path):
|
|
csv_path = tmp_path / "methods.csv"
|
|
csv_path.write_text(CSV, encoding="utf-8")
|
|
return csv_path
|
|
|
|
|
|
def rows(lib):
|
|
return pick_methods.load(lib)
|
|
|
|
|
|
# --- load / merge -----------------------------------------------------------
|
|
|
|
def test_load_all_fields_present(lib):
|
|
r = rows(lib)
|
|
assert len(r) == 5
|
|
assert r[0]["method_name"] == "Pre-mortem Analysis"
|
|
assert all(set(pick_methods.FIELDS) <= set(row) for row in r)
|
|
|
|
|
|
def test_load_extra_json_literal_and_file(tmp_path, lib):
|
|
literal = pick_methods.load_extra(json.dumps(EXTRA))
|
|
f = tmp_path / "extra.json"
|
|
f.write_text(json.dumps(EXTRA), encoding="utf-8")
|
|
from_file = pick_methods.load_extra(str(f))
|
|
assert literal == from_file
|
|
assert literal[0]["method_name"] == "Regulatory Inversion"
|
|
assert literal[0]["num"] == "" # missing fields normalize to empty
|
|
assert literal[0]["code"] == "reg-inversion" # code survives loading
|
|
|
|
|
|
def test_merge_extra_replaces_by_name_and_appends(lib):
|
|
merged = pick_methods.merge_extra(rows(lib), pick_methods.load_extra(json.dumps(EXTRA)))
|
|
assert len(merged) == 6 # 5 shipped, 1 replaced in place, 1 appended
|
|
premortem = next(r for r in merged if r["method_name"] == "Pre-mortem Analysis")
|
|
assert premortem["description"] == "RETUNED pre-mortem"
|
|
assert premortem["num"] == "1" # replacement inherits the shipped num
|
|
appended = next(r for r in merged if r["method_name"] == "Regulatory Inversion")
|
|
assert appended["num"] == "6" # appended extras get the next free num
|
|
assert dict(pick_methods.categories(merged))["domain"] == 1 # new category is first-class
|
|
|
|
|
|
def test_extras_are_addressable_by_num(lib):
|
|
merged = pick_methods.merge_extra(rows(lib), pick_methods.load_extra(json.dumps(EXTRA)))
|
|
found, missing = pick_methods.find(merged, ["6", "1"])
|
|
assert [r["method_name"] for r in found] == ["Regulatory Inversion", "Pre-mortem Analysis"]
|
|
assert missing == []
|
|
|
|
|
|
# --- categories / filter / find / exclude -----------------------------------
|
|
|
|
def test_categories_counts_sorted(lib):
|
|
assert pick_methods.categories(rows(lib)) == [("core", 2), ("creative", 1), ("risk", 2)]
|
|
|
|
|
|
def test_filter_is_case_insensitive(lib):
|
|
got = pick_methods.filter_cats(rows(lib), ["RISK"])
|
|
assert {r["method_name"] for r in got} == {"Pre-mortem Analysis", "Assumption Audit"}
|
|
|
|
|
|
def test_filter_none_returns_all(lib):
|
|
assert len(pick_methods.filter_cats(rows(lib), None)) == 5
|
|
|
|
|
|
def test_find_by_name_num_and_missing(lib):
|
|
found, missing = pick_methods.find(rows(lib), ["scamper method", "3", "Nope"])
|
|
assert [r["method_name"] for r in found] == ["SCAMPER Method", "First Principles Analysis"]
|
|
assert missing == ["Nope"]
|
|
|
|
|
|
def test_exclude_skips_named(lib):
|
|
got = pick_methods.exclude(rows(lib), ["pre-mortem analysis", "SCAMPER Method"])
|
|
assert {r["method_name"] for r in got} == {
|
|
"Assumption Audit", "First Principles Analysis", "Socratic Questioning",
|
|
}
|
|
|
|
|
|
# --- spread sampling ---------------------------------------------------------
|
|
|
|
def test_spread_hits_distinct_categories(lib):
|
|
for seed in range(20):
|
|
picks = pick_methods.spread_sample(rows(lib), 3, random.Random(seed))
|
|
assert len({r["category"] for r in picks}) == 3
|
|
|
|
|
|
def test_spread_wraps_when_categories_run_out(lib):
|
|
picks = pick_methods.spread_sample(rows(lib), 5, random.Random(0))
|
|
assert len(picks) == 5
|
|
assert len({r["method_name"] for r in picks}) == 5 # no duplicates
|
|
|
|
|
|
def test_spread_clamps_to_pool(lib):
|
|
assert len(pick_methods.spread_sample(rows(lib), 99, random.Random(0))) == 5
|
|
|
|
|
|
# --- CLI ---------------------------------------------------------------------
|
|
|
|
def run(args, lib, capsys):
|
|
code = pick_methods.main(["--file", str(lib), *args])
|
|
captured = capsys.readouterr()
|
|
return code, captured.out, captured.err
|
|
|
|
|
|
def test_cli_categories(lib, capsys):
|
|
code, out, _ = run(["categories"], lib, capsys)
|
|
assert code == 0
|
|
assert "risk\t2" in out
|
|
|
|
|
|
def test_cli_list_requires_scope(lib, capsys):
|
|
code, _, err = run(["list"], lib, capsys)
|
|
assert code == 2
|
|
assert "--category" in err
|
|
|
|
|
|
def test_cli_list_category_and_all(lib, capsys):
|
|
code, out, _ = run(["list", "--category", "core"], lib, capsys)
|
|
assert code == 0 and len(out.strip().splitlines()) == 2
|
|
assert "Socratic Questioning" in out and "SCAMPER" not in out
|
|
code, out, _ = run(["list", "--all"], lib, capsys)
|
|
assert code == 0 and "SCAMPER" in out
|
|
|
|
|
|
def test_cli_show_found_and_missing(lib, capsys):
|
|
code, out, err = run(["show", "Assumption Audit", "Ghost"], lib, capsys)
|
|
assert code == 0
|
|
assert "stress-test" in out
|
|
assert "not found: Ghost" in err
|
|
code, _, _ = run(["show", "Ghost"], lib, capsys)
|
|
assert code == 1
|
|
|
|
|
|
def test_cli_random_spread_exclude(lib, capsys):
|
|
code, out, _ = run(
|
|
["random", "-n", "3", "--spread", "--exclude", "SCAMPER Method"], lib, capsys
|
|
)
|
|
assert code == 0
|
|
lines = [ln for ln in out.strip().splitlines() if ln]
|
|
assert len(lines) == 3
|
|
assert "SCAMPER" not in out
|
|
|
|
|
|
def test_cli_random_clamps_and_empty_pool(lib, capsys):
|
|
code, out, _ = run(["random", "-n", "99"], lib, capsys)
|
|
assert code == 0 and len(out.strip().splitlines()) == 5
|
|
code, _, err = run(["random", "--category", "nope"], lib, capsys)
|
|
assert code == 1 and "no methods match" in err
|
|
|
|
|
|
def test_cli_extra_inline_json(lib, capsys):
|
|
code, out, _ = run(
|
|
["--extra", json.dumps(EXTRA), "list", "--category", "domain"], lib, capsys
|
|
)
|
|
assert code == 0 and "Regulatory Inversion" in out
|
|
|
|
|
|
def test_cli_bad_extra_and_missing_file(tmp_path, lib, capsys):
|
|
code, _, err = run(["--extra", str(tmp_path / "gone.json"), "categories"], lib, capsys)
|
|
assert code == 2 and "--extra" in err
|
|
code = pick_methods.main(["--file", str(tmp_path / "gone.csv"), "categories"])
|
|
assert code == 2
|
|
|
|
|
|
def test_cli_json_output(lib, capsys):
|
|
code, out, _ = run(["--json", "show", "1"], lib, capsys)
|
|
assert code == 0
|
|
data = json.loads(out)
|
|
assert data[0]["method_name"] == "Pre-mortem Analysis"
|
|
|
|
|
|
# --- shipped catalog integration ----------------------------------------------
|
|
|
|
def test_shipped_catalog_loads_clean():
|
|
shipped = pick_methods.DEFAULT_FILE
|
|
assert shipped.is_file(), f"shipped catalog missing: {shipped}"
|
|
r = pick_methods.load(shipped)
|
|
assert len(r) >= 60
|
|
for row in r:
|
|
assert row["category"] and row["method_name"] and row["description"], row
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main([__file__, "-q"]))
|