test(tui): add off-screen render coverage #93
@@ -2049,6 +2049,7 @@ mod tests {
|
||||
use irtt_client::{
|
||||
ClientTimestamp, OneWayDelaySample, PacketMeta, RttSample, ServerTiming, WarningKind,
|
||||
};
|
||||
use ratatui::backend::TestBackend;
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
sync::Arc,
|
||||
@@ -2935,4 +2936,353 @@ mod tests {
|
||||
assert_eq!(graph.server_to_client_ns, Some(30_000));
|
||||
|
|
||||
assert_eq!(graph.server_processing_ns, Some(100_000));
|
||||
}
|
||||
|
||||
// Off-screen render coverage. These exercise draw_dashboard through a real
|
||||
// Ratatui backend, so layout selection, panel presence, and the text a user
|
||||
// actually sees are protected rather than only the pure state and viewport
|
||||
// arithmetic covered above.
|
||||
|
||||
/// Render `state` at the given terminal size and return the visible rows.
|
||||
fn render_rows(state: &TuiState, width: u16, height: u16) -> Vec<String> {
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||
terminal.draw(|frame| draw_dashboard(frame, state)).unwrap();
|
||||
let buffer = terminal.backend().buffer().clone();
|
||||
(0..buffer.area.height)
|
||||
.map(|y| {
|
||||
(0..buffer.area.width)
|
||||
.map(|x| buffer.cell((x, y)).map(|cell| cell.symbol()).unwrap_or(" "))
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_text(state: &TuiState, width: u16, height: u16) -> String {
|
||||
render_rows(state, width, height).join("\n")
|
||||
}
|
||||
|
||||
/// Whether a bordered panel with this title is on screen.
|
||||
///
|
||||
/// A panel is identified by its title in a top border, so this cannot be
|
||||
/// satisfied by body text that happens to mention the same word.
|
||||
fn has_panel(rows: &[String], title: &str) -> bool {
|
||||
let needle = format!("\u{250c}{title}");
|
||||
rows.iter().any(|row| row.contains(&needle))
|
||||
}
|
||||
|
||||
/// The interior rows of one bordered panel, sliced to that panel's columns.
|
||||
///
|
||||
/// Panels sit side by side, so a whole-row search matches text belonging to
|
||||
/// a neighbour, and most panel content is echoed somewhere else on the
|
||||
/// frame. Scoping assertions to the owning panel is what makes them
|
||||
/// protect the panel they name.
|
||||
fn panel_rows(rows: &[String], title: &str) -> Vec<String> {
|
||||
let needle: Vec<char> = format!("\u{250c}{title}").chars().collect();
|
||||
let (top, start) = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(index, row)| {
|
||||
let chars: Vec<char> = row.chars().collect();
|
||||
chars
|
||||
.windows(needle.len())
|
||||
.position(|window| window == needle.as_slice())
|
||||
.map(|column| (index, column))
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no panel titled {title} in:\n{}", rows.join("\n")));
|
||||
let title_row: Vec<char> = rows[top].chars().collect();
|
||||
let end = title_row[start..]
|
||||
.iter()
|
||||
.position(|glyph| *glyph == '\u{2510}')
|
||||
.map(|offset| start + offset)
|
||||
.expect("a panel top border ends with a corner");
|
||||
rows[top + 1..]
|
||||
.iter()
|
||||
.map(|row| {
|
||||
row.chars()
|
||||
.skip(start)
|
||||
.take(end - start + 1)
|
||||
.collect::<String>()
|
||||
})
|
||||
.take_while(|row| !row.starts_with('\u{2514}'))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether any interior row of `title`'s panel contains `text`.
|
||||
fn panel_contains(rows: &[String], title: &str, text: &str) -> bool {
|
||||
panel_rows(rows, title).iter().any(|row| row.contains(text))
|
||||
}
|
||||
|
||||
/// Assert every target owns a row in the target table, scoped to that
|
||||
/// panel's columns.
|
||||
///
|
||||
/// A whole-frame search cannot prove this: the first target is also named
|
||||
/// in the session header, and a target that replied appears in recent
|
||||
/// events and as a chart series.
|
||||
fn assert_target_table_rows(rows: &[String], labels: &[&str], replied: &str) {
|
||||
let table = panel_rows(rows, "targets");
|
||||
let row_for = |label: &str| {
|
||||
table
|
||||
.iter()
|
||||
.find(|row| {
|
||||
row.trim_start_matches('\u{2502}')
|
||||
.trim_start()
|
||||
.starts_with(label)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
panic!("no target table row for {label} in:\n{}", table.join("\n"))
|
||||
})
|
||||
.clone()
|
||||
};
|
||||
for label in labels {
|
||||
let row = row_for(label);
|
||||
assert!(
|
||||
row.contains("opening"),
|
||||
"target row for {label} lost its status column: {row}"
|
||||
);
|
||||
}
|
||||
// The target that replied shows its last sample.
|
||||
let replied_row = row_for(replied);
|
||||
assert!(
|
||||
replied_row.contains("3.0ms"),
|
||||
"{replied} row lost its last sample: {replied_row}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A single-target state carrying a session, one reply, and one warning.
|
||||
///
|
||||
/// The TUI opens in the graph view, so dashboard tests toggle it.
|
||||
fn active_single_target_state() -> TuiState {
|
||||
let mut state = TuiState::default();
|
||||
let target = target_instance("target");
|
||||
state.process_target_event(
|
||||
&target,
|
||||
&ClientEvent::SessionStarted {
|
||||
remote: remote(),
|
||||
token: 0xabcd,
|
||||
negotiated: NegotiatedParams {
|
||||
params: irtt_proto::Params::default(),
|
||||
restrictions: Vec::new(),
|
||||
},
|
||||
at: ts(Duration::ZERO),
|
||||
},
|
||||
);
|
||||
state.process_target_event(&target, &reply(11, 2_500_000));
|
||||
state.process_target_event(
|
||||
&target,
|
||||
&ClientEvent::Warning {
|
||||
kind: WarningKind::WrongToken,
|
||||
message: "wrong token".to_owned(),
|
||||
at: ts(Duration::ZERO),
|
||||
},
|
||||
);
|
||||
state
|
||||
}
|
||||
|
||||
fn active_dashboard_state() -> TuiState {
|
||||
let mut state = active_single_target_state();
|
||||
state.toggle_view();
|
||||
assert_eq!(state.view, TuiView::Dashboard);
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_terminal_below_the_minimum_width_renders_only_the_resize_notice() {
|
||||
let state = active_dashboard_state();
|
||||
|
||||
let text = render_text(&state, MIN_WIDTH - 1, MIN_HEIGHT);
|
||||
|
||||
assert!(text.contains("terminal too small"));
|
||||
assert!(text.contains("press q / Ctrl-C to quit"));
|
||||
assert!(!text.contains("packets"));
|
||||
assert!(!text.contains("q quit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_terminal_below_the_minimum_height_renders_only_the_resize_notice() {
|
||||
let state = active_dashboard_state();
|
||||
|
||||
let text = render_text(&state, MIN_WIDTH, MIN_HEIGHT - 1);
|
||||
|
||||
assert!(text.contains("terminal too small"));
|
||||
assert!(!text.contains("packets"));
|
||||
assert!(!text.contains("q quit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_smallest_usable_terminal_renders_the_compact_dashboard() {
|
||||
let state = active_dashboard_state();
|
||||
|
||||
let rows = render_rows(&state, MIN_WIDTH, MIN_HEIGHT);
|
||||
let text = rows.join("\n");
|
||||
|
If the short-terminal path regresses to rendering the resize notice together with ordinary dashboard content while still omitting the status line, this test continues to pass because it only checks for the notice and the absence of AGENTS.md reference: AGENTS.md:L85-L89 Useful? React with 👍 / 👎. **<sub><sub></sub></sub> Assert that dashboard panels are absent below minimum height**
If the short-terminal path regresses to rendering the resize notice together with ordinary dashboard content while still omitting the status line, this test continues to pass because it only checks for the notice and the absence of `q quit`. The corresponding minimum-width test explicitly rejects the packet panel; add an equivalent negative dashboard-panel assertion here so the test actually protects its stated resize-notice-only behavior.
AGENTS.md reference: [AGENTS.md:L85-L89](https://github.com/Lochnair/irtt-rs/blob/2062535c20459a097a098d664a8f75e17c9cfcff/AGENTS.md#L85-L89)
Useful? React with 👍 / 👎.
Agreed — the minimum-height case now rejects dashboard content ( 🤖 Addressed by Claude Code Agreed — the minimum-height case now rejects dashboard content (`packets`) as the minimum-width case does.
_🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
|
||||
|
||||
assert!(!text.contains("terminal too small"));
|
||||
assert!(text.contains("session"));
|
||||
assert!(text.contains("packets"));
|
||||
assert!(rows[usize::from(MIN_HEIGHT) - 2].contains("q quit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_compact_terminal_renders_the_header_packet_and_status_rows() {
|
||||
let state = active_dashboard_state();
|
||||
|
||||
let rows = render_rows(&state, 80, 24);
|
||||
let text = rows.join("\n");
|
||||
|
||||
assert!(text.contains("irtt-rs"));
|
||||
assert!(text.contains("session"));
|
||||
assert!(text.contains("packets"));
|
||||
assert!(text.contains("sent"));
|
||||
// The compact layout drops the timing, recent-events, and sample panels
|
||||
// that only fit in the large layout.
|
||||
assert!(!text.contains("recent events"));
|
||||
assert!(!text.contains("last warning:"));
|
||||
assert!(!text.contains("timing"));
|
||||
// The status line is the bottom bordered row of the layout.
|
||||
assert!(rows[22].contains("q quit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_terminal_renders_every_dashboard_panel() {
|
||||
let state = active_dashboard_state();
|
||||
|
||||
let rows = render_rows(&state, 180, 44);
|
||||
let text = rows.join("\n");
|
||||
|
||||
for expected in [
|
||||
"session",
|
||||
"packets",
|
||||
"timing",
|
||||
"recent events",
|
||||
"sample",
|
||||
"effective RTT",
|
||||
"send delay",
|
||||
"server process",
|
||||
] {
|
||||
assert!(text.contains(expected), "missing {expected} in:\n{text}");
|
||||
}
|
||||
assert!(rows[42].contains("q quit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_sample_data_and_warnings_are_visible_in_the_large_layout() {
|
||||
let state = active_dashboard_state();
|
||||
|
||||
let rows = render_rows(&state, 140, 40);
|
||||
|
||||
// Session identity, on the header panel's own rows: the token is also
|
||||
// echoed by the recent-events panel.
|
||||
assert!(panel_contains(&rows, "session", "session: 0xabcd"));
|
||||
assert!(panel_contains(&rows, "session", "remote: 127.0.0.1:2112"));
|
||||
// The most recent sample, on the sample panel's own rows.
|
||||
assert!(panel_contains(&rows, "sample", "last seq: 11"));
|
||||
let sample = panel_rows(&rows, "sample");
|
||||
let sample_metrics = sample
|
||||
.iter()
|
||||
.find(|row| row.contains("raw / adjusted / effective:"))
|
||||
.expect("the sample panel renders a metric row");
|
||||
// The whole row, so a regression in any one field is caught: the
|
||||
// fixture's adjusted and effective RTT share a value, and asserting
|
||||
// "2.5ms" alone would still pass with the effective field dropped.
|
||||
assert!(
|
||||
sample_metrics.contains("raw / adjusted / effective: 1.5ms / 2.5ms / 2.5ms"),
|
||||
"unexpected sample metric row: {sample_metrics}"
|
||||
);
|
||||
// The warning reaches the recent-events panel as an event line: the
|
||||
// sample panel's own last-warning row would otherwise satisfy a loose
|
||||
// "warning" plus "wrong token" search.
|
||||
assert!(panel_contains(
|
||||
&rows,
|
||||
"recent events",
|
||||
"warning WrongToken: wrong token"
|
||||
));
|
||||
// ...and the sample panel's last-warning row carries the warning
|
||||
// itself, not the absent-value placeholder.
|
||||
let last_warning = sample
|
||||
.iter()
|
||||
.find(|row| row.contains("last warning:"))
|
||||
.expect("the sample panel renders a last-warning row");
|
||||
assert!(
|
||||
last_warning.contains("wrong token"),
|
||||
"last-warning row did not carry the warning: {last_warning}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_run_renders_the_error_status_and_message() {
|
||||
let mut state = active_dashboard_state();
|
||||
state.set_error("open timed out".to_owned());
|
||||
|
||||
let rows = render_rows(&state, 140, 40);
|
||||
let text = rows.join("\n");
|
||||
|
||||
assert!(text.contains("open timed out"));
|
||||
assert!(rows[38].contains(TuiStatus::Error.label()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multi_target_state_renders_the_target_table_in_place_of_packet_counters() {
|
||||
let mut state = TuiState::with_target_labels(
|
||||
TuiConfig::default(),
|
||||
["alpha".to_owned(), "beta".to_owned(), "gamma".to_owned()],
|
||||
);
|
||||
state.toggle_view();
|
||||
state.process_target_event(&target_instance("beta"), &reply(4, 3_000_000));
|
||||
|
||||
let rows = render_rows(&state, 140, 40);
|
||||
|
||||
assert!(has_panel(&rows, "targets"));
|
||||
assert_target_table_rows(&rows, &["alpha", "beta", "gamma"], "beta");
|
||||
assert!(has_panel(&rows, "timing - first target"));
|
||||
assert!(has_panel(&rows, "sample - first target"));
|
||||
// The table replaces the single-target packet counters.
|
||||
assert!(!has_panel(&rows, "packets"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_graph_view_renders_a_chart_instead_of_the_dashboard_panels() {
|
||||
let state = active_single_target_state();
|
||||
assert_eq!(state.view, TuiView::Graph);
|
||||
|
||||
let rows = render_rows(&state, 140, 40);
|
||||
let text = rows.join("\n");
|
||||
|
||||
assert!(text.contains(state.graph_metric.title()));
|
||||
assert!(text.contains("live"));
|
||||
assert!(rows[38].contains("q quit"));
|
||||
// The graph view replaces every dashboard panel, not just some.
|
||||
for panel in ["packets", "timing", "recent events", "sample"] {
|
||||
assert!(
|
||||
!has_panel(&rows, panel),
|
||||
"{panel} panel rendered in the graph view"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_multi_target_graph_view_keeps_the_target_table_header() {
|
||||
let mut state = TuiState::with_target_labels(
|
||||
TuiConfig::default(),
|
||||
["alpha".to_owned(), "beta".to_owned()],
|
||||
);
|
||||
state.process_target_event(&target_instance("beta"), &reply(4, 3_000_000));
|
||||
|
||||
let rows = render_rows(&state, 140, 40);
|
||||
let text = rows.join("\n");
|
||||
|
||||
assert!(has_panel(&rows, "targets"));
|
||||
// Scoped to the table: the replying target also names a chart series.
|
||||
assert_target_table_rows(&rows, &["alpha", "beta"], "beta");
|
||||
assert!(text.contains(state.graph_metric.title()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paused_display_with_dropped_events_is_reported_on_the_status_line() {
|
||||
let mut state = active_dashboard_state();
|
||||
state.toggle_pause();
|
||||
state.mark_dropped_managed_events(3);
|
||||
|
||||
let rows = render_rows(&state, 140, 40);
|
||||
|
||||
assert!(rows[38].contains("display paused"));
|
||||
assert!(rows[38].contains("incomplete:dropped=3"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user
If the sample panel regresses to rendering
last warning: -while the recent-events panel still containswrong token, both of these whole-frame substring assertions continue to pass, so the claimed last-warning-row coverage does not protect that user-visible behavior. Inspect the sample-panel row (or assert the combinedlast warning: ...wrong tokentext) instead.AGENTS.md reference: AGENTS.md:L85-L89
Useful? React with 👍 / 👎.
Good catch — the assertion now locates the
last warning:row and requires that row to contain the warning, so a regression to the placeholder fails even while the recent-events panel still shows it.🤖 Addressed by Claude Code
If the
alphaorbetarow disappears from the target table, these whole-frame assertions still pass:alphais also rendered as the first target in the header, while the injectedbetareply puts that label in the recent-events panel and graph legend; the remaininggammarow is enough to prove only that some table content survived. Restrict these checks to the rows occupied by the target panel, or assert each label together with its table-row fields, so the claimed table coverage protects every visible target row.AGENTS.md reference: AGENTS.md:L89-L89
Useful? React with 👍 / 👎.
If the sample panel regresses to rendering a missing or incorrect effective RTT, this test still passes because the recent-events panel independently contains
reply seq=11 effective=2.5ms, while the separate sequence assertion only proves that another sample row rendered. Find the row containingraw / adjusted / effective:and assert2.5mson that row so the intended sample-panel value is actually protected.AGENTS.md reference: AGENTS.md:L89-L89
Useful? React with 👍 / 👎.
If a compact-layout regression renders a condensed or otherwise reformatted timing panel, this negative assertion can still pass because it searches for one incidental sequence of spaces from the current large timing-row formatter rather than for the panel itself. Check for the unique
timingpanel title, or inspect the compact layout's panel regions, so the test protects the stated absence of that panel independently of metric formatting.AGENTS.md reference: AGENTS.md:L85-L89
Useful? React with 👍 / 👎.
Agreed — labels are now matched against the target table's own columns (rows sliced at the panel separator), each with its status column, plus the replying target's last sample; verified it fails when a table row is dropped.
🤖 Addressed by Claude Code
Agreed, and stronger than suggested: asserting
2.5mson that row still passed with the effective field dropped, because the fixture's adjusted and effective RTT coincide — so the whole row is now asserted, verified against that exact regression.🤖 Addressed by Claude Code
Agreed — replaced the spacing-dependent check with the
timingpanel title, confirmed absent from the compact frame.🤖 Addressed by Claude Code
The
SessionStartedfixture also appendssession started token=0xabcdto the recent-events panel, so this whole-frame assertion still passes if the session header regresses to displayingsession: -. Locate the header’s session row and assert the token there so this test actually protects the user-visible session identity it claims to cover.AGENTS.md reference: AGENTS.md:L85-L89
Useful? React with 👍 / 👎.
These negative checks only exclude the recent-events and sample panels; a regression that also renders the packet or timing panel alongside the graph would still pass, despite the test claiming the graph replaces the dashboard panels. Assert the absence of those uniquely titled panels as well so the view-selection behavior is fully protected.
AGENTS.md reference: AGENTS.md:L85-L89
Useful? React with 👍 / 👎.
Unlike the corrected dashboard case, this separate graph-view test still searches the entire frame for
beta; because the reply creates a chart series namedbeta, the assertion passes even if the target table drops that row. Inspect the target-table rows here as well so every target shown in the graph view is actually protected.AGENTS.md reference: AGENTS.md:L85-L89
Useful? React with 👍 / 👎.
The sample panel’s
last warning: WrongToken: wrong tokenrow also contains bothwarningandwrong token, so this predicate passes even if the warning disappears from recent events entirely. Restrict the search to the recent-events panel or require the event-specificwarning WrongToken: wrong tokentext so that panel’s behavior is independently protected.AGENTS.md reference: AGENTS.md:L85-L89
Useful? React with 👍 / 👎.
Agreed — the token is now asserted on the session panel's own row via a new
panel_containshelper; verified it fails when the header regresses tosession: -.🤖 Addressed by Claude Code
Agreed — the graph-view test now rejects all four dashboard panels (packets, timing, recent events, sample) by panel title; verified it fails when the packet panel is drawn alongside the graph.
🤖 Addressed by Claude Code
Agreed — the graph-view case now uses the same table-scoped check as the dashboard case; verified it fails when a target table row is dropped.
🤖 Addressed by Claude Code
Agreed — the recent-events check now requires the event-specific
warning WrongToken: wrong tokentext within that panel's own rows; verified it fails when the event line loses its message.🤖 Addressed by Claude Code