use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::{ViewApp, ViewTab, render};
use crate::tui::test_support::{
AIBOM_BSI, DEMO_NEW, SIZES, aibom_single, demo_single, pin_theme, render_to_text,
};
use crate::tui::view::events::handle_key_event;
fn demo_view_app(active_tab: ViewTab) -> ViewApp {
pin_theme();
let (sbom, profile) = demo_single();
let mut app = ViewApp::new(sbom, DEMO_NEW, profile);
app.active_tab = active_tab;
app
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn render_tab(active_tab: ViewTab, width: u16, height: u16) -> String {
let mut app = demo_view_app(active_tab);
render_to_text(width, height, |frame| {
render(frame, &mut app);
})
}
const VIEW_TABS: [(&str, ViewTab); 8] = [
("overview", ViewTab::Overview),
("tree", ViewTab::Tree),
("vulnerabilities", ViewTab::Vulnerabilities),
("licenses", ViewTab::Licenses),
("dependencies", ViewTab::Dependencies),
("quality", ViewTab::Quality),
("compliance", ViewTab::Compliance),
("source", ViewTab::Source),
];
#[test]
fn snapshot_all_view_tabs() {
let mut settings = insta::Settings::clone_current();
settings.add_filter(
r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+\([^│\n]*",
"$1 (AGE)",
);
settings.add_filter(r"Age: \d+d", "Age: [N]d");
settings.bind(|| {
for (name, tab) in VIEW_TABS {
for (w, h) in SIZES {
let text = render_tab(tab, w, h);
insta::assert_snapshot!(format!("view_{name}_{w}x{h}"), text);
}
}
});
}
#[test]
fn tab_switch_cycles_within_profile() {
let mut app = demo_view_app(ViewTab::Overview);
handle_key_event(&mut app, key(KeyCode::Tab));
assert_eq!(app.active_tab, ViewTab::Tree);
handle_key_event(&mut app, key(KeyCode::Tab));
assert_eq!(app.active_tab, ViewTab::Dependencies);
handle_key_event(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT));
assert_eq!(app.active_tab, ViewTab::Tree);
}
#[test]
fn numeric_keys_jump_to_view_tab() {
let mut app = demo_view_app(ViewTab::Overview);
handle_key_event(&mut app, key(KeyCode::Char('3')));
assert_eq!(app.active_tab, ViewTab::Dependencies);
handle_key_event(&mut app, key(KeyCode::Char('5')));
assert_eq!(app.active_tab, ViewTab::Vulnerabilities);
handle_key_event(&mut app, key(KeyCode::Char('1')));
assert_eq!(app.active_tab, ViewTab::Overview);
}
#[test]
fn help_overlay_toggles() {
let mut app = demo_view_app(ViewTab::Overview);
handle_key_event(&mut app, key(KeyCode::Char('?')));
assert!(app.show_help);
handle_key_event(&mut app, key(KeyCode::Char('?')));
assert!(!app.show_help);
}
#[test]
fn help_overlay_closes_on_capital_k() {
let mut app = demo_view_app(ViewTab::Overview);
handle_key_event(&mut app, key(KeyCode::Char('?')));
assert!(app.show_help);
handle_key_event(&mut app, key(KeyCode::Char('K')));
assert!(
!app.show_help,
"'K' must close the help overlay as advertised"
);
}
#[test]
fn tree_search_entry_activates_filter() {
let mut app = demo_view_app(ViewTab::Tree);
assert!(!app.tree_search_active);
handle_key_event(&mut app, key(KeyCode::Char('/')));
assert!(
app.tree_search_active,
"'/' on the Tree tab starts the inline tree filter"
);
}
fn aibom_view_app(active_tab: ViewTab) -> ViewApp {
pin_theme();
let (sbom, profile) = aibom_single();
let mut app = ViewApp::new(sbom, AIBOM_BSI, profile);
app.active_tab = active_tab;
app
}
#[test]
fn aibom_fixture_detected_as_aibom_profile() {
let (_sbom, profile) = aibom_single();
assert_eq!(profile, crate::model::BomProfile::AiBom);
}
#[test]
fn aibom_view_app_uses_ai_readiness_scoring() {
let app = aibom_view_app(ViewTab::AiReadiness);
assert_eq!(
app.quality_report.profile,
crate::quality::ScoringProfile::AiReadiness
);
}
#[test]
fn aibom_profile_exposes_ai_tab_suite() {
let tabs = ViewTab::tabs_for_profile(crate::model::BomProfile::AiBom);
assert!(tabs.contains(&ViewTab::Models));
assert!(tabs.contains(&ViewTab::Datasets));
assert!(tabs.contains(&ViewTab::AiReadiness));
}
const AIBOM_TABS: [(&str, ViewTab); 3] = [
("models", ViewTab::Models),
("datasets", ViewTab::Datasets),
("ai_readiness", ViewTab::AiReadiness),
];
#[test]
fn snapshot_aibom_tabs() {
let mut settings = insta::Settings::clone_current();
settings.add_filter(r"Age: \d+d", "Age: [N]d");
settings.bind(|| {
for (name, tab) in AIBOM_TABS {
let mut app = aibom_view_app(tab);
let text = render_to_text(120, 40, |frame| {
render(frame, &mut app);
});
insta::assert_snapshot!(format!("aibom_{name}_120x40"), text);
}
});
}
#[test]
fn ai_readiness_header_uses_humanized_profile_label() {
let mut app = aibom_view_app(ViewTab::AiReadiness);
let text = render_to_text(120, 40, |frame| {
render(frame, &mut app);
});
assert!(
text.contains("Profile: AI Readiness"),
"header must humanize the profile name:\n{text}"
);
assert!(
!text.contains("Profile: AiReadiness"),
"raw enum literal must not leak into the header:\n{text}"
);
}
#[test]
fn aibom_overview_renders_ai_panels() {
let mut app = aibom_view_app(ViewTab::Overview);
let text = render_to_text(120, 40, |frame| {
render(frame, &mut app);
});
assert!(
text.contains("AI Inventory"),
"AI inventory panel missing:\n{text}"
);
assert!(
text.contains("Models: 1") && text.contains("Datasets: 1"),
"model/dataset counts missing:\n{text}"
);
assert!(
text.contains("AI Readiness"),
"AI readiness panel missing:\n{text}"
);
assert!(
text.contains("Checks passed:"),
"readiness summary missing:\n{text}"
);
assert!(text.contains("Components"), "stat cards missing:\n{text}");
}
const MISTYPED_MLBOM: &str =
include_str!("../../../../tests/fixtures/cyclonedx/mistyped-mlbom.cdx.json");
#[test]
fn models_tab_surfaces_mistyped_model_card_carriers() {
pin_theme();
let sbom = crate::parsers::parse_sbom_str(MISTYPED_MLBOM).expect("fixture must parse");
let mut app = ViewApp::new(sbom, MISTYPED_MLBOM, crate::model::BomProfile::AiBom);
app.active_tab = ViewTab::Models;
let text = render_to_text(120, 40, |frame| {
render(frame, &mut app);
});
assert!(
!text.contains("No machine-learning models found"),
"model-card carrier must not be hidden behind the empty state:\n{text}"
);
assert!(
text.contains("sentiment-model"),
"mistyped model missing from the list:\n{text}"
);
assert!(
text.contains("[typed: application]"),
"declared-type badge missing:\n{text}"
);
assert!(
text.contains("fix the 'type' field"),
"detail panel fix hint missing:\n{text}"
);
}
#[test]
fn compliance_help_bar_labels_grouping_toggle() {
let mut app = demo_view_app(ViewTab::Compliance);
let text = render_to_text(120, 40, |frame| {
render(frame, &mut app);
});
assert!(
text.contains("g group [Grouped]"),
"grouping hint must read 'g group [Grouped]':\n{text}"
);
handle_key_event(&mut app, key(KeyCode::Char('g')));
let text = render_to_text(120, 40, |frame| {
render(frame, &mut app);
});
assert!(
text.contains("g group [Flat]"),
"toggled grouping hint must read 'g group [Flat]':\n{text}"
);
}
#[test]
fn compliance_grouped_pattern_cells_elide() {
let mut app = demo_view_app(ViewTab::Compliance);
let text = render_to_text(120, 40, |frame| {
render(frame, &mut app);
});
assert!(
text.contains("supplier/manufa..."),
"clipped pattern cell must end in '...':\n{text}"
);
}
#[test]
fn tree_slash_is_labeled_filter() {
let mut app = demo_view_app(ViewTab::Tree);
let text = render_to_text(120, 40, |frame| {
render(frame, &mut app);
});
assert!(
text.contains("/ filter"),
"Tree footer must advertise '/ filter':\n{text}"
);
handle_key_event(&mut app, key(KeyCode::Char('/')));
for c in "ssl".chars() {
handle_key_event(&mut app, key(KeyCode::Char(c)));
}
let text = render_to_text(120, 40, |frame| {
render(frame, &mut app);
});
assert!(
text.contains("Filter: ssl"),
"inline bar must be labeled 'Filter:':\n{text}"
);
assert!(
!text.contains("Search: ssl"),
"inline bar must not be labeled 'Search:':\n{text}"
);
}
use crate::model::{
CanonicalId, Component, NormalizedSbom, Severity, VulnerabilityRef, VulnerabilitySource,
};
fn epss_kev_view_app() -> ViewApp {
pin_theme();
let mut sbom = NormalizedSbom::default();
let mut comp = Component::new("openssl".to_string(), "openssl-ref".to_string())
.with_version("3.0.0".to_string());
let mut vuln = VulnerabilityRef::new("CVE-2024-9999".to_string(), VulnerabilitySource::Nvd);
vuln.severity = Some(Severity::Critical);
vuln.is_kev = true;
vuln.epss_score = Some(0.84);
comp.vulnerabilities.push(vuln);
sbom.components
.insert(CanonicalId::from_name_version("openssl", None), comp);
let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Sbom);
app.active_tab = ViewTab::Vulnerabilities;
app
}
#[test]
fn vuln_explorer_renders_epss_badge() {
let mut app = epss_kev_view_app();
let text = render_to_text(120, 40, |frame| {
render(frame, &mut app);
});
assert!(
text.contains("EPSS 84%"),
"EPSS badge should render in the view-mode vuln explorer:\n{text}"
);
}
#[test]
fn kev_filter_key_toggles_and_renders_state() {
let mut app = epss_kev_view_app();
assert!(!app.vuln_state.filter_kev);
handle_key_event(&mut app, key(KeyCode::Char('K')));
assert!(app.vuln_state.filter_kev, "'K' enables the KEV-only filter");
let text = render_to_text(120, 40, |frame| {
render(frame, &mut app);
});
assert!(text.contains("CVE-2024-9999"), "KEV vuln survives filter");
assert!(text.contains("KEV:"), "filter bar shows KEV state");
assert!(
text.contains("[K]"),
"toolbar hint advertises the [K] toggle:\n{text}"
);
handle_key_event(&mut app, key(KeyCode::Char('K')));
assert!(
!app.vuln_state.filter_kev,
"'K' toggles the filter back off"
);
handle_key_event(&mut app, key(KeyCode::Char('k')));
assert!(!app.vuln_state.filter_kev, "'k' must never toggle KEV");
}
#[test]
fn compliance_selector_exposes_every_standard() {
use crate::quality::ComplianceLevel;
let levels = ComplianceLevel::all();
for (idx, level) in levels.iter().enumerate() {
let mut app = aibom_view_app(ViewTab::Compliance);
app.ensure_compliance_results();
app.compliance_state.selected_standard = idx;
let text = render_to_text(120, 40, |frame| {
render(frame, &mut app);
});
let label = level.short_name();
assert!(
text.contains(label),
"compliance standard `{label}` (index {idx}) must scroll into view:\n{text}"
);
}
assert!(levels.iter().any(|l| l.short_name() == "AI-Act"));
assert!(levels.iter().any(|l| l.short_name() == "BSI-AI"));
}
#[test]
fn compliance_overview_marks_na_standards_muted_not_green() {
use crate::quality::{
ComplianceLevel, ComplianceResult, Violation, ViolationCategory, ViolationSeverity,
};
fn na_marker(level: ComplianceLevel, rule_id: &'static str) -> ComplianceResult {
ComplianceResult::new(
level,
vec![Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::DocumentMetadata,
message: "SBOM contains no AI components".to_string(),
element: None,
requirement: "Applicability".to_string(),
rule_id,
component_id: None,
counts: None,
standard_refs: Vec::new(),
}],
)
}
let mut app = demo_view_app(ViewTab::Compliance);
let results: Vec<ComplianceResult> = ComplianceLevel::all()
.iter()
.map(|level| match level {
ComplianceLevel::EuAiAct => na_marker(*level, "SBOM-AIACT-NA"),
ComplianceLevel::BsiSbomForAi => na_marker(*level, "SBOM-BSIAI-NA"),
_ => ComplianceResult::new(*level, Vec::new()),
})
.collect();
let ai_idx = ComplianceLevel::all()
.iter()
.position(|l| *l == ComplianceLevel::EuAiAct)
.expect("EU AI Act must be in ComplianceLevel::all()");
assert!(!results[ai_idx].is_applicable(), "fixture must be N/A");
app.compliance_results = Some(results);
app.compliance_state.selected_standard = 0;
let text = render_to_text(120, 60, |frame| {
render(frame, &mut app);
});
let ai_line = text
.lines()
.find(|l| l.contains("EU AI Act Annex IV Readiness"))
.unwrap_or_else(|| panic!("overview must list the AI Act standard:\n{text}"))
.to_string();
assert!(
ai_line.contains("\u{2014}") && ai_line.contains("not applicable"),
"N/A standard must render muted em-dash + 'not applicable': {ai_line}"
);
assert!(
!ai_line.contains('\u{2713}')
&& !ai_line.contains("passed")
&& !ai_line.contains("warnings"),
"N/A standard must not render as a pass in the overview: {ai_line}"
);
}
#[test]
fn crypto_list_scrolls_the_selection_into_view() {
let (sbom, profile) = crate::tui::test_support::cbom_single();
let mut app = ViewApp::new(sbom, crate::tui::test_support::CBOM, profile);
app.active_tab = ViewTab::Crypto;
app.crypto_list_selected = 0;
let top = render_to_text(80, 24, |frame| render(frame, &mut app));
app.crypto_list_selected = usize::MAX; let scrolled = render_to_text(80, 24, |frame| render(frame, &mut app));
assert_ne!(
top, scrolled,
"selecting the last algorithm in a short panel must scroll it into view"
);
}
#[test]
fn component_detail_scroll_clamps_to_content() {
let mut app = demo_view_app(ViewTab::Tree);
render_to_text(80, 24, |frame| render(frame, &mut app));
handle_key_event(&mut app, key(KeyCode::Down));
handle_key_event(&mut app, key(KeyCode::Enter));
assert!(
app.get_selected_component().is_some(),
"Down+Enter must select the first leaf component"
);
app.component_detail_scroll = 999;
let text = render_to_text(80, 24, |frame| {
render(frame, &mut app);
});
assert!(
app.component_detail_scroll < 999,
"render must clamp the over-scrolled offset, got {}",
app.component_detail_scroll
);
assert!(
text.contains("fields"),
"clamped panel must still show content (the completeness line):
{text}"
);
}
#[test]
fn overview_tab_orders_risk_before_identifiers() {
pin_theme();
let mut sbom = NormalizedSbom::default();
let mut comp = Component::new("kev-lib".to_string(), "kev-ref".to_string())
.with_version("1.0.0".to_string());
let mut v = VulnerabilityRef::new("CVE-2024-0001".to_string(), VulnerabilitySource::Nvd);
v.severity = Some(Severity::Critical);
comp.vulnerabilities.push(v);
comp.eol = Some(crate::model::EolInfo {
status: crate::model::EolStatus::EndOfLife,
product: "kev-lib".to_string(),
cycle: "1.0".to_string(),
eol_date: None,
support_end_date: None,
is_lts: false,
latest_in_cycle: None,
latest_release_date: None,
days_until_eol: Some(-30),
});
comp.staleness = Some(crate::model::StalenessInfo {
level: crate::model::StalenessLevel::Stale,
last_published: None,
is_deprecated: false,
is_archived: false,
deprecation_message: None,
days_since_update: Some(700),
latest_version: None,
});
comp.identifiers.purl = Some("pkg:npm/kev-lib@1.0.0".to_string());
comp.hashes.push(crate::model::Hash::new(
crate::model::HashAlgorithm::Sha256,
"abc123".to_string(),
));
let id = CanonicalId::from_name_version("kev-lib", None);
sbom.components.insert(id.clone(), comp);
let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Sbom);
app.active_tab = ViewTab::Tree;
app.selected_component = Some(id.value().to_string());
app.focus_panel = crate::tui::view::app::FocusPanel::Right;
let text = render_to_text(120, 40, |frame| {
render(frame, &mut app);
});
assert!(text.contains("1 vulns"), "vuln badge must render:\n{text}");
assert!(
text.contains(crate::model::EolStatus::EndOfLife.label()),
"EOL badge must render:\n{text}"
);
assert!(
text.contains(crate::model::StalenessLevel::Stale.label()),
"staleness badge must render:\n{text}"
);
assert!(
!text.contains("no known risk signals"),
"all-clear line must not render alongside risk badges:\n{text}"
);
let vulns_at = text.find("Vulns:").expect("Vulns section must render");
let eol_at = text.find("End-of-Life:").expect("EOL section must render");
let purl_at = text.find("PURL:").expect("PURL must render");
assert!(
vulns_at < purl_at && eol_at < purl_at,
"risk sections must precede identifier plumbing:\n{text}"
);
assert!(
!text.contains("Hash:"),
"the hash belongs to the Identifiers tab, not Overview:\n{text}"
);
}
#[test]
fn view_tree_component_detail_80x24() {
let mut app = demo_view_app(ViewTab::Tree);
render_to_text(80, 24, |frame| render(frame, &mut app));
handle_key_event(&mut app, key(KeyCode::Down));
handle_key_event(&mut app, key(KeyCode::Enter));
let text = render_to_text(80, 24, |frame| {
render(frame, &mut app);
});
insta::assert_snapshot!("view_tree_component_detail_80x24", text);
}
#[test]
fn pqc_selection_scrolls_into_view() {
let (sbom, profile) = crate::tui::test_support::cbom_single();
let mut app = ViewApp::new(sbom, crate::tui::test_support::CBOM, profile);
app.active_tab = ViewTab::PqcCompliance;
app.pqc_selected = 0;
let top = render_to_text(80, 24, |frame| render(frame, &mut app));
app.go_last();
assert!(app.pqc_selected > 0, "fixture must have several algorithms");
let scrolled = render_to_text(80, 24, |frame| render(frame, &mut app));
assert_ne!(
top, scrolled,
"selecting the last algorithm must scroll it into view / update the detail pane"
);
}
#[test]
fn ai_readiness_scroll_reveals_rows_below_fold() {
let (sbom, profile) = crate::tui::test_support::aibom_single();
let mut app = ViewApp::new(sbom, AIBOM_BSI, profile);
app.active_tab = ViewTab::AiReadiness;
app.ai_readiness_scroll = 0;
let top = render_to_text(80, 24, |frame| render(frame, &mut app));
app.go_last();
assert!(
app.ai_readiness_scroll > 0,
"fixture must overflow the fold"
);
let scrolled = render_to_text(80, 24, |frame| render(frame, &mut app));
assert_ne!(
top, scrolled,
"scrolling must reveal checks/recommendations below the fold"
);
}
#[test]
fn view_list_click_selects_the_item_under_the_cursor() {
use crate::tui::view::events::handle_mouse_event;
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
let cases = [
(ViewTab::Tree, "axios@1.4.0", 1usize), (ViewTab::Licenses, "Apache-2.0", 1usize), (ViewTab::Dependencies, "axios@1.4.0", 1usize), ];
for (tab, needle, expected) in cases {
let mut app = demo_view_app(tab);
let text = render_to_text(80, 24, |frame| {
render(frame, &mut app);
});
let row = text
.lines()
.position(|line| line.contains(needle))
.unwrap_or_else(|| panic!("{needle} not rendered on {tab:?}")) as u16;
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 5,
row,
modifiers: KeyModifiers::empty(),
},
);
let selected = match tab {
ViewTab::Tree => app.tree_state.selected,
ViewTab::Licenses => app.license_state.selected,
ViewTab::Dependencies => app.dependency_state.selected,
_ => unreachable!(),
};
assert_eq!(selected, expected, "click {needle:?} on {tab:?} @row {row}");
}
}
#[test]
fn aibom_tab_click_selects_a_profile_specific_tab() {
use crate::tui::view::events::handle_mouse_event;
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use unicode_width::UnicodeWidthStr;
let tabs = ViewTab::tabs_for_profile(crate::model::BomProfile::AiBom);
let target = *tabs.last().expect("AI-BOM has tabs"); let first = tabs[0];
let mut app = aibom_view_app(first);
let text = render_to_text(240, 40, |frame| {
render(frame, &mut app);
});
let tab_row = text.lines().nth(2).expect("tab bar row").to_string();
let needle = target.title();
let byte = tab_row
.rfind(needle)
.unwrap_or_else(|| panic!("{needle} not in tab row: {tab_row:?}"));
let col = UnicodeWidthStr::width(&tab_row[..byte]) as u16;
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: col,
row: 2,
modifiers: KeyModifiers::empty(),
},
);
assert_eq!(app.active_tab, target, "click on {needle} @col {col}");
}
#[test]
fn gpl_risk_summary_renders_as_copyleft() {
pin_theme();
let mut sbom = NormalizedSbom::default();
let mut comp = Component::new("readline".to_string(), "readline-ref".to_string())
.with_version("8.2".to_string());
comp.licenses
.add_declared(crate::model::LicenseExpression::new(
"GPL-3.0-only".to_string(),
));
sbom.components
.insert(CanonicalId::from_name_version("readline", None), comp);
let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Sbom);
app.active_tab = ViewTab::Licenses;
let text = render_to_text(80, 24, |frame| {
render(frame, &mut app);
});
assert!(
text.contains("\u{26a0} 1"),
"GPL must count as copyleft in the risk summary:\n{text}"
);
assert!(
text.contains("? 0"),
"GPL must not fall into the unknown bucket:\n{text}"
);
}
#[test]
fn esc_never_quits_view_app() {
let mut app = demo_view_app(ViewTab::Tree);
handle_key_event(&mut app, key(KeyCode::Esc));
assert!(!app.should_quit, "Esc at top level must not quit");
app.focus_panel = crate::tui::view::app::FocusPanel::Right;
handle_key_event(&mut app, key(KeyCode::Esc));
assert!(!app.should_quit);
assert_eq!(
app.focus_panel,
crate::tui::view::app::FocusPanel::Left,
"Esc backs out of the right panel"
);
handle_key_event(&mut app, key(KeyCode::Char('q')));
assert!(app.should_quit, "'q' still quits");
}
#[test]
fn view_tree_filter_empty_state() {
let mut app = demo_view_app(ViewTab::Tree);
app.tree_filter = crate::tui::view::app::TreeFilter::Bookmarked;
let text = render_to_text(80, 24, |frame| {
render(frame, &mut app);
});
assert!(
text.contains("No components match filter 'Bookmarked'"),
"empty tree must explain the active filter:\n{text}"
);
assert!(
text.contains("[f] to change filter"),
"empty state must offer the recovery hint:\n{text}"
);
insta::assert_snapshot!("view_tree_filter_bookmarked_empty_80x24", text);
}
#[test]
fn filter_bar_hints_all_visible_at_80_cols() {
let mut app = epss_kev_view_app();
let text = render_to_text(80, 24, |frame| {
render(frame, &mut app);
});
assert!(
text.contains("[}/{] group jump"),
"the tail of the hint row must render at 80 cols:\n{text}"
);
assert!(
text.contains("[E/C]"),
"the expand/collapse hint must render at 80 cols:\n{text}"
);
}
#[test]
fn overview_severity_bars_visible_at_min_size() {
let mut app = demo_view_app(ViewTab::Overview);
let text = render_to_text(80, 24, |frame| {
render(frame, &mut app);
});
assert!(
text.contains("Critical"),
"severity bars must render at 80x24:\n{text}"
);
assert!(
text.contains("Vulnerability Severity"),
"severity section header must render at 80x24:\n{text}"
);
}
#[test]
fn view_overview_eol_compact_snapshot() {
let mut app = demo_view_app(ViewTab::Overview);
app.stats.eol_enriched = true;
app.stats.eol_count = 2;
app.stats.eol_approaching_count = 1;
app.stats.eol_security_only_count = 1;
app.stats.eol_supported_count = 8;
let text = render_to_text(80, 24, |frame| {
render(frame, &mut app);
});
assert!(
text.contains("End-of-Life"),
"EOL section must render in compact mode:\n{text}"
);
let mut settings = insta::Settings::clone_current();
settings.add_filter(
r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+\([^│\n]*",
"$1 (AGE)",
);
settings.bind(|| {
insta::assert_snapshot!("view_overview_eol_80x24", text);
});
}
fn cbom_view_app(active_tab: ViewTab) -> ViewApp {
pin_theme();
let (sbom, profile) = crate::tui::test_support::cbom_single();
let mut app = ViewApp::new(sbom, crate::tui::test_support::CBOM, profile);
app.active_tab = active_tab;
app
}
#[test]
fn snapshot_cbom_tabs() {
let mut settings = insta::Settings::clone_current();
settings.add_filter(r"Remaining: \d+ days *", "Remaining: [N] days ");
let _guard = settings.bind_to_scope();
let tabs = [
("crypto", ViewTab::Crypto),
("algorithms", ViewTab::Algorithms),
("certificates", ViewTab::Certificates),
("keys", ViewTab::Keys),
("protocols", ViewTab::Protocols),
("pqc_compliance", ViewTab::PqcCompliance),
];
for (name, tab) in tabs {
for (w, h) in SIZES {
let mut app = cbom_view_app(tab);
let text = render_to_text(w, h, |frame| {
render(frame, &mut app);
});
insta::assert_snapshot!(format!("cbom_{name}_{w}x{h}"), text);
}
}
}
#[test]
fn cbom_selected_row_uses_theme_selection_bg() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut app = cbom_view_app(ViewTab::Crypto);
app.crypto_list_selected = 0;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|frame| render(frame, &mut app))
.expect("render");
let buffer = terminal.backend().buffer();
let expected = crate::tui::theme::colors().selection;
let found = (0..24u16).any(|y| {
(0..80u16).any(|x| {
buffer
.cell((x, y))
.is_some_and(|c| c.style().bg == Some(expected))
})
});
assert!(
found,
"some cell must carry the theme selection background {expected:?}"
);
let darkgray = (0..24u16).any(|y| {
(0..80u16).any(|x| {
buffer
.cell((x, y))
.is_some_and(|c| c.style().bg == Some(ratatui::style::Color::DarkGray))
})
});
assert!(
!darkgray,
"no cell may use the hardcoded DarkGray selection background"
);
}
fn triage_view_app(active_tab: ViewTab) -> ViewApp {
pin_theme();
let mut sbom = NormalizedSbom::default();
let mut kev_comp = Component::new("kev-lib".to_string(), "kev-ref".to_string())
.with_version("1.0.0".to_string());
let mut kev_vuln = VulnerabilityRef::new("CVE-2024-0001".to_string(), VulnerabilitySource::Nvd);
kev_vuln.severity = Some(Severity::Critical);
kev_vuln.is_kev = true;
kev_vuln.epss_score = Some(0.73);
kev_comp.vulnerabilities.push(kev_vuln);
sbom.components
.insert(CanonicalId::from_name_version("kev-lib", None), kev_comp);
let mut fix_comp = Component::new("fix-lib".to_string(), "fix-ref".to_string())
.with_version("2.0.0".to_string());
let mut fix_vuln = VulnerabilityRef::new("CVE-2024-0002".to_string(), VulnerabilitySource::Nvd);
fix_vuln.severity = Some(Severity::High);
fix_vuln.remediation = Some(crate::model::Remediation {
remediation_type: crate::model::RemediationType::Upgrade,
description: None,
fixed_version: Some("2.1.0".to_string()),
});
fix_comp.vulnerabilities.push(fix_vuln);
sbom.components
.insert(CanonicalId::from_name_version("fix-lib", None), fix_comp);
let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Sbom);
app.active_tab = active_tab;
app
}
#[test]
fn view_vuln_triage_snapshots() {
for (w, h) in SIZES {
let mut app = triage_view_app(ViewTab::Vulnerabilities);
let text = render_to_text(w, h, |frame| {
render(frame, &mut app);
});
assert!(
text.contains("KEV 1") && text.contains("Fixable 1"),
"triage row must render at {w}x{h}:\n{text}"
);
insta::assert_snapshot!(format!("view_vuln_triage_{w}x{h}"), text);
}
}
#[test]
fn all_critical_sbom_uses_compact_stats() {
pin_theme();
let mut sbom = NormalizedSbom::default();
for i in 0..2 {
let name = format!("crit-{i}");
let mut comp =
Component::new(name.clone(), format!("{name}-ref")).with_version("1.0.0".to_string());
let mut v = VulnerabilityRef::new(format!("CVE-2024-1{i:03}"), VulnerabilitySource::Nvd);
v.severity = Some(Severity::Critical);
comp.vulnerabilities.push(v);
sbom.components
.insert(CanonicalId::from_name_version(&name, None), comp);
}
let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Sbom);
app.active_tab = ViewTab::Vulnerabilities;
let text = render_to_text(80, 24, |frame| {
render(frame, &mut app);
});
assert!(
!text.contains(" MEDIUM "),
"compact path must not render the empty MEDIUM card:\n{text}"
);
assert!(
text.contains("2 vulns") || text.contains("2 vulnerabilities"),
"compact summary must render UN-CLIPPED (the old assertion was \
satisfied by an unrelated '(1 vulnerabilities)' grammar bug while \
the card clipped '2 vulnerabiliti' mid-word):\n{text}"
);
}
#[test]
fn view_legend_overlay_80x30() {
let mut app = demo_view_app(ViewTab::Licenses);
app.toggle_legend();
let text = render_to_text(80, 30, |frame| {
render(frame, &mut app);
});
assert!(
text.contains("\u{2298}"),
"the full license vocabulary (incl. \u{2298} Proprietary) must be visible:\n{text}"
);
insta::assert_snapshot!("view_legend_overlay_80x30", text);
}
#[test]
fn view_vuln_compact_80x24_shows_full_triage_line() {
pin_theme();
let mut sbom = NormalizedSbom::default();
for i in 0..3 {
let name = format!("crit-{i}");
let mut comp =
Component::new(name.clone(), format!("{name}-ref")).with_version("1.0.0".to_string());
for j in 0..(3 - i) {
let mut v =
VulnerabilityRef::new(format!("CVE-2024-2{i}{j:02}"), VulnerabilitySource::Nvd);
v.severity = Some(Severity::Critical);
v.cvss.push(crate::model::CvssScore {
version: crate::model::CvssVersion::V31,
base_score: 9.8 - (i as f32).mul_add(0.3, j as f32 * 0.1),
vector: None,
exploitability_score: None,
impact_score: None,
});
if i == 0 && j == 0 {
v.is_kev = true;
v.epss_score = Some(0.42);
}
if i == 1 && j == 0 {
v.remediation = Some(crate::model::Remediation {
remediation_type: crate::model::RemediationType::Upgrade,
description: None,
fixed_version: Some("1.1.0".to_string()),
});
}
comp.vulnerabilities.push(v);
}
sbom.components
.insert(CanonicalId::from_name_version(&name, None), comp);
}
let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Sbom);
app.active_tab = ViewTab::Vulnerabilities;
let text = render_to_text(80, 24, |frame| {
render(frame, &mut app);
});
assert!(
text.contains("EPSS\u{2265}10% 1"),
"the EPSS triage count must survive 80 cols:\n{text}"
);
insta::assert_snapshot!("view_vuln_compact_80x24", text);
}
#[test]
fn crypto_header_surfaces_danger_counts_at_80_cols() {
use crate::model::{
AlgorithmProperties, CertificateProperties, CryptoAssetType, CryptoMaterialState,
CryptoMaterialType, CryptoPrimitive, CryptoProperties, RelatedCryptoMaterialProperties,
};
pin_theme();
let mut sbom = NormalizedSbom::default();
let mut add = |name: &str, cp: CryptoProperties| {
let mut c = Component::new(name.to_string(), format!("{name}-ref"));
c.component_type = crate::model::ComponentType::Cryptographic;
c.crypto_properties = Some(cp);
sbom.components
.insert(CanonicalId::from_name_version(name, None), c);
};
add(
"MD5",
CryptoProperties::new(CryptoAssetType::Algorithm)
.with_algorithm_properties(AlgorithmProperties::new(CryptoPrimitive::Hash)),
);
add(
"RSA-2048",
CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
AlgorithmProperties::new(CryptoPrimitive::Pke).with_nist_quantum_security_level(0),
),
);
add(
"expired-cert",
CryptoProperties::new(CryptoAssetType::Certificate).with_certificate_properties(
CertificateProperties::new()
.with_not_valid_after(chrono::Utc::now() - chrono::Duration::days(30)),
),
);
add(
"expiring-cert",
CryptoProperties::new(CryptoAssetType::Certificate).with_certificate_properties(
CertificateProperties::new()
.with_not_valid_after(chrono::Utc::now() + chrono::Duration::days(30)),
),
);
add(
"compromised-key",
CryptoProperties::new(CryptoAssetType::RelatedCryptoMaterial)
.with_related_crypto_material_properties(
RelatedCryptoMaterialProperties::new(CryptoMaterialType::PrivateKey)
.with_state(CryptoMaterialState::Compromised),
),
);
add(
"small-key",
CryptoProperties::new(CryptoAssetType::RelatedCryptoMaterial)
.with_related_crypto_material_properties(
RelatedCryptoMaterialProperties::new(CryptoMaterialType::PrivateKey)
.with_size(1024),
),
);
let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Cbom);
app.active_tab = ViewTab::Crypto;
let text = render_to_text(80, 24, |frame| {
render(frame, &mut app);
});
for token in [
"Compromised:1",
"Weak:1",
"QVuln:1",
"Expired:1",
"WeakKeys:1",
"Expiring:1",
] {
assert!(
text.contains(token),
"danger header must surface {token}:\n{text}"
);
}
}
#[test]
fn license_tab_surfaces_compat_conflicts() {
pin_theme();
let mut sbom = NormalizedSbom::default();
for (name, lic) in [("gpl-lib", "GPL-2.0-only"), ("apache-lib", "Apache-2.0")] {
let mut comp =
Component::new(name.to_string(), format!("{name}-ref")).with_version("1.0".to_string());
comp.licenses
.add_declared(crate::model::LicenseExpression::new(lic.to_string()));
sbom.components
.insert(CanonicalId::from_name_version(name, None), comp);
}
let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Sbom);
app.active_tab = ViewTab::Licenses;
let text = render_to_text(120, 40, |frame| {
render(frame, &mut app);
});
assert!(
text.contains("conflicts"),
"list header must count conflicts:\n{text}"
);
assert!(
text.contains("Compatibility"),
"detail panel must render the Compatibility section:\n{text}"
);
insta::assert_snapshot!("view_licenses_conflicts_120x40", text);
}
#[test]
fn empty_cbom_and_ai_tabs_use_enhanced_empty_state() {
pin_theme();
for (tab, reason) in [
(ViewTab::Crypto, "CycloneDX 1.6+"),
(ViewTab::Algorithms, "CycloneDX 1.6+"),
(ViewTab::Certificates, "CycloneDX 1.6+"),
(ViewTab::Keys, "CycloneDX 1.6+"),
(ViewTab::Protocols, "CycloneDX 1.6+"),
(ViewTab::PqcCompliance, "cryptoProperties"),
(ViewTab::Models, "machine-learning-model"),
(ViewTab::Datasets, "data components"),
] {
let profile = if matches!(tab, ViewTab::Models | ViewTab::Datasets) {
crate::model::BomProfile::AiBom
} else {
crate::model::BomProfile::Cbom
};
let mut app = ViewApp::new(NormalizedSbom::default(), "", profile);
app.active_tab = tab;
let text = render_to_text(80, 24, |frame| {
render(frame, &mut app);
});
assert!(
text.contains(reason),
"{tab:?} empty state must explain what data is required:\n{text}"
);
}
}
#[test]
fn snapshot_cbom_empty_algorithms() {
pin_theme();
let mut app = ViewApp::new(
NormalizedSbom::default(),
"",
crate::model::BomProfile::Cbom,
);
app.active_tab = ViewTab::Algorithms;
let text = render_to_text(80, 24, |frame| {
render(frame, &mut app);
});
insta::assert_snapshot!("cbom_empty_algorithms_80x24", text);
}
#[test]
fn view_global_search_ctrl_r_toggles_regex() {
pin_theme();
let mut app = demo_view_app(ViewTab::Overview);
handle_key_event(&mut app, key(KeyCode::Char('/')));
handle_key_event(
&mut app,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
);
assert_eq!(
app.search_state.mode,
crate::tui::app_states::SearchMode::Regex
);
for c in "ax.*s".chars() {
handle_key_event(&mut app, key(KeyCode::Char(c)));
}
assert!(
app.search_state
.results
.iter()
.any(|r| matches!(r, crate::tui::view::app::SearchResult::Component { name, .. } if name == "axios")),
"regex 'ax.*s' must match axios: {:?}",
app.search_state.results.len()
);
for _ in 0..5 {
handle_key_event(&mut app, key(KeyCode::Backspace));
}
handle_key_event(&mut app, key(KeyCode::Char('(')));
handle_key_event(&mut app, key(KeyCode::Char('(')));
assert!(app.search_state.search_error.is_some(), "error must be set");
assert!(app.search_state.results.is_empty());
let text = render_to_text(80, 24, |frame| {
render(frame, &mut app);
});
assert!(
text.contains("[regex]") && text.contains("invalid pattern"),
"badge + error must render:\n{text}"
);
insta::assert_snapshot!("view_search_overlay_error_80x24", text);
}
#[test]
fn view_help_overlay_scrolls_and_clamps() {
pin_theme();
let mut app = demo_view_app(ViewTab::Overview);
handle_key_event(&mut app, key(KeyCode::Char('?')));
assert!(app.show_help);
let text = render_to_text(80, 24, |frame| {
render(frame, &mut app);
});
assert!(
app.help_max_scroll > 0,
"the View shortcuts content must clip at 80x24 and write a scroll range back:\n{text}"
);
assert!(
text.contains("more"),
"the clipped overlay must advertise its hidden rows:\n{text}"
);
handle_key_event(&mut app, key(KeyCode::Char('j')));
assert_eq!(
app.help_scroll, 1,
"j must scroll the view help overlay down"
);
handle_key_event(&mut app, key(KeyCode::Up));
assert_eq!(app.help_scroll, 0, "Up must scroll back");
for _ in 0..500 {
handle_key_event(&mut app, key(KeyCode::Down));
}
assert_eq!(
app.help_scroll, app.help_max_scroll,
"scrolling must clamp to the measured ceiling, not run away"
);
handle_key_event(&mut app, key(KeyCode::Char('?')));
assert!(!app.show_help);
handle_key_event(&mut app, key(KeyCode::Char('?')));
assert_eq!(app.help_scroll, 0, "reopening must reset the scroll offset");
}
#[test]
fn view_ctrl_r_sets_search_mode_status() {
let mut app = demo_view_app(ViewTab::Overview);
handle_key_event(&mut app, key(KeyCode::Char('/')));
assert!(app.search_state.active);
let ctrl_r = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
handle_key_event(&mut app, ctrl_r);
assert_eq!(
app.status_message.as_deref(),
Some("Search mode: regex"),
"view-mode Ctrl+R must set the status message"
);
handle_key_event(&mut app, ctrl_r);
assert_eq!(
app.status_message.as_deref(),
Some("Search mode: substring"),
"toggling back must announce substring mode"
);
}
#[test]
fn overview_eol_enriched_keeps_stacked_panels_at_120x40() {
let mut app = demo_view_app(ViewTab::Overview);
app.stats.eol_enriched = true;
app.stats.eol_count = 2;
app.stats.eol_approaching_count = 1;
app.stats.eol_security_only_count = 1;
app.stats.eol_supported_count = 8;
let text = render_to_text(120, 40, |frame| {
render(frame, &mut app);
});
assert!(
text.contains(" End-of-Life Status ("),
"EOL-enriched 120x40 must render the stacked bordered EOL panel:\n{text}"
);
assert!(
text.contains(" Ecosystem Distribution "),
"EOL-enriched 120x40 must render the stacked Ecosystem panel:\n{text}"
);
assert!(
!text.contains("\u{2500}\u{2500} Vulnerability Severity \u{2500}\u{2500}"),
"120x40 with EOL data must not flap into the compact section-header layout:\n{text}"
);
}
#[test]
fn view_tab_marker_clicks_select_adjacent_hidden_tabs() {
use crate::tui::view::events::handle_mouse_event;
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use unicode_width::UnicodeWidthStr;
let click = |app: &mut ViewApp, column: u16| {
handle_mouse_event(
app,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row: 2,
modifiers: KeyModifiers::empty(),
},
);
};
let mut app = demo_view_app(ViewTab::Compliance);
let tabs = ViewTab::tabs_for_profile(app.bom_profile);
let _ = render_to_text(80, 24, |frame| render(frame, &mut app));
assert!(
app.tab_window.clipped_left,
"Compliance at 80 cols must clip earlier tabs: {:?}",
app.tab_window
);
let before = app.tab_window.start;
click(&mut app, 0); assert_eq!(
app.active_tab,
tabs[before - 1],
"\u{ab} click must select the tab just left of the window"
);
let mut app = demo_view_app(ViewTab::Overview);
let _ = render_to_text(80, 24, |frame| render(frame, &mut app));
let window = app.tab_window;
assert!(
window.clipped_right && window.end < tabs.len(),
"Overview at 80 cols must clip trailing tabs: {window:?}"
);
let mut cursor: u16 = if window.clipped_left { 2 } else { 0 };
for (i, tab) in tabs.iter().enumerate().take(window.end).skip(window.start) {
let label = format!("[{}] {} ", i + 1, tab.title());
cursor += UnicodeWidthStr::width(label.as_str()) as u16;
if i + 1 != window.end {
cursor += 3;
}
}
click(&mut app, cursor);
assert_eq!(
app.active_tab, tabs[window.end],
"\u{bb} click must select the tab just right of the window"
);
}
#[test]
fn pqc_detail_pane_overflow_marker_and_compliant_branch() {
let mut app = cbom_view_app(ViewTab::PqcCompliance);
app.pqc_selected = 7;
let text = render_to_text(80, 24, |frame| render(frame, &mut app));
assert!(
text.contains("\u{250c} X25519 "),
"detail pane must be titled for the selected algorithm (fixture index drift?):\n{text}"
);
assert!(
text.contains("more lines \u{2014} press e to export the full report"),
"overflowing violation detail must render the explicit truncation marker \
(pointing at export — the CBOM profile has no Compliance tab) instead of \
clipping silently:\n{text}"
);
let mut app = cbom_view_app(ViewTab::PqcCompliance);
app.pqc_selected = 5;
let text = render_to_text(80, 24, |frame| render(frame, &mut app));
assert!(
text.contains("\u{250c} AES-256-GCM "),
"detail pane must be titled for the selected algorithm (fixture index drift?):\n{text}"
);
assert!(
text.contains("Compliant \u{2014} no violations for this algorithm"),
"violation-free algorithms must render the Compliant branch:\n{text}"
);
assert!(
!text.contains("more lines"),
"the truncation marker must not render when the detail fits:\n{text}"
);
}
#[test]
fn pqc_cnsa_cell_ignores_non_error_violations() {
use crate::quality::{
ComplianceLevel, ComplianceResult, Violation, ViolationCategory, ViolationSeverity,
};
pin_theme();
let algo_x_app = |severity: ViolationSeverity| -> ViewApp {
let mut sbom = NormalizedSbom::default();
let mut comp = Component::new("ALGO-X".to_string(), "algo-x-ref".to_string());
comp.component_type = crate::model::ComponentType::Cryptographic;
comp.crypto_properties = Some(crate::model::CryptoProperties::new(
crate::model::CryptoAssetType::Algorithm,
));
sbom.components
.insert(CanonicalId::from_name_version("ALGO-X", None), comp);
let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Cbom);
app.active_tab = ViewTab::PqcCompliance;
app.compliance_results = Some(vec![
ComplianceResult::new(
ComplianceLevel::Cnsa2,
vec![Violation {
severity,
category: ViolationCategory::CryptographyInfo,
message: "advisory note about parameter choice".to_string(),
element: Some("ALGO-X".to_string()),
requirement: "advisory requirement".to_string(),
rule_id: "TEST-CNSA-PROBE",
component_id: None,
counts: None,
standard_refs: Vec::new(),
}],
),
ComplianceResult::new(ComplianceLevel::NistPqc, vec![]),
]);
app
};
let mut app = algo_x_app(ViolationSeverity::Warning);
let text = render_to_text(80, 24, |frame| render(frame, &mut app));
assert!(
text.contains("ALGO-X"),
"precondition: the algorithm row must render:\n{text}"
);
assert!(
text.contains("PASS"),
"the CNSA/NIST cells must show PASS for a Warning-only violation:\n{text}"
);
assert!(
!text.contains("FAIL"),
"a Warning-severity CNSA violation must not flip the cell to FAIL:\n{text}"
);
assert!(
!text.contains("NON-COMPLIANT"),
"a Warning must not flip the header verdict:\n{text}"
);
let mut app = algo_x_app(ViolationSeverity::Error);
let text = render_to_text(80, 24, |frame| render(frame, &mut app));
assert!(
text.contains("FAIL"),
"an Error-severity CNSA violation naming the algorithm must flip the cell to FAIL:\n{text}"
);
}
#[test]
fn ai_readiness_go_last_scrolls_to_checks_bound() {
let mut app = aibom_view_app(ViewTab::AiReadiness);
app.go_last();
assert_eq!(
app.ai_readiness_scroll, 10,
"max scroll must be the LONGER list (11 checks) - 1, not 4 recommendations - 1"
);
let text = render_to_text(120, 40, |frame| render(frame, &mut app));
assert!(
text.lines()
.any(|l| l.contains("AI-011") && l.contains('%')),
"at max scroll the checks TABLE must reveal the last check row (AI-011 with its weight cell):\n{text}"
);
assert!(
!text.lines().any(|l| l.contains("AI-001")),
"at max scroll the first check row must have scrolled off (offset not driving the table?):\n{text}"
);
}
#[test]
fn ai_readiness_rec_pane_clamps_to_its_own_length_at_max_scroll() {
let mut app = aibom_view_app(ViewTab::AiReadiness);
app.go_last(); let text = render_to_text(120, 40, |frame| render(frame, &mut app));
assert!(
text.contains("Recommendations (4)"),
"precondition: the recommendations pane must render:\n{text}"
);
assert!(
text.contains("[P2] [Completeness] [AI-011]"),
"the recommendations pane must still show content at max checks scroll \
(an unclamped shared offset of 10 blanks the 9-line pane):\n{text}"
);
}
#[test]
fn aibom_quality_tab_scroll_offset_drives_checks_table() {
let mut app = aibom_view_app(ViewTab::Quality);
let top = render_to_text(120, 40, |frame| render(frame, &mut app));
assert!(
top.contains("AI Readiness Checks"),
"AI-BOM Quality tab must route to the AI-readiness renderer:\n{top}"
);
assert!(
!top.contains("AI-010"),
"precondition: AI-010 must sit below the checks-table fold at offset 0:\n{top}"
);
app.quality_state.scroll_offset = 8;
let scrolled = render_to_text(120, 40, |frame| render(frame, &mut app));
assert_ne!(
top, scrolled,
"quality_state.scroll_offset must drive the AI-BOM Quality checks table (hardcoded 0 regression)"
);
assert!(
scrolled.contains("AI-010"),
"offset 8 must reveal the below-the-fold AI-010 check row:\n{scrolled}"
);
}
#[test]
fn crypto_header_renders_all_clear_when_no_danger_metrics() {
use crate::model::{AlgorithmProperties, CryptoAssetType, CryptoPrimitive, CryptoProperties};
pin_theme();
let mut sbom = NormalizedSbom::default();
let mut comp = Component::new("ML-KEM-1024".to_string(), "ml-kem-ref".to_string());
comp.component_type = crate::model::ComponentType::Cryptographic;
comp.crypto_properties = Some(
CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
AlgorithmProperties::new(CryptoPrimitive::Kem).with_nist_quantum_security_level(5),
),
);
sbom.components
.insert(CanonicalId::from_name_version("ML-KEM-1024", None), comp);
let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Cbom);
app.active_tab = ViewTab::Crypto;
let text = render_to_text(80, 24, |frame| render(frame, &mut app));
assert!(
text.contains("No crypto risk flags"),
"an all-clear CBOM must render the success line, not an empty first header row:\n{text}"
);
for token in [
"Compromised:",
"Weak:",
"QVuln:",
"Expired:",
"WeakKeys:",
"Expiring:",
"HybridPQC:",
] {
assert!(
!text.contains(token),
"zero-count danger token {token} must be omitted from the header:\n{text}"
);
}
assert!(
text.contains("Quantum:"),
"the neutral counts line must still render under the all-clear line:\n{text}"
);
}
use crate::tui::view::events::get_yank_text;
fn two_group_vuln_app() -> ViewApp {
pin_theme();
let mut sbom = NormalizedSbom::default();
for (name, cve, score, sev) in [
("log4j", "CVE-2021-44228", 10.0_f32, Severity::Critical),
("lodash", "CVE-2021-23337", 7.2_f32, Severity::High),
] {
let mut comp =
Component::new(name.to_string(), format!("{name}-ref")).with_version("1.0.0".into());
let mut vuln = VulnerabilityRef::new(cve.to_string(), VulnerabilitySource::Nvd);
vuln.severity = Some(sev);
vuln.cvss.push(crate::model::CvssScore::new(
crate::model::CvssVersion::V31,
score,
));
vuln.affected_versions.push("1.0.0".to_string());
comp.vulnerabilities.push(vuln);
sbom.components
.insert(CanonicalId::from_name_version(name, None), comp);
}
let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Sbom);
app.active_tab = ViewTab::Vulnerabilities;
app
}
#[test]
fn yank_follows_displayed_selection_on_licenses() {
let mut app = demo_view_app(ViewTab::Licenses);
render_to_text(120, 40, |frame| render(frame, &mut app));
assert_eq!(get_yank_text(&app).as_deref(), Some("MIT"));
handle_key_event(&mut app, key(KeyCode::Char('j')));
assert_eq!(get_yank_text(&app).as_deref(), Some("Apache-2.0"));
handle_key_event(&mut app, key(KeyCode::Char('j')));
assert_eq!(
get_yank_text(&app).as_deref(),
Some("Unknown"),
"the synthetic Unknown bucket is part of the display list and must be yankable"
);
}
#[test]
fn yank_follows_displayed_selection_on_vulnerabilities() {
let mut app = two_group_vuln_app();
render_to_text(120, 40, |frame| render(frame, &mut app));
let first = get_yank_text(&app).expect("group header row must be yankable");
assert!(
first.contains("log4j"),
"group-header yank must be the group label, got {first:?}"
);
handle_key_event(&mut app, key(KeyCode::Char('E')));
render_to_text(120, 40, |frame| render(frame, &mut app));
handle_key_event(&mut app, key(KeyCode::Char('j')));
assert_eq!(
get_yank_text(&app).as_deref(),
Some("CVE-2021-44228"),
"vuln-row yank must be the displayed row's CVE id"
);
}
#[test]
fn yank_follows_displayed_selection_on_cbom_tabs() {
let (sbom, profile) = crate::tui::test_support::cbom_single();
let mut app = ViewApp::new(sbom, crate::tui::test_support::CBOM, profile);
use crate::model::{ComponentType, CryptoAssetType};
let mut algo_names: Vec<String> = app
.sbom
.components
.values()
.filter(|c| {
c.component_type == ComponentType::Cryptographic
&& c.crypto_properties
.as_ref()
.is_some_and(|cp| cp.asset_type == CryptoAssetType::Algorithm)
})
.map(|c| c.name.clone())
.collect();
algo_names.sort();
assert!(
algo_names.len() >= 2,
"fixture must have several algorithms"
);
app.active_tab = ViewTab::Algorithms;
render_to_text(120, 40, |frame| render(frame, &mut app));
assert_eq!(get_yank_text(&app).as_deref(), Some(algo_names[0].as_str()));
handle_key_event(&mut app, key(KeyCode::Char('j')));
assert_eq!(get_yank_text(&app).as_deref(), Some(algo_names[1].as_str()));
app.active_tab = ViewTab::Keys;
render_to_text(120, 40, |frame| render(frame, &mut app));
let yanked = get_yank_text(&app).expect("keys tab has a selection");
let is_key_material = app.sbom.components.values().any(|c| {
c.name == yanked
&& c.crypto_properties
.as_ref()
.is_some_and(|cp| cp.asset_type == CryptoAssetType::RelatedCryptoMaterial)
});
assert!(
is_key_material,
"Keys-tab yank must name key material, got {yanked:?}"
);
}
#[test]
fn yank_on_tree_group_row_copies_group_label() {
let mut app = demo_view_app(ViewTab::Tree);
render_to_text(120, 40, |frame| render(frame, &mut app));
assert_eq!(get_yank_text(&app).as_deref(), Some("npm"));
}
#[test]
fn e_exports_and_x_folds_on_dependencies() {
let mut app = demo_view_app(ViewTab::Dependencies);
render_to_text(120, 40, |frame| render(frame, &mut app));
handle_key_event(&mut app, key(KeyCode::Char('e')));
assert!(app.show_export, "'e' on Dependencies must open export");
handle_key_event(&mut app, key(KeyCode::Esc));
assert!(!app.show_export);
handle_key_event(&mut app, key(KeyCode::Char('x')));
assert!(
!app.dependency_state.expanded.is_empty(),
"'x' expands all dependency nodes"
);
handle_key_event(&mut app, key(KeyCode::Char('X')));
assert!(
app.dependency_state.expanded.is_empty(),
"'X' collapses all dependency nodes"
);
}
#[test]
fn group_jump_keys_work_and_tab_still_switches_tabs() {
let mut app = two_group_vuln_app();
render_to_text(120, 40, |frame| render(frame, &mut app));
assert_eq!(app.vuln_state.selected, 0);
handle_key_event(&mut app, key(KeyCode::Char('}')));
assert_eq!(
app.vuln_state.selected, 1,
"}} jumps to the next group header"
);
handle_key_event(&mut app, key(KeyCode::Char('{')));
assert_eq!(app.vuln_state.selected, 0, "{{ jumps back");
handle_key_event(&mut app, key(KeyCode::Tab));
assert_ne!(
app.active_tab,
ViewTab::Vulnerabilities,
"Tab must still switch app tabs on the Vulnerabilities tab"
);
}
#[test]
fn legend_closes_on_any_key() {
let mut app = demo_view_app(ViewTab::Tree);
handle_key_event(&mut app, key(KeyCode::Char('l')));
assert!(app.show_legend);
handle_key_event(&mut app, key(KeyCode::Char('z')));
assert!(!app.show_legend, "any key must close the legend");
}
#[test]
fn vuln_empty_state_is_neutral_not_celebratory() {
pin_theme();
let mut sbom = NormalizedSbom::default();
let comp = Component::new("leftpad".to_string(), "leftpad-ref".to_string())
.with_version("1.0.0".to_string());
sbom.components
.insert(CanonicalId::from_name_version("leftpad", None), comp);
let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Sbom);
app.active_tab = ViewTab::Vulnerabilities;
let text = render_to_text(120, 40, |frame| render(frame, &mut app));
assert!(
text.contains("No vulnerability data in this document"),
"empty state must describe missing DATA:\n{text}"
);
assert!(
text.contains("enrich"),
"empty state must point at the enrich/VEX remedy:\n{text}"
);
assert!(
!text.contains("Great news"),
"celebratory wording must be gone:\n{text}"
);
}
#[test]
fn group_max_cvss_and_fix_column_are_honest() {
let mut app = two_group_vuln_app();
let text = render_to_text(120, 40, |frame| render(frame, &mut app));
assert!(
!text.contains("max:"),
"the clipped-prone 'max:' suffix must not render:\n{text}"
);
assert!(
text.contains("10.0"),
"the group's max CVSS must render in the CVSS column:\n{text}"
);
handle_key_event(&mut app, key(KeyCode::Char('E')));
let text = render_to_text(120, 40, |frame| render(frame, &mut app));
let row = text
.lines()
.find(|l| l.contains("CVE-2021-44228") && l.contains('\u{2500}'))
.unwrap_or_else(|| panic!("expanded vuln row must render:\n{text}"));
assert!(
row.contains('\u{2014}'),
"no-fix vulns must show an em-dash under Fix, got: {row}"
);
assert!(
!row.contains("1.0.0"),
"the AFFECTED version must not masquerade as a fix: {row}"
);
}
#[test]
fn licenses_summary_strip_labels_units() {
let mut app = demo_view_app(ViewTab::Licenses);
let text = render_to_text(120, 40, |frame| render(frame, &mut app));
assert!(
text.contains("components: "),
"risk glyphs must be labelled as component counts:\n{text}"
);
assert!(
text.contains("3 licenses"),
"the right side must be labelled as a license count:\n{text}"
);
}
#[test]
fn tree_stats_by_type_uses_declared_types() {
let mut app = demo_view_app(ViewTab::Tree);
render_to_text(120, 40, |frame| render(frame, &mut app));
handle_key_event(&mut app, key(KeyCode::Char('j')));
let text = render_to_text(120, 40, |frame| render(frame, &mut app));
assert!(
text.contains("Libraries"),
"declared library components must bucket as Libraries:\n{text}"
);
assert!(
!text.contains("Other Files"),
"npm components must not fall into the filename heuristic:\n{text}"
);
}
#[test]
fn overview_distributions_are_labelled_and_complete() {
let mut app = demo_view_app(ViewTab::Overview);
let text = render_to_text(120, 40, |frame| render(frame, &mut app));
assert!(
text.contains("npm"),
"the only ecosystem must render its own bar:\n{text}"
);
assert!(
text.contains("2") && text.contains("Unique Licenses"),
"unique-license count must exclude the Unknown bucket:\n{text}"
);
assert!(
text.contains("unlicensed:"),
"unlicensed components get their own labelled line:\n{text}"
);
}
#[test]
fn model_detail_resolves_dataset_refs_and_scrolls() {
let mut app = aibom_view_app(ViewTab::Models);
let text = render_to_text(120, 40, |frame| render(frame, &mut app));
assert!(
text.contains("product-reviews-1M"),
"dataset bom-ref must resolve to the component name:\n{text}"
);
assert!(
text.contains("Model Card"),
"section header must be contextual, not 'ML / Dataset':\n{text}"
);
assert!(
!text.contains("ML / Dataset"),
"generic section header must be gone:\n{text}"
);
let before = render_to_text(80, 24, |frame| render(frame, &mut app));
handle_key_event(&mut app, key(KeyCode::Char('J')));
let after = render_to_text(80, 24, |frame| render(frame, &mut app));
assert_ne!(before, after, "J must scroll the model detail pane");
}