1use std::collections::BTreeMap;
48
49use schemars::JsonSchema;
50use serde::{Deserialize, Serialize};
51use serde_json::{json, Value};
52
53use crate::capability::handler::{CapabilityError, Context};
54use crate::capability::model::{CachePolicy, CliExposure, Exposure, Stability};
55use crate::capability::module::ModuleDescriptor;
56use crate::{capability, module};
57
58use super::health::HealthStatus;
59use super::{get, mcp, Empty};
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
80#[serde(rename_all = "snake_case")]
81pub enum DashboardMeasure {
82 Exact,
84 Count,
87}
88
89impl DashboardMeasure {
90 pub fn read(self, answer: &Value, pointer: &str) -> Value {
100 let at = answer.pointer(pointer);
101 match self {
102 DashboardMeasure::Exact => at.cloned().unwrap_or(Value::Null),
103 DashboardMeasure::Count => match at {
104 None | Some(Value::Null) => json!(0),
105 Some(Value::Array(list)) => json!(list.len()),
106 Some(Value::Object(map)) => json!(map.len()),
107 Some(other) => other.clone(),
108 },
109 }
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
131pub struct DashboardSource {
132 pub capability: String,
134 pub input: Value,
136 pub pointer: String,
138 pub measure: DashboardMeasure,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
163pub struct DashboardCard {
164 pub id: String,
166 pub title: String,
168 pub value: Value,
171 pub status: HealthStatus,
173 pub detail: String,
175 pub source: DashboardSource,
177 pub route: String,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub action: Option<String>,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
197pub struct DashboardQuestion {
198 pub id: String,
200 pub title: String,
202 pub status: HealthStatus,
204 pub cards: Vec<DashboardCard>,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
219pub struct DashboardOverview {
220 pub status: HealthStatus,
223 pub questions: Vec<DashboardQuestion>,
225}
226
227type Judge = fn(&Value, &Value) -> (HealthStatus, String);
231
232struct Spec {
236 question: &'static str,
237 id: &'static str,
238 title: &'static str,
239 capability: &'static str,
240 input: &'static str,
241 pointers: &'static [&'static str],
242 measure: DashboardMeasure,
243 route: &'static str,
244 action: Option<&'static str>,
245 judge: Judge,
246}
247
248const QUESTIONS: [(&str, &str); 4] = [
249 ("healthy", "Is it healthy?"),
250 ("changed", "What changed?"),
251 ("broken", "What is broken?"),
252 ("action", "What needs action?"),
253];
254
255fn count(v: &Value) -> u64 {
256 v.as_u64().unwrap_or(0)
257}
258
259fn some_or(v: &Value, none: HealthStatus, some: HealthStatus) -> HealthStatus {
260 if count(v) > 0 {
261 some
262 } else {
263 none
264 }
265}
266
267fn health_status(v: &Value, answer: &Value) -> (HealthStatus, String) {
268 let status = serde_json::from_value::<HealthStatus>(v.clone()).unwrap_or(HealthStatus::Unknown);
269 let checks = answer["checks"].as_array().cloned().unwrap_or_default();
270 let not_ok: Vec<String> = checks
271 .iter()
272 .filter(|c| c["status"] != "ok")
273 .map(|c| {
274 format!(
275 "{} ({})",
276 c["id"].as_str().unwrap_or("?"),
277 c["status"].as_str().unwrap_or("?")
278 )
279 })
280 .collect();
281 let detail = if not_ok.is_empty() {
282 format!("the worst of {} check(s); every one is ok", checks.len())
283 } else {
284 format!(
285 "the worst of {} check(s); not ok: {}",
286 checks.len(),
287 not_ok.join(", ")
288 )
289 };
290 (status, detail)
291}
292
293fn health_failing(v: &Value, answer: &Value) -> (HealthStatus, String) {
294 let failing: Vec<&str> = answer["checks"]
295 .as_array()
296 .map(|a| {
297 a.iter()
298 .filter(|c| c["status"] == "fail")
299 .filter_map(|c| c["id"].as_str())
300 .collect()
301 })
302 .unwrap_or_default();
303 let detail = if failing.is_empty() {
304 "no check's engine reports a failure".to_string()
305 } else {
306 format!("failing: {}", failing.join(", "))
307 };
308 (some_or(v, HealthStatus::Ok, HealthStatus::Fail), detail)
309}
310
311fn version_declared(_: &Value, answer: &Value) -> (HealthStatus, String) {
312 let tool = answer["tool"].as_str().unwrap_or("?");
313 if answer["agree"].as_bool() == Some(true) {
314 (
315 HealthStatus::Ok,
316 format!("authored in Cargo.toml; share/version.txt states {tool}, current"),
317 )
318 } else {
319 (
320 HealthStatus::Fail,
321 format!("share/version.txt states {tool}, which `generate --check` refuses"),
322 )
323 }
324}
325
326fn last_release(v: &Value, _: &Value) -> (HealthStatus, String) {
327 match v.as_str() {
328 Some(r) => (
329 HealthStatus::Ok,
330 format!("the layer records {r} as released"),
331 ),
332 None => (
333 HealthStatus::Ok,
334 "the layer records no release yet".to_string(),
335 ),
336 }
337}
338
339fn pending_bump(v: &Value, answer: &Value) -> (HealthStatus, String) {
340 let n = answer["changes"].as_array().map(Vec::len).unwrap_or(0);
341 match v.as_str() {
342 Some("none") | None => (
343 HealthStatus::Ok,
344 "no commit since the last release implies a release".to_string(),
345 ),
346 Some(bump) => (
347 HealthStatus::Warn,
348 format!("{n} kind(s) of change since the last release imply a {bump} release"),
349 ),
350 }
351}
352
353fn next_version(v: &Value, answer: &Value) -> (HealthStatus, String) {
354 let bump = answer["bump"].as_str().unwrap_or("?");
355 match (v.as_str(), answer["decided_by"].as_str()) {
356 (Some(_), Some("contract")) => (
357 HealthStatus::Ok,
358 format!(
359 "the version the public contract requires: the declared one when it already \
360 satisfies the contract, otherwise the smallest it allows; the commits imply a \
361 {bump} (evidence)"
362 ),
363 ),
364 (Some(_), Some("contract_and_commits")) => (
365 HealthStatus::Ok,
366 format!(
367 "the contract requires no release over the last one and the commits imply a \
368 {bump}: the smallest release above it, a patch"
369 ),
370 ),
371 (_, Some("undecided")) => (
372 HealthStatus::Unknown,
373 format!(
374 "undecided: the public contract could not be measured ({}), so no version is \
375 selected; the commits imply a {bump} (evidence only), and `release bump` needs \
376 an explicit target",
377 answer["contract_unreadable"]
378 .as_str()
379 .unwrap_or("no reason given")
380 ),
381 ),
382 (Some(_), _) => (
383 HealthStatus::Ok,
384 "the version the public contract requires".to_string(),
385 ),
386 (None, _) => (HealthStatus::Ok, "nothing would be released".to_string()),
387 }
388}
389
390fn blocked_issues(v: &Value, answer: &Value) -> (HealthStatus, String) {
391 if v.is_null() {
392 return (
393 HealthStatus::Unknown,
394 "the plan's vocabulary declares no BLOCKED status".to_string(),
395 );
396 }
397 (
398 some_or(v, HealthStatus::Ok, HealthStatus::Warn),
399 format!(
400 "of {} issue(s), waiting on a dependency that is not done",
401 answer["counts"]["total"].as_u64().unwrap_or(0)
402 ),
403 )
404}
405
406fn repository_errors(v: &Value, _: &Value) -> (HealthStatus, String) {
407 (
408 some_or(v, HealthStatus::Ok, HealthStatus::Fail),
409 "error-level diagnostics across the whole worktree topology".to_string(),
410 )
411}
412
413fn canonical(v: &Value, answer: &Value) -> (HealthStatus, String) {
414 let path = answer["worktree"]["path"].as_str().unwrap_or("?");
415 if v.as_bool() == Some(true) {
416 (
417 HealthStatus::Ok,
418 format!("{path} is where its branch belongs"),
419 )
420 } else {
421 (
422 HealthStatus::Fail,
423 format!("{path} is not where its branch belongs; the guard refuses a commit there"),
424 )
425 }
426}
427
428fn next_issue(v: &Value, answer: &Value) -> (HealthStatus, String) {
429 let milestone = answer["active_milestone"].as_str().unwrap_or("?");
430 if answer["issue"].is_object() {
431 (
432 HealthStatus::Ok,
433 format!(
434 "{} (active milestone {milestone})",
435 answer["issue"]["title"]
436 .as_str()
437 .unwrap_or("the next ready issue")
438 ),
439 )
440 } else if v.is_null() {
441 (
442 HealthStatus::Warn,
443 format!("nothing is executable and no reason was given (active milestone {milestone})"),
444 )
445 } else {
446 (
447 HealthStatus::Warn,
448 format!("nothing is executable (active milestone {milestone})"),
449 )
450 }
451}
452
453fn blockers(v: &Value, _: &Value) -> (HealthStatus, String) {
454 (
455 some_or(v, HealthStatus::Ok, HealthStatus::Fail),
456 "unresolved questions on this branch; every one refuses `finish --outcome completed`"
457 .to_string(),
458 )
459}
460
461fn overlaps(v: &Value, answer: &Value) -> (HealthStatus, String) {
462 (
463 some_or(v, HealthStatus::Ok, HealthStatus::Warn),
464 format!(
465 "pairs of workers whose claimed scopes meet, on this checkout's board of {} peer(s)",
466 answer["count"].as_u64().unwrap_or(0)
467 ),
468 )
469}
470
471const CARDS: &[Spec] = &[
473 Spec {
474 question: "healthy",
475 id: "health-status",
476 title: "Health of this process",
477 capability: "health.report",
478 input: "{}",
479 pointers: &["/status"],
480 measure: DashboardMeasure::Exact,
481 route: "/cockpit/health",
482 action: Some("majordomus doctor"),
483 judge: health_status,
484 },
485 Spec {
486 question: "healthy",
487 id: "health-failing",
488 title: "Failing checks",
489 capability: "health.report",
490 input: "{}",
491 pointers: &["/tallies/fail"],
492 measure: DashboardMeasure::Count,
493 route: "/cockpit/health",
494 action: Some("majordomus doctor"),
495 judge: health_failing,
496 },
497 Spec {
498 question: "changed",
499 id: "version-declared",
500 title: "Declared version",
501 capability: "release.version",
502 input: "{}",
503 pointers: &["/declared"],
504 measure: DashboardMeasure::Exact,
505 route: "/cockpit/release",
506 action: Some("majordomus release version"),
507 judge: version_declared,
508 },
509 Spec {
510 question: "changed",
511 id: "last-release",
512 title: "Last release",
513 capability: "release.version",
514 input: "{}",
515 pointers: &["/last_release"],
516 measure: DashboardMeasure::Exact,
517 route: "/cockpit/release",
518 action: Some("majordomus release changelog"),
519 judge: last_release,
520 },
521 Spec {
522 question: "changed",
523 id: "pending-bump",
524 title: "Bump the commits imply",
525 capability: "release.version",
526 input: "{}",
527 pointers: &["/bump"],
528 measure: DashboardMeasure::Exact,
529 route: "/cockpit/release",
530 action: Some("majordomus release analyze"),
531 judge: pending_bump,
532 },
533 Spec {
534 question: "changed",
535 id: "next-version",
536 title: "Next version",
537 capability: "release.version",
538 input: "{}",
539 pointers: &["/next"],
540 measure: DashboardMeasure::Exact,
541 route: "/cockpit/release",
542 action: Some("majordomus release bump"),
543 judge: next_version,
544 },
545 Spec {
546 question: "broken",
547 id: "blocked-issues",
548 title: "Blocked issues",
549 capability: "plan.status",
550 input: "{}",
551 pointers: &["/counts/by_status/BLOCKED"],
552 measure: DashboardMeasure::Exact,
553 route: "/cockpit/capabilities/plan.status",
554 action: Some("majordomus plan blocked"),
555 judge: blocked_issues,
556 },
557 Spec {
558 question: "broken",
559 id: "worktree-errors",
560 title: "Worktree topology errors",
561 capability: "worktree.status",
562 input: "{}",
563 pointers: &["/repository_errors"],
564 measure: DashboardMeasure::Exact,
565 route: "/cockpit/worktrees",
566 action: Some("majordomus worktree topology"),
567 judge: repository_errors,
568 },
569 Spec {
570 question: "broken",
571 id: "worktree-canonical",
572 title: "This worktree is where it belongs",
573 capability: "worktree.status",
574 input: "{}",
575 pointers: &["/canonical"],
576 measure: DashboardMeasure::Exact,
577 route: "/cockpit/worktrees",
578 action: Some("majordomus worktree migrate"),
579 judge: canonical,
580 },
581 Spec {
582 question: "action",
583 id: "next-issue",
584 title: "Next issue",
585 capability: "plan.next",
586 input: "{}",
587 pointers: &["/issue/id", "/reason"],
588 measure: DashboardMeasure::Exact,
589 route: "/cockpit/capabilities/plan.next",
590 action: Some("majordomus plan next"),
591 judge: next_issue,
592 },
593 Spec {
594 question: "action",
595 id: "open-blockers",
596 title: "Questions refusing completion",
597 capability: "continuity.state",
598 input: "{}",
599 pointers: &["/tallies/blockers"],
600 measure: DashboardMeasure::Count,
601 route: "/cockpit/continuity",
602 action: Some("majordomus question list"),
603 judge: blockers,
604 },
605 Spec {
606 question: "action",
607 id: "peer-overlaps",
608 title: "Overlapping claims",
609 capability: "peers.list",
610 input: r#"{"checkouts":"this"}"#,
611 pointers: &["/overlaps"],
612 measure: DashboardMeasure::Count,
613 route: "/cockpit/capabilities/peers.list",
614 action: None,
615 judge: overlaps,
616 },
617];
618
619fn overview(ctx: &Context, _: Empty) -> Result<DashboardOverview, CapabilityError> {
624 let answers = ask_all(ctx, &sources(CARDS));
625 let questions: Vec<DashboardQuestion> = QUESTIONS
626 .iter()
627 .map(|(id, title)| {
628 let cards: Vec<DashboardCard> = CARDS
629 .iter()
630 .filter(|s| s.question == *id)
631 .map(|spec| card(spec, &answers[&(spec.capability, spec.input)]))
632 .collect();
633 DashboardQuestion {
634 id: (*id).into(),
635 title: (*title).into(),
636 status: cards
637 .iter()
638 .fold(HealthStatus::Ok, |acc, c| acc.worse(c.status)),
639 cards,
640 }
641 })
642 .collect();
643 Ok(DashboardOverview {
644 status: questions
645 .iter()
646 .fold(HealthStatus::Ok, |acc, q| acc.worse(q.status)),
647 questions,
648 })
649}
650
651type Source = (&'static str, &'static str);
653
654fn sources(cards: &[Spec]) -> Vec<Source> {
657 let mut out: Vec<Source> = Vec::new();
658 for spec in cards {
659 let source = (spec.capability, spec.input);
660 if !out.contains(&source) {
661 out.push(source);
662 }
663 }
664 out
665}
666
667fn ask_all(ctx: &Context, sources: &[Source]) -> BTreeMap<Source, Result<Value, String>> {
678 let answers: Vec<Result<Value, String>> = std::thread::scope(|scope| {
679 let asked: Vec<_> = sources
680 .iter()
681 .map(|&(capability, input)| scope.spawn(move || ask(ctx, capability, input)))
682 .collect();
683 asked
684 .into_iter()
685 .map(|thread| joined(thread.join()))
686 .collect()
687 });
688 let mut out = BTreeMap::new();
689 for (&source, answer) in sources.iter().zip(answers) {
690 ctx.progress.step_done(source.0, answer.is_ok(), None);
691 out.insert(source, answer);
692 }
693 out
694}
695
696fn joined(thread: std::thread::Result<Result<Value, String>>) -> Result<Value, String> {
699 match thread {
700 Ok(answer) => answer,
701 Err(_) => Err("the source panicked while answering".into()),
702 }
703}
704
705fn ask(ctx: &Context, capability: &str, input: &str) -> Result<Value, String> {
707 let input: Value = serde_json::from_str(input)
708 .map_err(|e| format!("the card's own input does not parse: {e}"))?;
709 ctx.execute(capability, input).map_err(|e| e.to_string())
710}
711
712fn card(spec: &Spec, answer: &Result<Value, String>) -> DashboardCard {
715 let input: Value = serde_json::from_str(spec.input).unwrap_or(Value::Null);
716 let mut source = DashboardSource {
717 capability: spec.capability.into(),
718 input,
719 pointer: spec.pointers[0].into(),
720 measure: spec.measure,
721 };
722 let (value, status, detail) = match answer {
723 Ok(answer) => {
724 if let Some(p) = spec.pointers.iter().find(|p| answer.pointer(p).is_some()) {
725 source.pointer = (*p).into();
726 }
727 let value = spec.measure.read(answer, &source.pointer);
728 let (status, detail) = (spec.judge)(&value, answer);
729 (value, status, detail)
730 }
731 Err(reason) => (
732 Value::Null,
733 HealthStatus::Unknown,
734 format!(
735 "{} could not answer, so this is unknown rather than a pass: {reason}",
736 spec.capability
737 ),
738 ),
739 };
740 DashboardCard {
741 id: spec.id.into(),
742 title: spec.title.into(),
743 value,
744 status,
745 detail,
746 source,
747 route: spec.route.into(),
748 action: spec.action.map(str::to_string),
749 }
750}
751
752pub fn module() -> ModuleDescriptor {
765 module! {
766 id: "dashboard",
767 title: "Dashboards",
768 description: "The Dashboard Suite as a projection of the canonical state: every card is one fact read out of an existing capability's answer, carrying the capability it asked, the input, the JSON pointer its value was read from, the Cockpit page that holds the evidence and the command that acts on it. No dashboard stores, counts or judges anything of its own (ADR 0088).",
769 stability: Stability::BehaviorallyVerified,
770 capabilities: [
771 capability! {
772 id: "dashboard.overview",
773 title: "The Overview: four questions",
774 description: "Is it healthy, what changed, what is broken, what needs action — each answered by cards read out of `health.report`, `release.version`, `plan.status`, `worktree.status`, `plan.next`, `continuity.state` and `peers.list` (this checkout's board), asked once each through the executor. Every card carries its source capability, input and RFC 6901 pointer, so asking the source the same question yields the same value; a source that cannot answer makes its cards `unknown`, never `ok`. Nothing here rebuilds canonical state or asks anything beyond this process and its checkout.",
775 input: Empty,
776 output: DashboardOverview,
777 stability: Stability::BehaviorallyVerified,
778 exposure: Exposure {
779 mcp: mcp("majordomus_dashboard_overview"),
780 http: get("/api/v1/dashboard/overview"),
781 cli: Some(CliExposure { path: vec!["dashboard".into(), "overview".into()] }),
782 },
783 tags: ["dashboard", "overview", "introspection"],
784 cache: CachePolicy::Disabled,
787 handler: overview,
788 },
789 ],
790 }
791}
792
793#[cfg(test)]
794mod tests {
795 use super::*;
796 use crate::synthetic::{Shape, SyntheticRepository};
797
798 #[test]
801 fn a_panicked_source_and_an_unparsable_input_fail_their_own_card() {
802 assert_eq!(
803 joined(Err(Box::new("boom"))),
804 Err("the source panicked while answering".to_string())
805 );
806 assert_eq!(joined(Ok(Ok(Value::Null))), Ok(Value::Null));
807 let repo = SyntheticRepository::new(Shape::default()).expect("a synthetic repository");
808 let ctx = repo.context().expect("a context");
809 let reason =
810 ask(&ctx, "plan.status", "{not json").expect_err("an input that does not parse");
811 assert!(reason.contains("does not parse"), "{reason}");
812 }
813
814 #[test]
818 fn the_next_version_card_names_who_answered() {
819 let v = Value::from("0.11.0");
820 let by = |decided_by: &str| {
821 next_version(
822 &v,
823 &serde_json::json!({ "bump": "major", "decided_by": decided_by }),
824 )
825 };
826 let (status, contract) = by("contract");
827 assert_eq!(status, HealthStatus::Ok);
828 assert!(contract.starts_with("the version the public contract requires"));
829 assert!(contract.contains("the commits imply a major (evidence)"));
830 let (_, both) = by("contract_and_commits");
831 assert!(both.starts_with("the contract requires no release over the last one"));
832 let (status, undecided) = next_version(
835 &Value::Null,
836 &serde_json::json!({
837 "bump": "major",
838 "decided_by": "undecided",
839 "contract_unreadable": "no release to compare with",
840 }),
841 );
842 assert_eq!(status, HealthStatus::Unknown);
843 assert!(undecided.starts_with("undecided: the public contract could not be measured"));
844 assert!(undecided.contains("no release to compare with"));
845 assert!(undecided.contains("needs an explicit target"));
846 let (_, nothing) = next_version(
847 &Value::Null,
848 &serde_json::json!({ "decided_by": "contract" }),
849 );
850 assert_eq!(nothing, "nothing would be released");
851 for answer in [
855 serde_json::json!({ "bump": "minor" }),
856 serde_json::json!({ "bump": "minor", "decided_by": "a-later-word" }),
857 ] {
858 let (status, plain) = next_version(&v, &answer);
859 assert_eq!(status, HealthStatus::Ok);
860 assert_eq!(plain, "the version the public contract requires");
861 }
862 }
863
864 #[test]
867 fn the_declaration_yields_the_projections_it_claims() {
868 let m = module();
869 let ids: Vec<&str> = m
870 .capabilities
871 .iter()
872 .map(|e| e.capability.id.as_str())
873 .collect();
874 assert_eq!(ids, ["dashboard.overview"]);
875 let exposure = &m.capabilities[0].capability.exposure;
876 assert_eq!(
877 exposure.http.as_ref().unwrap().path,
878 "/api/v1/dashboard/overview"
879 );
880 assert_eq!(
881 exposure.cli.as_ref().unwrap().path,
882 ["dashboard", "overview"]
883 );
884 }
885
886 #[test]
889 fn every_card_is_declared_once_and_reads_a_cheap_source() {
890 let mut seen = std::collections::BTreeSet::new();
891 for spec in CARDS {
892 assert!(seen.insert(spec.id), "{} is declared twice", spec.id);
893 assert!(
894 QUESTIONS.iter().any(|(q, _)| *q == spec.question),
895 "{} answers no question",
896 spec.id
897 );
898 assert!(!spec.pointers.is_empty(), "{} reads nowhere", spec.id);
899 for p in spec.pointers {
900 assert!(p.starts_with('/'), "{}: {p} is not a JSON pointer", spec.id);
901 }
902 serde_json::from_str::<Value>(spec.input)
903 .unwrap_or_else(|e| panic!("{}: input does not parse: {e}", spec.id));
904 for refused in ["delivery.", "release.analysis", "evidence."] {
905 assert!(
906 !spec.capability.starts_with(refused),
907 "{} reads {}, which the Overview must not ask",
908 spec.id,
909 spec.capability
910 );
911 }
912 }
913 for (q, _) in QUESTIONS {
914 assert!(
915 CARDS.iter().any(|s| s.question == q),
916 "the question {q} has no card"
917 );
918 }
919 }
920
921 #[test]
924 fn every_source_is_asked_once_in_the_order_it_is_declared() {
925 let asked = sources(CARDS);
926 let mut seen = std::collections::BTreeSet::new();
927 for source in &asked {
928 assert!(seen.insert(*source), "{source:?} would be asked twice");
929 }
930 let mut first_named: Vec<Source> = Vec::new();
931 for spec in CARDS {
932 if !first_named.contains(&(spec.capability, spec.input)) {
933 first_named.push((spec.capability, spec.input));
934 }
935 }
936 assert_eq!(asked, first_named);
937 assert!(
938 asked.len() < CARDS.len(),
939 "some cards share a source, and sharing is what this is for"
940 );
941 }
942
943 #[test]
946 fn asked_at_once_every_source_answers_as_it_does_alone() {
947 let repo = SyntheticRepository::new(Shape::default()).expect("a synthetic repository");
948 let ctx = repo.context().expect("a context");
949 let asked = sources(CARDS);
950 let at_once = ask_all(&ctx, &asked);
951 assert_eq!(at_once.len(), asked.len(), "one answer per source");
952 for &(capability, input) in &asked {
953 let alone = ask(&ctx, capability, input);
954 let concurrent = &at_once[&(capability, input)];
955 assert_eq!(
956 alone.is_ok(),
957 concurrent.is_ok(),
958 "{capability} answered differently at once ({concurrent:?}) and alone ({alone:?})"
959 );
960 }
961 }
962
963 #[test]
966 fn every_card_is_its_sources_value_or_unknown() {
967 let repo = SyntheticRepository::new(Shape::default()).expect("a synthetic repository");
968 let ctx = repo.context().expect("a context");
969 for spec in CARDS {
970 assert!(
971 ctx.registry.get(spec.capability).is_some(),
972 "{} reads {}, which the registry does not hold",
973 spec.id,
974 spec.capability
975 );
976 }
977 let o = overview(&ctx, Empty {}).expect("the overview answers");
978 let ids: Vec<&str> = o.questions.iter().map(|q| q.id.as_str()).collect();
979 assert_eq!(ids, ["healthy", "changed", "broken", "action"]);
980 for card in o.questions.iter().flat_map(|q| &q.cards) {
981 match ctx.execute(&card.source.capability, card.source.input.clone()) {
982 Ok(answer) => assert_eq!(
983 card.source.measure.read(&answer, &card.source.pointer),
984 card.value,
985 "{} is not its source's value",
986 card.id
987 ),
988 Err(_) => {
989 assert_eq!(card.status, HealthStatus::Unknown, "{}", card.id);
990 assert!(card.value.is_null(), "{}", card.id);
991 }
992 }
993 }
994 }
995
996 #[test]
999 fn an_unanswered_source_is_unknown_with_its_reason() {
1000 let spec = &CARDS[0];
1001 let c = card(spec, &Err("no such thing".into()));
1002 assert_eq!(c.status, HealthStatus::Unknown);
1003 assert!(c.value.is_null());
1004 assert!(c.detail.contains("no such thing"), "{}", c.detail);
1005 assert!(
1006 c.detail.contains("unknown rather than a pass"),
1007 "{}",
1008 c.detail
1009 );
1010 assert_eq!(c.source.capability, spec.capability);
1011 }
1012
1013 #[test]
1016 fn a_card_names_the_pointer_it_read() {
1017 let spec = CARDS
1018 .iter()
1019 .find(|s| s.id == "next-issue")
1020 .expect("declared");
1021 let with = card(
1022 spec,
1023 &Ok(json!({ "issue": { "id": "I0001" }, "active_milestone": "M1" })),
1024 );
1025 assert_eq!(with.source.pointer, "/issue/id");
1026 assert_eq!(with.value, json!("I0001"));
1027 assert_eq!(with.status, HealthStatus::Ok);
1028 let without = card(
1029 spec,
1030 &Ok(json!({ "reason": "run plan blocked", "active_milestone": "M1" })),
1031 );
1032 assert_eq!(without.source.pointer, "/reason");
1033 assert_eq!(without.value, json!("run plan blocked"));
1034 assert_eq!(without.status, HealthStatus::Warn);
1035 }
1036}