From 97c2a1690daceb600e378553d82dc0dae7836a8e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:26:55 +0100 Subject: [PATCH] fix(dashboard-check): fail when a dashboard or field is missing; read .adoc The STATE-vs-dashboard gate could not fail on main, for two reasons: - .github/workflows/dashboard-check.yml passed `toolchain: v1` to dtolnay/rust-toolchain, which is not a toolchain name, so the job died in setup before the checker ran (red on main b1fc4ed). - The checker read TOPOLOGY.md / READINESS.md. After the md-to-adoc migration (#102) both are .adoc, so it compared nothing and printed OK. Now an absent surface, or a field that cannot be parsed on either side, is a failure rather than a silent skip, and OK lists every value it compared. The checker reads .adoc with a .md fallback, and descriptiles/STATE.a2ml with a 6a2/ fallback. The TOPOLOGY grade is read from the OVERALL line only. The change surfaced one real gap: #102 dropped TOPOLOGY's 'Last updated' line. It is restored with its original date, 2026-07-02. Tests: 8 -> 19. Ten mutants (each a skip reintroduced, or a lookup disabled) are all killed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VxcAoyMQe7CjQwCKL18Mm4 --- .github/workflows/dashboard-check.yml | 2 +- TOPOLOGY.adoc | 1 + crates/dashboard-check/src/main.rs | 513 ++++++++++++++++++++------ 3 files changed, 400 insertions(+), 116 deletions(-) diff --git a/.github/workflows/dashboard-check.yml b/.github/workflows/dashboard-check.yml index 0a337ee..ad2219e 100644 --- a/.github/workflows/dashboard-check.yml +++ b/.github/workflows/dashboard-check.yml @@ -29,7 +29,7 @@ jobs: - uses: dtolnay/rust-toolchain@v1 with: - toolchain: v1 + toolchain: stable - name: Reconcile dashboards against STATE.a2ml run: cargo run -q -p dashboard-check -- --check . diff --git a/TOPOLOGY.adoc b/TOPOLOGY.adoc index 44317e6..7324c95 100644 --- a/TOPOLOGY.adoc +++ b/TOPOLOGY.adoc @@ -1,4 +1,5 @@ == JanusKey — Project Topology +// Last updated: 2026-07-02 (completion dashboard reconciled to STATE.a2ml / READINESS; date carried over from TOPOLOGY.md, dropped by the md-to-adoc migration #102) === System Architecture diff --git a/crates/dashboard-check/src/main.rs b/crates/dashboard-check/src/main.rs index 56b0f7f..4bc9d2c 100644 --- a/crates/dashboard-check/src/main.rs +++ b/crates/dashboard-check/src/main.rs @@ -5,18 +5,23 @@ // machine-readable STATE.a2ml (the declared source of truth). // // Motivation: the estate's #1 recurring defect is "dashboards that lie" — a -// human-facing surface (TOPOLOGY.md completion bar, README badge, READINESS +// human-facing surface (TOPOLOGY completion bar, README badge, READINESS // grade) that claims more than STATE.a2ml records. This tool reads STATE and // asserts the dashboards agree; run in CI it makes divergence a build failure, // so the lie cannot be committed silently. // +// A check that compares nothing is itself a lying dashboard: every surface and +// every field this tool reconciles must be present and parseable, otherwise +// the run fails. (Before this rule, renaming TOPOLOGY.md to TOPOLOGY.adoc made +// the tool print "OK" while comparing nothing.) +// // `.a2ml` is TOML (the estate parses it with a TOML parser elsewhere), so we // parse it with the `toml` crate rather than a bespoke reader. // // Usage: // dashboard-check [--check] [REPO_ROOT] -// Exits 0 if the dashboards match STATE (or the surfaces are absent), non-zero -// with a diff report otherwise. `--check` is the default and only mode today. +// Exits 0 only if every reconciled field was found and agrees with STATE; +// non-zero with a report otherwise. `--check` is the default and only mode. use std::path::{Path, PathBuf}; use std::process::ExitCode; @@ -54,10 +59,13 @@ fn extract_state(toml_src: &str) -> Result { .and_then(|v| v.as_str().map(str::to_string)) .or_else(|| get("crg-compliance", "tier").and_then(|v| v.as_str().map(str::to_string))); - let last_updated = get("metadata", "last-updated") - .and_then(|v| v.as_str().map(str::to_string)); + let last_updated = get("metadata", "last-updated").and_then(|v| v.as_str().map(str::to_string)); - Ok(StateFacts { completion, grade, last_updated }) + Ok(StateFacts { + completion, + grade, + last_updated, + }) } /// Coerce a TOML value (string `"60"` or integer `60`) into a percentage. @@ -69,15 +77,51 @@ fn value_to_u32(v: &toml::Value) -> Option { } } -/// The `OVERALL: ... ~60%` figure inside the TOPOLOGY completion dashboard. -/// Scans every line containing "OVERALL" and returns the first that carries a -/// `%` figure — so prose that merely mentions "OVERALL" (e.g. the -/// source-of-truth note) does not shadow the real dashboard line. -fn extract_overall_pct(topology: &str) -> Option { +/// A dashboard file as read from disk: the name it was found under (so the +/// report says `TOPOLOGY.adoc`, not a guess) and its text. +struct Surface { + name: String, + text: String, +} + +/// Read `.adoc`, falling back to `.md`. `None` if neither exists. +fn read_surface(root: &Path, stem: &str) -> Option { + ["adoc", "md"].iter().find_map(|ext| { + let name = format!("{stem}.{ext}"); + read_opt(&root.join(&name)).map(|text| Surface { name, text }) + }) +} + +/// Locate STATE.a2ml: the canonical `descriptiles/` directory first, then the +/// retired `6a2/` name that this repo still uses. +fn state_path(root: &Path) -> PathBuf { + let canonical = root.join(".machine_readable/descriptiles/STATE.a2ml"); + if canonical.exists() { + canonical + } else { + root.join(".machine_readable/6a2/STATE.a2ml") + } +} + +/// The TOPOLOGY completion-dashboard line: the first line containing +/// "OVERALL" that also carries a `%` figure, so prose that merely +/// mentions "OVERALL" (e.g. the source-of-truth note) does not shadow it. +fn extract_overall_line(topology: &str) -> Option<&str> { topology .lines() - .filter(|l| l.contains("OVERALL")) - .find_map(first_percent) + .find(|l| l.contains("OVERALL") && first_percent(l).is_some()) +} + +/// The `OVERALL: ... ~60%` figure inside the TOPOLOGY completion dashboard. +fn extract_overall_pct(topology: &str) -> Option { + extract_overall_line(topology).and_then(first_percent) +} + +/// The grade printed on the OVERALL line (`... ~60% Grade D — Alpha`). +/// Read from that line only, so a "Grade" mentioned in prose elsewhere in +/// the file cannot stand in for the dashboard's own grade. +fn extract_overall_grade(topology: &str) -> Option { + extract_overall_line(topology).and_then(|l| extract_grade_after_token(l, "Grade ")) } /// First `%` occurrence in a string, as an integer. @@ -100,13 +144,15 @@ fn first_percent(s: &str) -> Option { None } -/// The grade letter after a `Grade ` token (e.g. "Grade D — Alpha"), used for -/// the TOPOLOGY dashboard line. +/// The grade letter after a `Grade ` token (e.g. "Grade D — Alpha"). fn extract_grade_after_token(text: &str, token: &str) -> Option { for line in text.lines() { if let Some(idx) = line.find(token) { let rest = line[idx + token.len()..].trim_start(); - let g: String = rest.chars().take_while(|c| c.is_ascii_alphabetic()).collect(); + let g: String = rest + .chars() + .take_while(|c| c.is_ascii_alphabetic()) + .collect(); if !g.is_empty() { return Some(g); } @@ -115,83 +161,151 @@ fn extract_grade_after_token(text: &str, token: &str) -> Option { None } -/// READINESS grade: `**Current Grade:** D` or `## CRG Grade: D (...)`. +/// READINESS grade: AsciiDoc `*Current Grade:* D`, Markdown +/// `**Current Grade:** D`, or a `CRG Grade: D (...)` heading. fn extract_grade_readiness(readiness: &str) -> Option { - extract_grade_after_token(readiness, "Current Grade:** ") + extract_grade_after_token(readiness, "Current Grade:* ") + .or_else(|| extract_grade_after_token(readiness, "Current Grade:** ")) .or_else(|| extract_grade_after_token(readiness, "CRG Grade: ")) } -/// The `` date from TOPOLOGY. +/// The `Last updated: YYYY-MM-DD` date from TOPOLOGY, written either as an +/// AsciiDoc `// Last updated: …` comment or a Markdown `` comment. +/// Lines that mention "Last updated" without a date (instructions) are skipped. fn extract_last_updated(topology: &str) -> Option { - let line = topology.lines().find(|l| l.contains("Last updated:"))?; - let idx = line.find("Last updated:")? + "Last updated:".len(); - let rest = line[idx..].trim_start(); - let date: String = rest - .chars() - .take_while(|c| c.is_ascii_digit() || *c == '-') - .collect(); - if date.len() >= 8 { Some(date) } else { None } + topology.lines().find_map(|line| { + let idx = line.find("Last updated:")? + "Last updated:".len(); + let date: String = line[idx..] + .trim_start() + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '-') + .collect(); + (date.len() >= 8).then_some(date) + }) +} + +/// Result of a reconciliation: what was compared (for an auditable "OK") +/// and what was wrong. +#[derive(Debug, Default)] +struct Report { + observed: Vec, + problems: Vec, } -/// Compare STATE against the dashboards; return a list of human-readable -/// mismatch messages (empty = all good). Pure so it is unit-testable. +/// Compare STATE against the dashboards. Every field must be present on both +/// sides; a missing surface or an unparseable field is a problem, never a +/// skip. Pure so it is unit-testable. fn reconcile( state: &StateFacts, - topology: Option<&str>, - readiness: Option<&str>, -) -> Vec { - let mut problems = Vec::new(); - - if let (Some(pct), Some(topo)) = (state.completion, topology) { - match extract_overall_pct(topo) { - Some(dpct) if dpct != pct => problems.push(format!( - "completion mismatch: STATE says {pct}% but TOPOLOGY.md OVERALL says {dpct}%" - )), - None => problems.push( - "TOPOLOGY.md has no parseable 'OVERALL: …%' line to check against STATE".into(), - ), - _ => {} - } + topology: Option<&Surface>, + readiness: Option<&Surface>, +) -> Report { + let mut r = Report::default(); + + let Some(topo) = topology else { + r.problems + .push("no TOPOLOGY.adoc or TOPOLOGY.md to reconcile against STATE".into()); + return finish_without_topology(state, readiness, r); + }; + + match (state.completion, extract_overall_pct(&topo.text)) { + (None, _) => r + .problems + .push("STATE.a2ml has no completion-percentage".into()), + (_, None) => r.problems.push(format!( + "{} has no parseable 'OVERALL: …%' line to check against STATE", + topo.name + )), + (Some(pct), Some(dpct)) if dpct != pct => r.problems.push(format!( + "completion mismatch: STATE says {pct}% but {} OVERALL says {dpct}%", + topo.name + )), + (Some(pct), Some(_)) => r + .observed + .push(format!("completion {pct}% ({})", topo.name)), } - if let Some(grade) = &state.grade { - if let Some(topo) = topology { - if let Some(g) = extract_grade_after_token(topo, "Grade ") { - if &g != grade { - problems.push(format!( - "grade mismatch: STATE says {grade} but TOPOLOGY.md says Grade {g}" - )); - } - } - } - if let Some(read) = readiness { - match extract_grade_readiness(read) { - Some(g) if &g != grade => problems.push(format!( - "grade mismatch: STATE says {grade} but READINESS.md says Grade {g}" - )), - _ => {} - } - } + check_grade( + state, + &topo.name, + extract_overall_grade(&topo.text), + "OVERALL line", + &mut r, + ); + + match (&state.last_updated, extract_last_updated(&topo.text)) { + (None, _) => r + .problems + .push("STATE.a2ml has no [metadata].last-updated".into()), + (_, None) => r.problems.push(format!( + "{} has no 'Last updated: YYYY-MM-DD' line, so its staleness cannot be checked", + topo.name + )), + // Lexicographic compare works for ISO YYYY-MM-DD dates. + (Some(su), Some(du)) if du.as_str() < su.as_str() => r.problems.push(format!( + "staleness: {} 'Last updated: {du}' predates STATE last-updated {su}", + topo.name + )), + (Some(su), Some(du)) => r + .observed + .push(format!("{} last updated {du} >= STATE {su}", topo.name)), } - if let (Some(su), Some(topo)) = (&state.last_updated, topology) { - if let Some(du) = extract_last_updated(topo) { - // Lexicographic compare works for ISO YYYY-MM-DD dates. - if du.as_str() < su.as_str() { - problems.push(format!( - "staleness: TOPOLOGY.md 'Last updated: {du}' predates STATE last-updated {su}" - )); - } - } + finish_without_topology(state, readiness, r) +} + +/// The READINESS half of [`reconcile`], shared by the path where TOPOLOGY is +/// missing so that both dashboards are always reported on. +fn finish_without_topology( + state: &StateFacts, + readiness: Option<&Surface>, + mut r: Report, +) -> Report { + match readiness { + None => r + .problems + .push("no READINESS.adoc or READINESS.md to reconcile against STATE".into()), + Some(read) => check_grade( + state, + &read.name, + extract_grade_readiness(&read.text), + "Current Grade", + &mut r, + ), } + r +} - problems +/// Compare one dashboard's grade with STATE's, recording a problem when +/// either side is missing or they differ. +fn check_grade( + state: &StateFacts, + surface: &str, + found: Option, + field: &str, + r: &mut Report, +) { + match (&state.grade, found) { + (None, _) => r + .problems + .push("STATE.a2ml has no crg-grade / crg-compliance tier".into()), + (_, None) => r + .problems + .push(format!("{surface} has no parseable grade on its {field}")), + (Some(grade), Some(g)) if &g != grade => r.problems.push(format!( + "grade mismatch: STATE says {grade} but {surface} says Grade {g}" + )), + (Some(grade), Some(_)) => r.observed.push(format!("grade {grade} ({surface})")), + } } +/// Read a file to a string, `None` if it is absent or unreadable. fn read_opt(path: &Path) -> Option { std::fs::read_to_string(path).ok() } +/// Entry point: reconcile the dashboards under REPO_ROOT (default `.`) and +/// exit 0 on agreement, 1 on divergence, 2 if STATE cannot be read or parsed. fn main() -> ExitCode { // Skip the binary name; ignore the `--check` flag (default mode). let mut root = PathBuf::from("."); @@ -202,7 +316,7 @@ fn main() -> ExitCode { root = PathBuf::from(arg); } - let state_path = root.join(".machine_readable/6a2/STATE.a2ml"); + let state_path = state_path(&root); let state_src = match read_opt(&state_path) { Some(s) => s, None => { @@ -219,20 +333,27 @@ fn main() -> ExitCode { } }; - let topology = read_opt(&root.join("TOPOLOGY.md")); - let readiness = read_opt(&root.join("READINESS.md")); + let topology = read_surface(&root, "TOPOLOGY"); + let readiness = read_surface(&root, "READINESS"); - let problems = reconcile(&state, topology.as_deref(), readiness.as_deref()); + let report = reconcile(&state, topology.as_ref(), readiness.as_ref()); - if problems.is_empty() { + if report.problems.is_empty() { println!( - "dashboard-check: OK — dashboards agree with STATE.a2ml (completion={:?}, grade={:?})", - state.completion, state.grade + "dashboard-check: OK — dashboards agree with {}:", + state_path.display() ); + for o in &report.observed { + println!(" ✓ {o}"); + } ExitCode::SUCCESS } else { - eprintln!("dashboard-check: {} divergence(s) from STATE.a2ml (the source of truth):", problems.len()); - for p in &problems { + eprintln!( + "dashboard-check: {} divergence(s) from {} (the source of truth):", + report.problems.len(), + state_path.display() + ); + for p in &report.problems { eprintln!(" ✗ {p}"); } eprintln!("Fix the dashboard to match STATE, or update STATE if it is stale."); @@ -256,20 +377,44 @@ completion-percentage = "60" const STATE_50_BARE: &str = r#" [metadata] last-updated = "2026-06-12" +crg-grade = "D" [project-context] completion-percentage = 50 "#; - const TOPOLOGY_60_D: &str = "\ - -OVERALL: ██████░░░░ ~60% Grade D — Alpha, Unstable + // Shapes copied from the real januskey TOPOLOGY.adoc / READINESS.adoc. + const TOPOLOGY_ADOC: &str = "\ +// Last updated: 2026-07-02 (completion dashboard reconciled to STATE.a2ml) +CRG grade *D*) and `+READINESS.md+` (Grade *D — Alpha, Unstable*). This +if the `+OVERALL+` percentage or grade here drifts from STATE.a2ml. + chaos, compatibility (Grade D) +OVERALL: ██████░░░░ ~60% Grade D — Alpha, Unstable (not v1.0) +. *Date*: Update the `+Last updated+` comment at the top of this file "; - const READINESS_D: &str = "\ -**Current Grade:** D -## CRG Grade: D (Alpha — Unstable) + const READINESS_ADOC: &str = "\ +*Current Grade:* D + +=== CRG Grade: D (Alpha — Unstable) "; + /// Build a [`Surface`] for tests. + fn surf(name: &str, text: &str) -> Surface { + Surface { + name: name.into(), + text: text.into(), + } + } + + /// Reconcile the real-shaped fixtures, with one of them optionally + /// replaced, and return the problem list. + fn problems_with(state: &str, topo: Option<&str>, read: Option<&str>) -> Vec { + let s = extract_state(state).unwrap(); + let t = topo.map(|t| surf("TOPOLOGY.adoc", t)); + let r = read.map(|r| surf("READINESS.adoc", r)); + reconcile(&s, t.as_ref(), r.as_ref()).problems + } + #[test] fn parses_quoted_state_fields() { let s = extract_state(STATE_60_D).unwrap(); @@ -285,58 +430,196 @@ OVERALL: ██████░░░░ ~60% Grade } #[test] - fn extracts_dashboard_signals() { - assert_eq!(extract_overall_pct(TOPOLOGY_60_D), Some(60)); - assert_eq!(extract_grade_after_token(TOPOLOGY_60_D, "Grade ").as_deref(), Some("D")); - assert_eq!(extract_grade_readiness(READINESS_D).as_deref(), Some("D")); - assert_eq!(extract_last_updated(TOPOLOGY_60_D).as_deref(), Some("2026-07-02")); + fn extracts_signals_from_real_adoc_shapes() { + assert_eq!(extract_overall_pct(TOPOLOGY_ADOC), Some(60)); + assert_eq!(extract_overall_grade(TOPOLOGY_ADOC).as_deref(), Some("D")); + assert_eq!( + extract_grade_readiness(READINESS_ADOC).as_deref(), + Some("D") + ); + assert_eq!( + extract_last_updated(TOPOLOGY_ADOC).as_deref(), + Some("2026-07-02") + ); } #[test] - fn passes_when_aligned() { + fn reads_adoc_current_grade_without_a_crg_heading() { + assert_eq!( + extract_grade_readiness("*Current Grade:* C\n").as_deref(), + Some("C") + ); + } + + #[test] + fn read_surface_finds_adoc_then_md() { + let dir = std::env::temp_dir().join(format!("dashboard-check-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("TOPOLOGY.adoc"), "adoc").unwrap(); + std::fs::write(dir.join("READINESS.md"), "md").unwrap(); + let t = read_surface(&dir, "TOPOLOGY").expect("TOPOLOGY.adoc must be found"); + let r = read_surface(&dir, "READINESS").expect("READINESS.md fallback must be found"); + assert_eq!( + (t.name.as_str(), t.text.as_str()), + ("TOPOLOGY.adoc", "adoc") + ); + assert_eq!((r.name.as_str(), r.text.as_str()), ("READINESS.md", "md")); + assert!(read_surface(&dir, "ABSENT").is_none()); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn state_path_prefers_descriptiles_over_6a2() { + let dir = + std::env::temp_dir().join(format!("dashboard-check-state-{}", std::process::id())); + let legacy = dir.join(".machine_readable/6a2"); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("STATE.a2ml"), "").unwrap(); + assert!(state_path(&dir).ends_with("6a2/STATE.a2ml")); + let canonical = dir.join(".machine_readable/descriptiles"); + std::fs::create_dir_all(&canonical).unwrap(); + std::fs::write(canonical.join("STATE.a2ml"), "").unwrap(); + assert!(state_path(&dir).ends_with("descriptiles/STATE.a2ml")); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn extracts_markdown_shapes_too() { + let md = "\nOVERALL: ~60% Grade D\n"; + assert_eq!(extract_last_updated(md).as_deref(), Some("2026-07-02")); + assert_eq!( + extract_grade_readiness("**Current Grade:** C\n").as_deref(), + Some("C") + ); + } + + #[test] + fn passes_when_aligned_and_reports_what_it_compared() { let s = extract_state(STATE_60_D).unwrap(); - let problems = reconcile(&s, Some(TOPOLOGY_60_D), Some(READINESS_D)); - assert!(problems.is_empty(), "expected no problems, got {problems:?}"); + let t = surf("TOPOLOGY.adoc", TOPOLOGY_ADOC); + let r = surf("READINESS.adoc", READINESS_ADOC); + let report = reconcile(&s, Some(&t), Some(&r)); + assert!( + report.problems.is_empty(), + "expected no problems, got {:?}", + report.problems + ); + // completion, TOPOLOGY grade, staleness, READINESS grade. + assert_eq!(report.observed.len(), 4, "{:?}", report.observed); } #[test] fn fails_on_completion_drift() { // The historical disease: STATE 50, dashboard 60. - let s = extract_state(STATE_50_BARE).unwrap(); - let problems = reconcile(&s, Some(TOPOLOGY_60_D), None); - assert_eq!(problems.len(), 1); - assert!(problems[0].contains("completion mismatch"), "{problems:?}"); + let p = problems_with(STATE_50_BARE, Some(TOPOLOGY_ADOC), Some(READINESS_ADOC)); + assert_eq!(p.len(), 1, "{p:?}"); + assert!(p[0].contains("completion mismatch"), "{p:?}"); } #[test] - fn fails_on_grade_drift() { - let s = extract_state(STATE_60_D).unwrap(); - let bad_topology = "OVERALL: ~60% Grade A — Production Ready\n"; - let problems = reconcile(&s, Some(bad_topology), None); - assert!(problems.iter().any(|p| p.contains("grade mismatch")), "{problems:?}"); + fn fails_on_topology_grade_drift() { + let bad = TOPOLOGY_ADOC.replace("~60% Grade D", "~60% Grade A"); + let p = problems_with(STATE_60_D, Some(&bad), Some(READINESS_ADOC)); + assert!( + p.iter() + .any(|p| p.contains("grade mismatch") && p.contains("TOPOLOGY")), + "{p:?}" + ); + } + + #[test] + fn prose_grade_does_not_stand_in_for_the_overall_grade() { + // OVERALL line without a grade; prose elsewhere still says "Grade D". + let no_grade = TOPOLOGY_ADOC.replace("Grade D — Alpha, Unstable (not v1.0)", ""); + let p = problems_with(STATE_60_D, Some(&no_grade), Some(READINESS_ADOC)); + assert!( + p.iter() + .any(|p| p.contains("no parseable grade on its OVERALL line")), + "{p:?}" + ); + } + + #[test] + fn fails_on_readiness_grade_drift() { + let bad = READINESS_ADOC.replace(": D", ": B").replace("* D", "* B"); + let p = problems_with(STATE_60_D, Some(TOPOLOGY_ADOC), Some(&bad)); + assert!( + p.iter() + .any(|p| p.contains("grade mismatch") && p.contains("READINESS")), + "{p:?}" + ); } #[test] fn prose_mentioning_overall_does_not_shadow_dashboard() { - // A source-of-truth note that mentions "OVERALL" in prose (no %) must - // not be picked instead of the real "OVERALL: ~60%" dashboard line. let topo = "\ +// Last updated: 2026-07-02 > agreement is enforced if the OVERALL percentage drifts from STATE. OVERALL: ██████░░░░ ~60% Grade D "; assert_eq!(extract_overall_pct(topo), Some(60)); - let s = extract_state(STATE_60_D).unwrap(); - assert!(reconcile(&s, Some(topo), None).is_empty()); + assert!(problems_with(STATE_60_D, Some(topo), Some(READINESS_ADOC)).is_empty()); } #[test] fn fails_on_stale_dashboard() { - let s = extract_state(STATE_60_D).unwrap(); - let stale = "\ - -OVERALL: ~60% Grade D -"; - let problems = reconcile(&s, Some(stale), None); - assert!(problems.iter().any(|p| p.contains("staleness")), "{problems:?}"); + let stale = TOPOLOGY_ADOC.replace("2026-07-02", "2026-01-01"); + let p = problems_with(STATE_60_D, Some(&stale), Some(READINESS_ADOC)); + assert!(p.iter().any(|p| p.contains("staleness")), "{p:?}"); + } + + // A check that compares nothing must fail: one test per field that used + // to be skipped silently when absent. + + #[test] + fn missing_surfaces_fail() { + let p = problems_with(STATE_60_D, None, None); + assert!(p.iter().any(|p| p.contains("no TOPOLOGY")), "{p:?}"); + assert!(p.iter().any(|p| p.contains("no READINESS")), "{p:?}"); + } + + #[test] + fn missing_last_updated_fails() { + let undated: String = TOPOLOGY_ADOC + .lines() + .skip(1) + .map(|l| format!("{l}\n")) + .collect(); + let p = problems_with(STATE_60_D, Some(&undated), Some(READINESS_ADOC)); + assert!(p.iter().any(|p| p.contains("no 'Last updated")), "{p:?}"); + } + + #[test] + fn missing_overall_fails() { + let no_overall = TOPOLOGY_ADOC.replace("OVERALL:", "TOTAL:"); + let p = problems_with(STATE_60_D, Some(&no_overall), Some(READINESS_ADOC)); + assert!( + p.iter().any(|p| p.contains("no parseable 'OVERALL")), + "{p:?}" + ); + } + + #[test] + fn unparseable_readiness_grade_fails() { + let p = problems_with(STATE_60_D, Some(TOPOLOGY_ADOC), Some("no grade here\n")); + assert!( + p.iter() + .any(|p| p.contains("READINESS.adoc has no parseable grade")), + "{p:?}" + ); + } + + #[test] + fn missing_state_fields_fail() { + let p = problems_with("[metadata]\n", Some(TOPOLOGY_ADOC), Some(READINESS_ADOC)); + assert!( + p.iter().any(|p| p.contains("no completion-percentage")), + "{p:?}" + ); + assert!(p.iter().any(|p| p.contains("no crg-grade")), "{p:?}"); + assert!( + p.iter().any(|p| p.contains("no [metadata].last-updated")), + "{p:?}" + ); } }