Files
gaokao-volunteer-system/tests/test_cli_doctor_phase3.py

276 lines
10 KiB
Python
Raw Normal View History

feat(cli): add order delegation + doctor self-check (phase 3) docs-only + cli: extend scripts/gaokao-cli with two thin wrappers without changing data/rules, data/majors_catalog, or rules/_truth. - gaokao-cli order <subcommand> delegates argv to data.orders.cli.main with the 'order' prefix stripped, so callers can use the unified command face without changing the existing orders parser. - gaokao-cli doctor <json?> returns a single self-check payload that reuses RuleLoader + MajorsCatalogLoader (no new dependencies, no gaokato/ scaffolding). Truth-root or catalog-root failures degrade gracefully with structured {error, ok=false} sections instead of raising. - tests/test_cli_doctor_phase3.py covers: help text, order delegation to list subcommand, doctor ok-path, doctor failure-path. Also re-aligns docs/ACTIVE_EXECUTION_BOARD_2026-06-17.md (new) + docs/CURRENT_STATE.md (single execution口径) + rules/majors source- of-truth index files so design-phase numbering no longer drifts from execution-phase numbering. Pre-existing 2026-06-16 optimization board and 2026-06-17 phase2 plan are demoted to historical snapshots. P0_P1_P2 remediation plan §4 historical cards get a red warning banner so they are not re-read as current pending. Verification: - focused: 4 passed - dev-verify: 770 passed, 1 failed (alipay_sim E2E failure pre-existed this commit; caused by an uncommitted upstream edit in admin/routes/web_public.py that rewires payment_return -> /portal/{token}/payment-success while the E2E still asserts /portal/{token}/status; out of scope for this commit and intentionally not touched.)
2026-06-17 18:05:16 +08:00
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import yaml # type: ignore[import-untyped]
feat(cli): add order delegation + doctor self-check (phase 3) docs-only + cli: extend scripts/gaokao-cli with two thin wrappers without changing data/rules, data/majors_catalog, or rules/_truth. - gaokao-cli order <subcommand> delegates argv to data.orders.cli.main with the 'order' prefix stripped, so callers can use the unified command face without changing the existing orders parser. - gaokao-cli doctor <json?> returns a single self-check payload that reuses RuleLoader + MajorsCatalogLoader (no new dependencies, no gaokato/ scaffolding). Truth-root or catalog-root failures degrade gracefully with structured {error, ok=false} sections instead of raising. - tests/test_cli_doctor_phase3.py covers: help text, order delegation to list subcommand, doctor ok-path, doctor failure-path. Also re-aligns docs/ACTIVE_EXECUTION_BOARD_2026-06-17.md (new) + docs/CURRENT_STATE.md (single execution口径) + rules/majors source- of-truth index files so design-phase numbering no longer drifts from execution-phase numbering. Pre-existing 2026-06-16 optimization board and 2026-06-17 phase2 plan are demoted to historical snapshots. P0_P1_P2 remediation plan §4 historical cards get a red warning banner so they are not re-read as current pending. Verification: - focused: 4 passed - dev-verify: 770 passed, 1 failed (alipay_sim E2E failure pre-existed this commit; caused by an uncommitted upstream edit in admin/routes/web_public.py that rewires payment_return -> /portal/{token}/payment-success while the E2E still asserts /portal/{token}/status; out of scope for this commit and intentionally not touched.)
2026-06-17 18:05:16 +08:00
PROJECT_ROOT = Path(__file__).resolve().parents[1]
CLI = PROJECT_ROOT / "scripts" / "gaokao-cli"
def _run_cli(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(CLI), *args],
check=False,
capture_output=True,
text=True,
cwd=PROJECT_ROOT,
)
def test_cli_share_delegates_list_subcommand() -> None:
# The shortlink CLI exposes a `list` subcommand. Asking for its --help
# proves the `gaokao-cli share ...` argv successfully reaches the
# shortlink parser instead of being swallowed by the top-level parser.
result = _run_cli("share", "list", "--help")
assert result.returncode == 0, result.stderr
# The shortlink list subcommand exposes --report / --owner.
assert "--report" in result.stdout
assert "--owner" in result.stdout
def test_cli_payment_doctor_rejects_unexpected_args() -> None:
# `gaokao-cli payment doctor` is a single-shot diagnostic with no
# flags; the compat shim should surface unexpected tokens instead
# of silently dropping them on the floor.
result = _run_cli("payment", "doctor", "--bogus")
assert result.returncode != 0
combined = (result.stdout or "") + (result.stderr or "")
assert "does not accept" in combined or "unrecognized" in combined.lower()
def test_cli_help_lists_unified_command_surface() -> None:
# P2-1 contract: 顶层 help 必须显式暴露统一 CLI 命令面,即使这些
# 命令最终仍由预委派逻辑转发给旧 parser / shell。
feat(cli): add order delegation + doctor self-check (phase 3) docs-only + cli: extend scripts/gaokao-cli with two thin wrappers without changing data/rules, data/majors_catalog, or rules/_truth. - gaokao-cli order <subcommand> delegates argv to data.orders.cli.main with the 'order' prefix stripped, so callers can use the unified command face without changing the existing orders parser. - gaokao-cli doctor <json?> returns a single self-check payload that reuses RuleLoader + MajorsCatalogLoader (no new dependencies, no gaokato/ scaffolding). Truth-root or catalog-root failures degrade gracefully with structured {error, ok=false} sections instead of raising. - tests/test_cli_doctor_phase3.py covers: help text, order delegation to list subcommand, doctor ok-path, doctor failure-path. Also re-aligns docs/ACTIVE_EXECUTION_BOARD_2026-06-17.md (new) + docs/CURRENT_STATE.md (single execution口径) + rules/majors source- of-truth index files so design-phase numbering no longer drifts from execution-phase numbering. Pre-existing 2026-06-16 optimization board and 2026-06-17 phase2 plan are demoted to historical snapshots. P0_P1_P2 remediation plan §4 historical cards get a red warning banner so they are not re-read as current pending. Verification: - focused: 4 passed - dev-verify: 770 passed, 1 failed (alipay_sim E2E failure pre-existed this commit; caused by an uncommitted upstream edit in admin/routes/web_public.py that rewires payment_return -> /portal/{token}/payment-success while the E2E still asserts /portal/{token}/status; out of scope for this commit and intentionally not touched.)
2026-06-17 18:05:16 +08:00
result = _run_cli("--help")
assert result.returncode == 0
assert "order" in result.stdout
assert "share" in result.stdout
assert "payment" in result.stdout
assert "channel" in result.stdout
assert "delivery" in result.stdout
assert "retention" in result.stdout
assert "backup" in result.stdout
feat(cli): add order delegation + doctor self-check (phase 3) docs-only + cli: extend scripts/gaokao-cli with two thin wrappers without changing data/rules, data/majors_catalog, or rules/_truth. - gaokao-cli order <subcommand> delegates argv to data.orders.cli.main with the 'order' prefix stripped, so callers can use the unified command face without changing the existing orders parser. - gaokao-cli doctor <json?> returns a single self-check payload that reuses RuleLoader + MajorsCatalogLoader (no new dependencies, no gaokato/ scaffolding). Truth-root or catalog-root failures degrade gracefully with structured {error, ok=false} sections instead of raising. - tests/test_cli_doctor_phase3.py covers: help text, order delegation to list subcommand, doctor ok-path, doctor failure-path. Also re-aligns docs/ACTIVE_EXECUTION_BOARD_2026-06-17.md (new) + docs/CURRENT_STATE.md (single execution口径) + rules/majors source- of-truth index files so design-phase numbering no longer drifts from execution-phase numbering. Pre-existing 2026-06-16 optimization board and 2026-06-17 phase2 plan are demoted to historical snapshots. P0_P1_P2 remediation plan §4 historical cards get a red warning banner so they are not re-read as current pending. Verification: - focused: 4 passed - dev-verify: 770 passed, 1 failed (alipay_sim E2E failure pre-existed this commit; caused by an uncommitted upstream edit in admin/routes/web_public.py that rewires payment_return -> /portal/{token}/payment-success while the E2E still asserts /portal/{token}/status; out of scope for this commit and intentionally not touched.)
2026-06-17 18:05:16 +08:00
assert "doctor" in result.stdout
share_help = _run_cli("share", "--help")
assert share_help.returncode == 0, share_help.stderr
# Reaches the shortlink parser (which exposes create/list/resolve/...)
assert "create" in share_help.stdout
payment_help = _run_cli("payment", "doctor")
# mock provider is fine: should exit 0; unsupported would exit 3.
assert payment_help.returncode in (0, 2)
feat(cli): add order delegation + doctor self-check (phase 3) docs-only + cli: extend scripts/gaokao-cli with two thin wrappers without changing data/rules, data/majors_catalog, or rules/_truth. - gaokao-cli order <subcommand> delegates argv to data.orders.cli.main with the 'order' prefix stripped, so callers can use the unified command face without changing the existing orders parser. - gaokao-cli doctor <json?> returns a single self-check payload that reuses RuleLoader + MajorsCatalogLoader (no new dependencies, no gaokato/ scaffolding). Truth-root or catalog-root failures degrade gracefully with structured {error, ok=false} sections instead of raising. - tests/test_cli_doctor_phase3.py covers: help text, order delegation to list subcommand, doctor ok-path, doctor failure-path. Also re-aligns docs/ACTIVE_EXECUTION_BOARD_2026-06-17.md (new) + docs/CURRENT_STATE.md (single execution口径) + rules/majors source- of-truth index files so design-phase numbering no longer drifts from execution-phase numbering. Pre-existing 2026-06-16 optimization board and 2026-06-17 phase2 plan are demoted to historical snapshots. P0_P1_P2 remediation plan §4 historical cards get a red warning banner so they are not re-read as current pending. Verification: - focused: 4 passed - dev-verify: 770 passed, 1 failed (alipay_sim E2E failure pre-existed this commit; caused by an uncommitted upstream edit in admin/routes/web_public.py that rewires payment_return -> /portal/{token}/payment-success while the E2E still asserts /portal/{token}/status; out of scope for this commit and intentionally not touched.)
2026-06-17 18:05:16 +08:00
def test_cli_order_delegates_list_subcommand() -> None:
# The orders CLI exposes a `list` subcommand. Asking for its --help
# proves the `gaokao-cli order ...` argv successfully reaches the
# orders parser instead of being swallowed by the top-level parser.
result = _run_cli("order", "list", "--help")
assert result.returncode == 0, result.stderr
# The orders parser's list subcommand should expose --status/--source/--limit;
# presence proves the delegation wired up correctly.
assert "--status" in result.stdout
assert "--limit" in result.stdout
def test_cli_doctor_returns_ok_json(tmp_path: Path) -> None:
result = _run_cli("doctor", "--json")
assert result.returncode == 0
payload = json.loads(result.stdout)
assert payload["ok"] is True
assert payload["rules"]["province_count"] >= 27
assert payload["rules"]["national_rule_count"] >= 0
assert payload["rules"]["stale_rule_count"] == 0
assert payload["rules_verify"]["ok"] is True
feat(cli): add order delegation + doctor self-check (phase 3) docs-only + cli: extend scripts/gaokao-cli with two thin wrappers without changing data/rules, data/majors_catalog, or rules/_truth. - gaokao-cli order <subcommand> delegates argv to data.orders.cli.main with the 'order' prefix stripped, so callers can use the unified command face without changing the existing orders parser. - gaokao-cli doctor <json?> returns a single self-check payload that reuses RuleLoader + MajorsCatalogLoader (no new dependencies, no gaokato/ scaffolding). Truth-root or catalog-root failures degrade gracefully with structured {error, ok=false} sections instead of raising. - tests/test_cli_doctor_phase3.py covers: help text, order delegation to list subcommand, doctor ok-path, doctor failure-path. Also re-aligns docs/ACTIVE_EXECUTION_BOARD_2026-06-17.md (new) + docs/CURRENT_STATE.md (single execution口径) + rules/majors source- of-truth index files so design-phase numbering no longer drifts from execution-phase numbering. Pre-existing 2026-06-16 optimization board and 2026-06-17 phase2 plan are demoted to historical snapshots. P0_P1_P2 remediation plan §4 historical cards get a red warning banner so they are not re-read as current pending. Verification: - focused: 4 passed - dev-verify: 770 passed, 1 failed (alipay_sim E2E failure pre-existed this commit; caused by an uncommitted upstream edit in admin/routes/web_public.py that rewires payment_return -> /portal/{token}/payment-success while the E2E still asserts /portal/{token}/status; out of scope for this commit and intentionally not touched.)
2026-06-17 18:05:16 +08:00
assert payload["majors"]["ok"] is True
assert payload["majors_verify"]["ok"] is True
def test_cli_doctor_fails_on_empty_truth_root(tmp_path: Path) -> None:
empty_truth = tmp_path / "truth"
empty_truth.mkdir()
empty_catalog = tmp_path / "catalog"
empty_catalog.mkdir()
(empty_catalog / "national").mkdir()
(empty_catalog / "schools").mkdir()
(empty_catalog / "national" / "2024.json").write_text(
json.dumps({"year": 2024, "majors": []}),
encoding="utf-8",
)
(empty_catalog / "national" / "latest.json").write_text(
json.dumps({"year": 2024, "majors": []}),
encoding="utf-8",
)
result = _run_cli(
"doctor",
"--truth-root",
str(empty_truth),
"--catalog-root",
str(empty_catalog),
"--json",
)
assert result.returncode == 1
payload = json.loads(result.stdout)
assert payload["ok"] is False
assert payload["rules"].get("province_count", 0) == 0
# When the truth_root is incomplete the loader raises before any
# province_count key is populated, so a structured error key replaces
# the missing province_count.
assert "error" in payload["rules"] or payload["rules"].get("province_count", 0) == 0
2026-06-17 22:18:57 +08:00
def test_cli_doctor_fails_when_rules_are_stale(tmp_path: Path) -> None:
truth_root = tmp_path / "truth"
province_dir = truth_root / "province"
province_dir.mkdir(parents=True, exist_ok=True)
(truth_root / "national.yaml").write_text(
yaml.safe_dump(
{
"scope": "national",
"year": 2026,
"version": "2026.1",
"last_verified_at": "2026-01-01",
"rules": {
"parallel_volunteer_principle": {
"title": "平行志愿原则",
"severity": "info",
"value": {"rule": "分数优先、遵循志愿、一次投档"},
"source_evidence_id": "national-2026-parallel-rule",
"effective_date": "2026-01-01",
"last_verified_at": "2026-01-01",
"status": "active",
}
},
},
allow_unicode=True,
sort_keys=False,
),
encoding="utf-8",
)
(province_dir / "hunan.yaml").write_text(
yaml.safe_dump(
{
"scope": "province",
"province": "湖南",
"year": 2026,
"version": "2026.1",
"last_verified_at": "2026-06-17",
"status": "active",
"rules": {
"max_volunteers": {
"title": "本科批志愿上限",
"severity": "fatal",
"value": {"max_volunteers": 45},
"source_evidence_id": "hunan-2026-max-volunteers",
"effective_date": "2026-01-01",
"last_verified_at": "2026-06-17",
"status": "active",
}
},
},
allow_unicode=True,
sort_keys=False,
),
encoding="utf-8",
)
evidence_root = truth_root.parent / "_evidence" / "hunan"
evidence_root.mkdir(parents=True, exist_ok=True)
(evidence_root / "hunan-2026-max-volunteers.md").write_text(
"> 湖南本科批院校专业组最多 45 个志愿。\n",
encoding="utf-8",
)
catalog_root = tmp_path / "catalog"
(catalog_root / "national").mkdir(parents=True, exist_ok=True)
(catalog_root / "schools").mkdir(parents=True, exist_ok=True)
(catalog_root / "changes").mkdir(parents=True, exist_ok=True)
(catalog_root / "changes" / "2024-2026.md").write_text(
"# changes\n\n- sample\n",
encoding="utf-8",
)
national_catalog = {
"year": 2024,
"version": "2024.1",
"coverage_mode": "mvp_subset",
"source": "教育部普通高等学校本科专业目录(人工摘录校对)",
"source_url": "https://www.moe.gov.cn/",
"last_verified_at": "2026-06-17",
"majors": [],
}
for name in ("2024.json", "latest.json"):
(catalog_root / "national" / name).write_text(
json.dumps(national_catalog, ensure_ascii=False),
encoding="utf-8",
)
result = _run_cli(
"doctor",
"--truth-root",
str(truth_root),
"--catalog-root",
str(catalog_root),
"--json",
)
assert result.returncode == 1
payload = json.loads(result.stdout)
assert payload["ok"] is False
assert payload["rules"]["stale_rule_count"] == 1
assert payload["rules_verify"]["ok"] is False
assert "NATIONAL.parallel_volunteer_principle" in payload["rules_verify"]["stale_rule_ids"]
2026-06-17 22:18:57 +08:00
def test_cli_channel_check_delegates_to_monitor() -> None:
# `gaokao-cli channel --help` should reach the channel_sync monitor
# parser, which exposes {check, manual-template}.
result = _run_cli("channel", "--help")
assert result.returncode == 0, result.stderr
assert "check" in result.stdout
assert "manual-template" in result.stdout
def test_cli_delivery_dispatch_help_reaches_parser() -> None:
result = _run_cli("delivery", "dispatch", "--help")
assert result.returncode == 0, result.stderr
assert "--channel" in result.stdout
assert "--limit" in result.stdout
def test_cli_delivery_unknown_subcommand_errors_cleanly() -> None:
result = _run_cli("delivery", "unknown")
assert result.returncode != 0
# The dispatch shim surfaces the expected subcommand set, not a raw
# argparse traceback, so callers can rely on the message.
combined = (result.stdout or "") + (result.stderr or "")
assert "dispatch" in combined and "watchdog" in combined
def test_cli_retention_help_reaches_parser() -> None:
result = _run_cli("retention", "--help")
# The retention parser exits 2 on --help with no subcommand; we
# only assert that we reached the retention script (not Python
# traceback and not a top-level argparse error).
combined = (result.stdout or "") + (result.stderr or "")
assert "Traceback" not in combined
assert result.returncode != 0 or "retention" in combined.lower()
def test_cli_backup_snapshot_help_reaches_shell() -> None:
result = _run_cli("backup", "snapshot", "--help")
# The shell script may exit 0 or non-zero if no --help is wired;
# we only assert the routing reached bash (no Python traceback).
combined = (result.stdout or "") + (result.stderr or "")
assert "Traceback" not in combined
def test_cli_backup_unknown_subcommand_errors_cleanly() -> None:
result = _run_cli("backup", "unknown")
assert result.returncode != 0
combined = (result.stdout or "") + (result.stderr or "")
assert "snapshot" in combined and "verify" in combined