test(tui): add off-screen render coverage #93
@@ -2049,6 +2049,7 @@ mod tests {
|
|||||||
use irtt_client::{
|
use irtt_client::{
|
||||||
ClientTimestamp, OneWayDelaySample, PacketMeta, RttSample, ServerTiming, WarningKind,
|
ClientTimestamp, OneWayDelaySample, PacketMeta, RttSample, ServerTiming, WarningKind,
|
||||||
};
|
};
|
||||||
|
use ratatui::backend::TestBackend;
|
||||||
use std::{
|
use std::{
|
||||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
@@ -2935,4 +2936,233 @@ mod tests {
|
|||||||
assert_eq!(graph.server_to_client_ns, Some(30_000));
|
assert_eq!(graph.server_to_client_ns, Some(30_000));
|
||||||
|
|
|||||||
assert_eq!(graph.server_processing_ns, Some(100_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")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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("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");
|
||||||
|
|
||||||
|
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("effective RTT "));
|
||||||
|
// 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 text = render_text(&state, 140, 40);
|
||||||
|
|
||||||
|
// Session identity from the header.
|
||||||
|
assert!(text.contains("0xabcd"));
|
||||||
|
assert!(text.contains("127.0.0.1:2112"));
|
||||||
|
// The most recent sample and its effective RTT.
|
||||||
|
assert!(text.contains("last seq: 11"));
|
||||||
|
assert!(text.contains("2.5ms"));
|
||||||
|
// Warning presentation reaches the recent-events panel and the sample
|
||||||
|
// panel's last-warning row.
|
||||||
|
assert!(text.contains("wrong token"));
|
||||||
|
assert!(text.contains("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));
|
||||||
|
|
||||||
|
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)_
|
|||||||
|
let text = render_text(&state, 140, 40);
|
||||||
|
|
||||||
|
assert!(text.contains("targets"));
|
||||||
|
for label in ["alpha", "beta", "gamma"] {
|
||||||
|
assert!(text.contains(label), "missing target {label} in:\n{text}");
|
||||||
|
}
|
||||||
|
assert!(text.contains("timing - first target"));
|
||||||
|
assert!(text.contains("sample - first target"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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"));
|
||||||
|
assert!(!text.contains("recent events"));
|
||||||
|
assert!(!text.contains("last warning:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 text = render_text(&state, 140, 40);
|
||||||
|
|
||||||
|
assert!(text.contains("targets"));
|
||||||
|
assert!(text.contains("alpha"));
|
||||||
|
assert!(text.contains("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