#![cfg(feature = "tui")]
use std::collections::BTreeSet;
use clap::CommandFactory;
use ignition_cli::cli::Cli;
use ignition_tui::routes::{Mapping, menu_label, routes};
use ignition_tui::state::{ACTIONS, Screen};
fn walk(cmd: &clap::Command, prefix: &str, out: &mut Vec<String>) {
for sub in cmd.get_subcommands() {
if sub.get_name() == "help" {
continue;
}
let path = if prefix.is_empty() {
sub.get_name().to_string()
} else {
format!("{prefix} {}", sub.get_name())
};
let has_real_children = sub
.get_subcommands()
.any(|child| child.get_name() != "help");
if !has_real_children || !sub.is_subcommand_required_set() {
out.push(path.clone());
}
walk(sub, &path, out);
}
}
#[test]
fn every_row_requiring_cli_node_is_mapped_and_no_orphans() {
let mut cli_nodes = Vec::new();
walk(&Cli::command(), "", &mut cli_nodes);
assert!(!cli_nodes.is_empty(), "the walk found the tree");
let cli_set: BTreeSet<&str> = cli_nodes.iter().map(String::as_str).collect();
let registry_set: BTreeSet<&str> = routes().iter().map(|route| route.path).collect();
let missing: Vec<&str> = cli_set.difference(®istry_set).copied().collect();
assert!(
missing.is_empty(),
"CLI row-requiring nodes with no TUI mapping (add routes() rows or \
an OutOfBand justification): {missing:#?}"
);
let orphans: Vec<&str> = registry_set.difference(&cli_set).copied().collect();
assert!(
orphans.is_empty(),
"TUI registry rows with no CLI node (stale or misspelled paths): {orphans:#?}"
);
assert_eq!(
cli_set.len(),
registry_set.len(),
"bidirectional equality over {} row-requiring nodes",
registry_set.len()
);
}
#[test]
fn out_of_band_rows_are_pinned() {
let mut out_of_band: Vec<&str> = routes()
.iter()
.filter(|route| matches!(route.mapping, Mapping::OutOfBand))
.map(|route| route.path)
.collect();
out_of_band.sort_unstable();
assert_eq!(
out_of_band,
vec!["api call", "completions", "edit", "lsp", "mcp"],
"the OutOfBand set must stay exactly [api call, completions, edit, mcp, lsp] — \
a new member needs a written justification here AND in routes.rs, \
landing in the SAME task as its clap command"
);
}
#[test]
fn bare_option_forms_are_row_requiring_nodes() {
let mut cli_nodes = Vec::new();
walk(&Cli::command(), "", &mut cli_nodes);
let cli_set: BTreeSet<&str> = cli_nodes.iter().map(String::as_str).collect();
for bare in ["sessions", "logs", "logs loggers"] {
assert!(
cli_set.contains(bare),
"the bare form {bare:?} must be row-requiring (Option subcommand)"
);
}
for group in [
"wait",
"project",
"workspace",
"tags",
"rig",
"rig trial",
"profile",
"resource",
"webdev",
] {
assert!(
!cli_set.contains(group),
"the required-subcommand group {group:?} must not be a row of its own"
);
}
}
#[test]
fn workspace_rows_cover_the_family() {
let rows: Vec<&ignition_tui::routes::CliRoute> = routes()
.iter()
.filter(|route| route.path.starts_with("workspace"))
.collect();
let expected = ["workspace checkout", "workspace status", "workspace push"];
assert_eq!(
rows.len(),
expected.len(),
"exactly the workspace leaves that exist: {rows:?}"
);
for path in expected {
let row = rows
.iter()
.find(|route| route.path == path)
.unwrap_or_else(|| panic!("workspace route row {path:?} missing"));
assert!(
matches!(row.mapping, Mapping::Screen(Screen::Projects)),
"{path} maps to the Projects screen"
);
}
}
#[test]
fn dashboard_actions_menu_matches_registry() {
const MENU_HOSTED: &[&str] = &[
"version",
"connections",
"wait gateway",
"wait restart",
"wait module",
"doctor",
"restart",
"backup download",
"backup restore",
"eam history",
"eam tasks",
"eam task new",
"eam task force",
"eam task suspend",
"eam task resume",
"eam task cancel",
"eam task modify",
"eam task delete",
"script run",
"lint",
"license status",
"redundancy status",
"gan status",
"diagnostics bundle generate",
"diagnostics bundle status",
"diagnostics bundle download",
"diagnostics bundle wait",
];
let dashboard_rows: Vec<&str> = routes()
.iter()
.filter(|route| matches!(route.mapping, Mapping::Screen(Screen::Dashboard)))
.map(|route| route.path)
.collect();
assert_eq!(
dashboard_rows.len(),
36,
"a new Screen(Dashboard) route landed — extend MENU_HOSTED + ACTIONS \
+ the update.rs executor arms in the same change, or justify the \
exclusion in this test's comment block: {dashboard_rows:#?}"
);
for path in MENU_HOSTED {
let row = routes()
.iter()
.find(|route| route.path == *path)
.unwrap_or_else(|| panic!("menu verb {path:?} has no routes() row"));
assert!(
matches!(row.mapping, Mapping::Screen(Screen::Dashboard)),
"menu verb {path:?} must map Screen(Dashboard)"
);
}
let menu_labels: Vec<(&str, &str)> = MENU_HOSTED
.iter()
.map(|&path| (path, menu_label(path).unwrap_or(path)))
.collect();
for (_, label) in &menu_labels {
assert!(
ACTIONS.contains(label),
"menu label {label:?} is not an ACTIONS entry (extend ACTIONS \
+ the update.rs executor arms in the same change)"
);
}
for action in ACTIONS {
let sources: Vec<&str> = menu_labels
.iter()
.filter(|(_, label)| *label == action)
.map(|(path, _)| *path)
.collect();
assert_eq!(
sources.len(),
1,
"ACTIONS entry {action:?} must trace to exactly one MENU_HOSTED \
path via menu_label (found {sources:?})"
);
}
assert_eq!(
ACTIONS.len(),
MENU_HOSTED.len(),
"ACTIONS and MENU_HOSTED must have equal cardinality"
);
}