Files
small-scripts/tests/test-idle-draft.sh
Paul O'Reilly 6c4bf449aa idle-draft: idle-subscription dispatcher for the writing pipeline
Elapsed-paced gates, bounded-parallel event-driven dispatch, filesystem-
derived stages with thin human-gate state, citation validation with
quarantine, credential parity check, prompt templates as data.
Per IDLE-DRAFT-PLAN.md in writing/oreillyconsulting.

Claude-Session: https://claude.ai/code/session_01YQDoWNM7XPPii28khFWoMc
2026-08-02 21:18:02 +12:00

474 lines
23 KiB
Bash
Executable File

#!/usr/bin/env bash
# Test script for idle-draft: exercises --dryrun / --probe-json against fixture
# dirs, plus direct unit tests of the pure logic functions (gates, stage
# derivation, prioritisation, credential parity, citation validation, state
# validation) by importing the script as a Python module.
#
# No API call, no `claude` invocation, and no real profile dir is ever touched.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/idle-draft"
RED='\033[31m'; GREEN='\033[32m'; RESET='\033[0m'
PASS=0
FAIL=0
pass() { printf " ${GREEN}PASS${RESET}: %s\n" "$1"; PASS=$((PASS + 1)); }
fail() { printf " ${RED}FAIL${RESET}: %s\n" "$1"; [[ -n "${2:-}" ]] && printf " %s\n" "$2"; FAIL=$((FAIL + 1)); }
assert_contains() {
local output="$1" expected="$2" label="$3"
if echo "$output" | grep -qF -- "$expected"; then pass "$label"; else fail "$label" "Expected to find: $expected"; fi
}
assert_not_contains() {
local output="$1" unexpected="$2" label="$3"
if echo "$output" | grep -qF -- "$unexpected"; then fail "$label" "Did not expect: $unexpected"; else pass "$label"; fi
}
assert_exit_code() {
local actual="$1" expected="$2" label="$3"
if [[ "$actual" -eq "$expected" ]]; then pass "$label"; else fail "$label" "Expected exit $expected, got $actual"; fi
}
TMPDIR=""
cleanup() { [[ -n "$TMPDIR" && -d "$TMPDIR" ]] && rm -rf "$TMPDIR"; }
trap cleanup EXIT
TMPDIR=$(mktemp -d)
FIXTURE_REPO="$TMPDIR/repo"
FIXTURE_CONFIG="$TMPDIR/idle-draft.config.json"
FIXTURE_PROBE="$TMPDIR/probe.json"
PROFILE_ANTHROPIC="$TMPDIR/profiles/anthropic"
PROFILE_MINIMAX="$TMPDIR/profiles/minimax"
# --- Build a fixture writing-repo tree ---------------------------------
mkdir -p "$FIXTURE_REPO/ai" "$FIXTURE_REPO/style" "$PROFILE_ANTHROPIC" "$PROFILE_MINIMAX"
(
cd "$FIXTURE_REPO" && git init -q && git config user.email t@example.com && git config user.name test
)
printf '# AGENTS root\n' > "$FIXTURE_REPO/AGENTS.md"
printf '# ai AGENTS\n' > "$FIXTURE_REPO/ai/AGENTS.md"
printf '# Source register\n' > "$FIXTURE_REPO/ai/SOURCE-REGISTER.md"
printf '# goes target voice\n' > "$FIXTURE_REPO/style/goes-target-voice.md"
printf '# review prompt\n' > "$FIXTURE_REPO/style/review-prompt.md"
# Item 01: only overview+agent -> next = research
printf '# Topic one\n\nPitch.\n' > "$FIXTURE_REPO/ai/01-topic-one.overview.md"
printf '# Commissioning brief: topic one\n' > "$FIXTURE_REPO/ai/01-topic-one.agent.md"
# Item 02: overview+agent+research -> next = draft
printf '# Topic two\n\nPitch.\n' > "$FIXTURE_REPO/ai/02-topic-two.overview.md"
printf '# Commissioning brief: topic two\n' > "$FIXTURE_REPO/ai/02-topic-two.agent.md"
printf '# Research: topic two\n\nEvidence.\n' > "$FIXTURE_REPO/ai/02-topic-two.research.md"
# Item 03: research+draft, human_edit_done -> next = review
printf '# Topic three\n\nPitch.\n' > "$FIXTURE_REPO/ai/03-topic-three.overview.md"
printf '# Commissioning brief: topic three\n' > "$FIXTURE_REPO/ai/03-topic-three.agent.md"
printf '# Research: topic three\n\nEvidence.\n' > "$FIXTURE_REPO/ai/03-topic-three.research.md"
printf '# Topic three draft\n\nBody.\n' > "$FIXTURE_REPO/ai/03-topic-three.draft.md"
cat > "$FIXTURE_REPO/idle-draft.state.json" <<'EOF'
{
"items": {
"ai/03-topic-three": { "human_edit_done": true }
}
}
EOF
(cd "$FIXTURE_REPO" && git add -A && git commit -q -m init)
cat > "$FIXTURE_CONFIG" <<EOF
{
"parallel": 2,
"providers": {
"anthropic": { "profile": "$PROFILE_ANTHROPIC", "threshold_pct": 80, "five_hour_ceiling": 50, "min_idle": 5 },
"minimax": { "profile": "$PROFILE_MINIMAX", "threshold_pct": 80, "five_hour_ceiling": 50, "min_idle": 5 }
},
"work_types": {
"review": { "providers": ["anthropic"] },
"draft": { "providers": ["anthropic"] },
"research": { "providers": ["anthropic", "minimax"] },
"topic_ideas": { "providers": ["anthropic", "minimax"] }
},
"dossiers": ["ai"],
"review_score_threshold": 11,
"max_unreviewed_research_per_dossier": 3,
"max_open_topic_proposals": 6,
"evidence_dirs": ["$TMPDIR/evidence"]
}
EOF
cat > "$FIXTURE_PROBE" <<'EOF'
{
"probed_at": "2026-08-02T00:00:00+00:00",
"providers": [
{"provider": "Anthropic", "available": true, "windows": {
"five_hour": {"utilization_pct": 10.0, "reset_at": null, "reset_in_seconds": 1000, "window_seconds": 18000, "elapsed_pct": 0.5},
"seven_day": {"utilization_pct": 5.0, "reset_at": null, "reset_in_seconds": 100000, "window_seconds": 604800, "elapsed_pct": 0.5}
}},
{"provider": "MiniMax", "available": true, "windows": {
"five_hour": {"utilization_pct": 10.0, "reset_at": null, "reset_in_seconds": 1000, "window_seconds": 18000, "elapsed_pct": 0.5},
"seven_day": {"utilization_pct": 5.0, "reset_at": null, "reset_in_seconds": 100000, "window_seconds": 604800, "elapsed_pct": 0.5}
}}
]
}
EOF
# ============================================================
echo "=== idle-draft: CLI / dryrun tests ==="
echo ""
echo "-- help --"
output=$("$SCRIPT" --help 2>&1); code=$?
assert_exit_code "$code" 0 "--help exits 0"
assert_contains "$output" "Usage:" "--help shows usage"
assert_contains "$output" "--dryrun" "--help mentions --dryrun"
assert_contains "$output" "mark" "--help mentions mark subcommand"
assert_contains "$output" "status" "--help mentions status subcommand"
echo "-- dryrun: highest-priority ready item wins (review > draft > research) --"
output=$("$SCRIPT" --config "$FIXTURE_CONFIG" --repo "$FIXTURE_REPO" --probe-json "$FIXTURE_PROBE" --dryrun --parallel 1 2>&1)
code=$?
assert_exit_code "$code" 0 "dryrun exits 0"
assert_contains "$output" "item: ai/03-topic-three" "dryrun picks item 03 (review-ready, highest stage rank)"
assert_contains "$output" "work_type: review" "dryrun names work_type review"
assert_contains "$output" "provider: anthropic" "dryrun names provider anthropic"
assert_not_contains "$output" "would run" "dryrun does not execute claude"
echo "-- dryrun: exact resolved claude argv for a research item --"
# Force only the research-ready item to be eligible by pointing --repo at a
# single-item fixture (item 01 only).
SOLO="$TMPDIR/solo-repo"
mkdir -p "$SOLO/ai" "$SOLO/style"
cp "$FIXTURE_REPO/ai/01-topic-one.overview.md" "$SOLO/ai/"
cp "$FIXTURE_REPO/ai/01-topic-one.agent.md" "$SOLO/ai/"
cp "$FIXTURE_REPO/AGENTS.md" "$SOLO/AGENTS.md"
cp -r "$FIXTURE_REPO/style" "$SOLO/style"
cp "$FIXTURE_REPO/ai/SOURCE-REGISTER.md" "$SOLO/ai/SOURCE-REGISTER.md"
(cd "$SOLO" && git init -q && git config user.email t@example.com && git config user.name test && git add -A && git commit -q -m init)
output=$("$SCRIPT" --config "$FIXTURE_CONFIG" --repo "$SOLO" --probe-json "$FIXTURE_PROBE" --dryrun --once 2>&1)
assert_contains "$output" "work_type: research" "dryrun (solo fixture) picks research"
assert_contains "$output" "argv: ['claude', '-p', '--max-turns', '25', '--add-dir'" "dryrun prints resolved claude argv with --add-dir for research"
assert_contains "$output" "$TMPDIR/evidence" "dryrun argv includes configured evidence dir"
echo "-- dryrun: no mutation --"
before_hash=$(cd "$FIXTURE_REPO" && git rev-parse HEAD)
"$SCRIPT" --config "$FIXTURE_CONFIG" --repo "$FIXTURE_REPO" --probe-json "$FIXTURE_PROBE" --dryrun >/dev/null 2>&1
after_hash=$(cd "$FIXTURE_REPO" && git rev-parse HEAD)
[[ "$before_hash" == "$after_hash" ]] && pass "dryrun makes no git commits" || fail "dryrun makes no git commits"
[[ ! -f "$FIXTURE_REPO/ai/03-topic-three.review.md" ]] && pass "dryrun writes no output file" || fail "dryrun writes no output file"
echo "-- state validation failure exits 2 --"
BADSTATE="$TMPDIR/badstate-repo"
mkdir -p "$BADSTATE/ai"
cp "$FIXTURE_REPO/ai/01-topic-one.overview.md" "$BADSTATE/ai/"
cp "$FIXTURE_REPO/ai/01-topic-one.agent.md" "$BADSTATE/ai/"
echo '{"items": {"ai/01-topic-one": {"unknown_field": true}}}' > "$BADSTATE/idle-draft.state.json"
output=$("$SCRIPT" --config "$FIXTURE_CONFIG" --repo "$BADSTATE" --probe-json "$FIXTURE_PROBE" --dryrun 2>&1)
code=$?
assert_exit_code "$code" 2 "unknown item field in state exits 2"
assert_contains "$output" "unknown key" "state error names the unknown key"
echo '{"items": {"ai/does-not-exist": {}}}' > "$BADSTATE/idle-draft.state.json"
output=$("$SCRIPT" --config "$FIXTURE_CONFIG" --repo "$BADSTATE" --probe-json "$FIXTURE_PROBE" --dryrun 2>&1)
code=$?
assert_exit_code "$code" 2 "state item not resolving to overview.md exits 2"
echo "-- config validation failure exits 2 --"
BADCONFIG="$TMPDIR/bad.config.json"
echo '{"parallel": 2}' > "$BADCONFIG"
output=$("$SCRIPT" --config "$BADCONFIG" --repo "$FIXTURE_REPO" --probe-json "$FIXTURE_PROBE" --dryrun 2>&1)
code=$?
assert_exit_code "$code" 2 "config missing required keys exits 2"
echo "-- mark subcommand round-trip --"
MARKREPO="$TMPDIR/mark-repo"
mkdir -p "$MARKREPO/ai"
cp "$FIXTURE_REPO/ai/01-topic-one.overview.md" "$MARKREPO/ai/"
cp "$FIXTURE_REPO/ai/01-topic-one.agent.md" "$MARKREPO/ai/"
output=$("$SCRIPT" mark ai/01-topic-one edited --config "$FIXTURE_CONFIG" --repo "$MARKREPO" 2>&1)
code=$?
assert_exit_code "$code" 0 "mark edited exits 0"
assert_contains "$(cat "$MARKREPO/idle-draft.state.json")" '"human_edit_done": true' "mark edited sets human_edit_done"
"$SCRIPT" mark ai/01-topic-one sampled --config "$FIXTURE_CONFIG" --repo "$MARKREPO" >/dev/null 2>&1
assert_contains "$(cat "$MARKREPO/idle-draft.state.json")" '"research_sampled": true' "mark sampled sets research_sampled"
"$SCRIPT" mark ai/01-topic-one approved --config "$FIXTURE_CONFIG" --repo "$MARKREPO" >/dev/null 2>&1
assert_contains "$(cat "$MARKREPO/idle-draft.state.json")" '"approved": true' "mark approved sets approved"
output=$("$SCRIPT" mark ai/99-nope edited --config "$FIXTURE_CONFIG" --repo "$MARKREPO" 2>&1); code=$?
assert_exit_code "$code" 2 "mark on nonexistent item exits 2"
echo "-- status subcommand --"
output=$("$SCRIPT" status --config "$FIXTURE_CONFIG" --repo "$FIXTURE_REPO" --probe-json "$FIXTURE_PROBE" 2>&1)
code=$?
assert_exit_code "$code" 0 "status exits 0"
assert_contains "$output" "ai/01-topic-one" "status lists item 01"
assert_contains "$output" "next=research" "status shows item 01 next=research"
assert_contains "$output" "ai/03-topic-three" "status lists item 03"
assert_contains "$output" "next=review" "status shows item 03 next=review"
assert_contains "$output" "anthropic:" "status shows provider gate line"
echo ""
echo "=== idle-draft: pure-logic unit tests (imported module) ==="
echo ""
PYOUT=$(python3 - "$SCRIPT" "$TMPDIR" <<'PYEOF'
import sys, json, importlib.util, importlib.machinery
from pathlib import Path
script_path, tmpdir = sys.argv[1], Path(sys.argv[2])
loader = importlib.machinery.SourceFileLoader("idle_draft_under_test", script_path)
spec = importlib.util.spec_from_loader(loader.name, loader)
m = importlib.util.module_from_spec(spec)
loader.exec_module(m)
results = []
def check(name, cond, detail=""):
results.append(("PY-PASS" if cond else "PY-FAIL", name, detail))
# --- Gate math ---
pcfg = {"threshold_pct": 80, "min_idle": 5, "five_hour_ceiling": 50}
def probe(elapsed, usage_sd, usage_fh, available=True):
return {
"available": available,
"windows": {
"seven_day": {"elapsed_pct": elapsed, "utilization_pct": usage_sd},
"five_hour": {"utilization_pct": usage_fh},
},
}
g = m.compute_gate(pcfg, probe(0.5, 5.0, 10.0))
check("gate: eligible when idle_points > min_idle and 5h < ceiling",
g["eligible"] is True and abs(g["idle_points"] - 35.0) < 1e-9, g)
g = m.compute_gate(pcfg, probe(0.5, 75.0, 10.0))
check("gate: ineligible when idle_points <= min_idle",
g["eligible"] is False and g["idle_points"] is not None, g)
g = m.compute_gate(pcfg, probe(0.5, 5.0, 60.0))
check("gate: ineligible when five_hour >= ceiling", g["eligible"] is False, g)
g = m.compute_gate(pcfg, probe(None, 5.0, 10.0))
check("gate: null elapsed_pct -> ineligible, idle_points None, fail-closed",
g["eligible"] is False and g["idle_points"] is None and "cannot pace" in g["reason"], g)
g = m.compute_gate(pcfg, probe(0.5, None, 10.0))
check("gate: null seven_day utilization -> ineligible", g["eligible"] is False, g)
g = m.compute_gate(pcfg, probe(0.5, 5.0, None))
check("gate: null five_hour utilization -> ineligible", g["eligible"] is False, g)
g = m.compute_gate(pcfg, {"available": False, "error": "boom"})
check("gate: unavailable provider -> ineligible", g["eligible"] is False and "unavailable" in g["reason"], g)
g = m.compute_gate(pcfg, None)
check("gate: no probe data -> ineligible", g["eligible"] is False, g)
g = m.compute_gate(pcfg, probe(1.0, 5.0, 10.0))
check("gate: elapsed_pct=1.0 (fully elapsed) computes idle_points=threshold-usage",
abs(g["idle_points"] - 75.0) < 1e-9, g)
# --- Stage derivation ---
def files(agent=True, research=False, draft=False, review=False):
return {"overview": True, "agent": agent, "research": research, "draft": draft, "review": review}
wt, reason = m.next_work_type_for_item(files(agent=False), {})
check("stage: missing agent.md -> None", wt is None and "commissioning brief" in reason, reason)
wt, reason = m.next_work_type_for_item(files(agent=True), {})
check("stage: agent only -> research", wt == "research", (wt, reason))
wt, reason = m.next_work_type_for_item(files(agent=True, research=True), {})
check("stage: research done -> draft", wt == "draft", (wt, reason))
wt, reason = m.next_work_type_for_item(files(agent=True, research=True, draft=True), {"human_edit_done": False})
check("stage: draft done, not human-edited -> None (waiting on human)",
wt is None and "human" in reason, (wt, reason))
wt, reason = m.next_work_type_for_item(files(agent=True, research=True, draft=True), {"human_edit_done": True})
check("stage: draft done, human-edited -> review", wt == "review", (wt, reason))
wt, reason = m.next_work_type_for_item(files(agent=True, research=True, draft=True, review=True), {"human_edit_done": True})
check("stage: review.md exists -> None (awaiting human revise/approve)", wt is None, (wt, reason))
wt, reason = m.next_work_type_for_item(files(agent=True), {"blocked": "too many failures"})
check("stage: blocked item -> None regardless of files", wt is None and "blocked" in reason, (wt, reason))
wt, reason = m.next_work_type_for_item(files(agent=True), {"approved": True})
check("stage: approved item -> None (terminal)", wt is None and "approved" in reason, (wt, reason))
# --- Prioritisation / cold-start / topic_ideas via build_ready_queue on the fixture repo ---
repo = tmpdir / "repo"
config = json.loads((tmpdir / "idle-draft.config.json").read_text())
state = m.load_state(repo / "idle-draft.state.json", repo)
queue = m.build_ready_queue(repo, config, state, set())
work_types_in_order = [c["work_type"] for c in queue]
check("priority: review-ready item ranks before draft/research candidates",
work_types_in_order[0] == "review", work_types_in_order)
check("priority: full queue is review, draft, research (stage rank descending)",
work_types_in_order == ["review", "draft", "research"], work_types_in_order)
# Cold-start throttle: two dossiers' worth of unreviewed research under a low cap
throttle_repo = tmpdir / "throttle-repo"
(throttle_repo / "ai").mkdir(parents=True, exist_ok=True)
for n, slug in [("01", "a"), ("02", "b"), ("03", "c")]:
base = throttle_repo / "ai" / f"{n}-{slug}"
(base.with_suffix("")).parent.mkdir(exist_ok=True, parents=True)
(throttle_repo / "ai" / f"{n}-{slug}.overview.md").write_text(f"# {slug}\n")
(throttle_repo / "ai" / f"{n}-{slug}.agent.md").write_text("# brief\n")
(throttle_repo / "ai" / "01-a.research.md").write_text("# Research: a\n")
(throttle_repo / "ai" / "02-b.research.md").write_text("# Research: b\n")
throttle_config = dict(config)
throttle_config["max_unreviewed_research_per_dossier"] = 2
throttle_state = m.default_state()
tqueue = m.build_ready_queue(throttle_repo, throttle_config, throttle_state, set())
research_candidates = [c["slug"] for c in tqueue if c["work_type"] == "research"]
check("cold-start throttle: item 03's research is suppressed once 2 unreviewed research files exist",
"03-c" not in research_candidates, research_candidates)
draft_candidates = [c["slug"] for c in tqueue if c["work_type"] == "draft"]
check("cold-start throttle: draft candidates for 01/02 unaffected by the research throttle",
set(draft_candidates) == {"01-a", "02-b"}, draft_candidates)
# topic_ideas: only offered when nothing else is eligible
idle_repo = tmpdir / "idle-repo"
(idle_repo / "ai").mkdir(parents=True, exist_ok=True)
(idle_repo / "ai" / "01-done.overview.md").write_text("# done\n")
(idle_repo / "ai" / "01-done.agent.md").write_text("# brief\n")
idle_state = {"items": {"ai/01-done": {"approved": True}}}
iqueue = m.build_ready_queue(idle_repo, config, idle_state, set())
check("topic_ideas: offered when no other work is eligible",
len(iqueue) == 1 and iqueue[0]["work_type"] == "topic_ideas", iqueue)
(idle_repo / "ai" / "TOPIC-PROPOSALS.md").write_text("\n".join(f"## Idea {i}" for i in range(6)))
capped_config = dict(config)
capped_config["max_open_topic_proposals"] = 6
iqueue2 = m.build_ready_queue(idle_repo, capped_config, idle_state, set())
check("topic_ideas: suppressed once max_open_topic_proposals reached",
len(iqueue2) == 0, iqueue2)
# --- Credential parity ---
ok, why = m._credential_parity_from_values("secret-abc", "secret-abc")
check("credential parity: matching values -> True", ok is True, why)
ok, why = m._credential_parity_from_values("secret-abc", "secret-xyz")
check("credential parity: mismatched values -> False", ok is False, why)
ok, why = m._credential_parity_from_values("", "secret-abc")
check("credential parity: empty profile value -> False", ok is False, why)
cred_repo = tmpdir / "cred"
cred_repo.mkdir(exist_ok=True)
profile_dir = cred_repo / "profile"
profile_dir.mkdir(exist_ok=True)
key_file = cred_repo / "key.txt"
key_file.write_text("shared-secret-value\n")
(profile_dir / "provider.env").write_text(f"ANTHROPIC_BASE_URL=https://example.invalid\nANTHROPIC_API_KEY_FILE={key_file}\n")
resolved = m.resolve_profile_credential_value("minimax", profile_dir)
check("credential parity: resolves ANTHROPIC_API_KEY_FILE from provider.env", resolved == "shared-secret-value", resolved)
ok, why = m.credential_parity("minimax", profile_dir, probe_credential_fn=lambda: "shared-secret-value")
check("credential parity: full check matches via injected probe reader", ok is True, why)
ok, why = m.credential_parity("minimax", profile_dir, probe_credential_fn=lambda: "different-secret")
check("credential parity: full check flags mismatch via injected probe reader", ok is False, why)
# --- provider.env parsing (replicates claude-profile) ---
parsed = m.parse_provider_env(profile_dir / "provider.env")
check("provider.env: base_url parsed", parsed["base_url"] == "https://example.invalid", parsed)
check("provider.env: api_key_file parsed", parsed["api_key_file"] == str(key_file), parsed)
extra_env_dir = cred_repo / "profile-extra"
extra_env_dir.mkdir(exist_ok=True)
(extra_env_dir / "provider.env").write_text("MODEL_ID=claude-haiku-4-5-20251001\nSOME_EXTRA_VAR=hello\n")
parsed2 = m.parse_provider_env(extra_env_dir / "provider.env")
check("provider.env: MODEL_ID recognised", parsed2["model_id"] == "claude-haiku-4-5-20251001", parsed2)
check("provider.env: unrecognised key falls into extra", parsed2["extra"].get("SOME_EXTRA_VAR") == "hello", parsed2)
env = m.build_child_env(profile_dir, base_env={})
check("build_child_env: CLAUDE_CONFIG_DIR set", env["CLAUDE_CONFIG_DIR"] == str(profile_dir), env)
check("build_child_env: ANTHROPIC_BASE_URL exported from provider.env", env.get("ANTHROPIC_BASE_URL") == "https://example.invalid", env)
check("build_child_env: ANTHROPIC_API_KEY read from key file", env.get("ANTHROPIC_API_KEY") == "shared-secret-value", env)
no_provider_env_dir = cred_repo / "profile-plain"
no_provider_env_dir.mkdir(exist_ok=True)
env2 = m.build_child_env(no_provider_env_dir, base_env={})
check("build_child_env: profile without provider.env gets only CLAUDE_CONFIG_DIR",
"ANTHROPIC_BASE_URL" not in env2 and "ANTHROPIC_API_KEY" not in env2 and env2["CLAUDE_CONFIG_DIR"] == str(no_provider_env_dir),
env2)
# --- Citation validation ---
real_path = cred_repo / "exists.txt"
real_path.write_text("x")
good_text = f"# Research: topic\n\nSee {real_path} for detail.\n"
ok, why = m.validate_output("research", good_text)
check("citation validation: accepts research output with an existing cited path", ok is True, why)
bad_text = "# Research: topic\n\nSee /home/nonexistent-user/definitely-not-here.md for detail.\n"
ok, why = m.validate_output("research", bad_text)
check("citation validation: rejects research output citing a dead path", ok is False, why)
ok, why = m.validate_output("research", "")
check("citation validation: rejects empty output", ok is False, why)
ok, why = m.validate_output("research", "no heading here\njust text\n")
check("citation validation: rejects output missing a top-level heading", ok is False, why)
ok, why = m.validate_output("topic_ideas", "## A proposal\n\nBody.\n")
check("citation validation: topic_ideas accepts ## as its top-level heading", ok is True, why)
ok, why = m.validate_output("topic_ideas", "# Wrong heading level\n")
check("citation validation: topic_ideas rejects a single # heading", ok is False, why)
# --- State validation ---
try:
m.validate_state({"items": {}, "bogus": 1}, repo)
check("state validation: unknown top-level key raises", False)
except m.StateValidationError:
check("state validation: unknown top-level key raises", True)
try:
m.validate_state({"items": {"ai/does-not-exist": {}}}, repo)
check("state validation: item not resolving to overview.md raises", False)
except m.StateValidationError:
check("state validation: item not resolving to overview.md raises", True)
try:
m.validate_state({"items": {"ai/01-topic-one": {"attempts": {"bogus_type": 1}}}}, repo)
check("state validation: unknown attempts work_type raises", False)
except m.StateValidationError:
check("state validation: unknown attempts work_type raises", True)
try:
m.validate_state({"items": {"ai/01-topic-one": {"human_edit_done": "yes"}}}, repo)
check("state validation: wrong-typed bool field raises", False)
except m.StateValidationError:
check("state validation: wrong-typed bool field raises", True)
# Valid state should not raise
m.validate_state({"items": {"ai/01-topic-one": {"human_edit_done": True, "attempts": {"draft": 1}}}}, repo)
check("state validation: well-formed state passes", True)
for status, name, detail in results:
print(f"{status}: {name}", detail if detail else "")
n_fail = sum(1 for s, _, _ in results if s == "PY-FAIL")
sys.exit(1 if n_fail else 0)
PYEOF
)
PY_EXIT=$?
echo "$PYOUT"
py_pass=$(echo "$PYOUT" | grep -c '^PY-PASS' || true)
py_fail=$(echo "$PYOUT" | grep -c '^PY-FAIL' || true)
PASS=$((PASS + py_pass))
FAIL=$((FAIL + py_fail))
echo ""
echo "Results: $PASS passed, $FAIL failed"
if [[ $FAIL -gt 0 ]]; then
exit 1
fi
printf "${GREEN}All tests passed.${RESET}\n"