From 17d3fed7c5dc99020c622a8aadfde85063667491 Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Sat, 15 Aug 2026 01:24:16 +0700 Subject: [PATCH 1/4] test(tui): add off-screen render coverage The TUI had pure state and graph-viewport tests but nothing that rendered a frame, so layout selection and panel presence were unprotected. Eleven tests drive draw_dashboard through Ratatui's TestBackend across the durable layouts: below the minimum width and height, the smallest usable terminal, compact, large, multi-target, and the graph view in both target modes, including active sample data, warning and error presentation, and status-line state. Assertions are on visible text and layout facts rather than full-screen snapshots. Test-only change; production rendering is untouched. --- crates/irtt-cli/src/cmd/tui/ui.rs | 230 ++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) diff --git a/crates/irtt-cli/src/cmd/tui/ui.rs b/crates/irtt-cli/src/cmd/tui/ui.rs index d468bac..878eb6d 100644 --- a/crates/irtt-cli/src/cmd/tui/ui.rs +++ b/crates/irtt-cli/src/cmd/tui/ui.rs @@ -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,233 @@ 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 { + 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::() + }) + .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)); + + 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")); + } } From 2062535c20459a097a098d664a8f75e17c9cfcff Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Sat, 15 Aug 2026 01:49:23 +0700 Subject: [PATCH 2/4] test(tui): assert the warning on its sample-panel row The whole-frame substring assertions passed as long as the warning appeared anywhere, so a sample panel regressing to "last warning: -" would still have been reported as covered. The assertion now locates the last-warning row and requires it to carry the warning itself. --- crates/irtt-cli/src/cmd/tui/ui.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/irtt-cli/src/cmd/tui/ui.rs b/crates/irtt-cli/src/cmd/tui/ui.rs index 878eb6d..0b90ed8 100644 --- a/crates/irtt-cli/src/cmd/tui/ui.rs +++ b/crates/irtt-cli/src/cmd/tui/ui.rs @@ -3078,7 +3078,8 @@ mod tests { fn active_sample_data_and_warnings_are_visible_in_the_large_layout() { let state = active_dashboard_state(); - let text = render_text(&state, 140, 40); + let rows = render_rows(&state, 140, 40); + let text = rows.join("\n"); // Session identity from the header. assert!(text.contains("0xabcd")); @@ -3086,10 +3087,20 @@ mod tests { // 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:")); + // The warning reaches the recent-events panel... + assert!(rows + .iter() + .any(|row| row.contains("warning") && row.contains("wrong token"))); + // ...and the sample panel's last-warning row carries the warning + // itself, not the absent-value placeholder. + let last_warning = rows + .iter() + .find(|row| row.contains("last warning:")) + .expect("the large layout renders a last-warning row"); + assert!( + last_warning.contains("wrong token"), + "last-warning row did not carry the warning: {last_warning}" + ); } #[test] From c12617a669d8bf8126ffd977a27fda86d125ca57 Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Sat, 15 Aug 2026 02:01:18 +0700 Subject: [PATCH 3/4] test(tui): tighten render assertions to the panels they claim Four assertions did not protect what they described. Target labels were searched across the whole frame, where the first target also appears in the session header and a replying target appears in recent events and the graph legend; labels are now matched against the target table's own columns, with the status column and the replying target's last sample. The sample panel's effective RTT is now asserted as a whole row, since the fixture's adjusted and effective values coincide and a bare "2.5ms" still matched with the effective field dropped. The minimum-height case now rejects dashboard content like the minimum-width case does, and the compact layout checks for the timing panel title rather than one incidental run of spaces from the large timing formatter. Each tightened assertion was confirmed to fail against a deliberate regression of the behavior it protects. --- crates/irtt-cli/src/cmd/tui/ui.rs | 70 ++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/crates/irtt-cli/src/cmd/tui/ui.rs b/crates/irtt-cli/src/cmd/tui/ui.rs index 0b90ed8..beea963 100644 --- a/crates/irtt-cli/src/cmd/tui/ui.rs +++ b/crates/irtt-cli/src/cmd/tui/ui.rs @@ -2960,6 +2960,25 @@ mod tests { render_rows(state, width, height).join("\n") } + /// The target table's own rows, sliced to that panel's columns. + /// + /// The table shares its band with the session panel, so a whole-row search + /// would happily match a label rendered on the left. Rows are cut at the + /// panel separator and stop at the band's bottom border. + fn target_table_rows(rows: &[String]) -> Vec { + let header = rows + .iter() + .position(|row| { + row.contains("target") && row.contains("status") && row.contains("warn") + }) + .expect("the multi-target layout renders a target table header"); + rows[header + 1..] + .iter() + .take_while(|row| row.contains("\u{2502}\u{2502}")) + .filter_map(|row| row.rsplit("\u{2502}\u{2502}").next().map(str::to_owned)) + .collect() + } + /// A single-target state carrying a session, one reply, and one warning. /// /// The TUI opens in the graph view, so dashboard tests toggle it. @@ -3016,6 +3035,7 @@ mod tests { 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")); } @@ -3047,7 +3067,7 @@ mod tests { // that only fit in the large layout. assert!(!text.contains("recent events")); assert!(!text.contains("last warning:")); - assert!(!text.contains("effective RTT ")); + assert!(!text.contains("timing")); // The status line is the bottom bordered row of the layout. assert!(rows[22].contains("q quit")); } @@ -3084,9 +3104,20 @@ mod tests { // 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. + // The most recent sample, with its effective RTT on the sample panel's + // own metric row rather than anywhere on the frame. assert!(text.contains("last seq: 11")); - assert!(text.contains("2.5ms")); + let sample_metrics = rows + .iter() + .find(|row| row.contains("raw / adjusted / effective:")) + .expect("the large layout renders a sample 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... assert!(rows .iter() @@ -3124,12 +3155,41 @@ mod tests { state.toggle_view(); state.process_target_event(&target_instance("beta"), &reply(4, 3_000_000)); - let text = render_text(&state, 140, 40); + let rows = render_rows(&state, 140, 40); + let text = rows.join("\n"); assert!(text.contains("targets")); + // Every target gets its own table row. Searching the whole frame would + // not prove this: the first target is also named in the session header, + // and a target with a reply appears in recent events and the legend. + let table = target_table_rows(&rows); for label in ["alpha", "beta", "gamma"] { - assert!(text.contains(label), "missing target {label} in:\n{text}"); + let row = 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{text}")); + assert!( + row.contains("opening"), + "target row for {label} lost its status column: {row}" + ); } + // The target that replied shows its last sample; the others do not. + let beta = table + .iter() + .find(|row| { + row.trim_start_matches('\u{2502}') + .trim_start() + .starts_with("beta") + }) + .expect("beta has a target table row"); + assert!( + beta.contains("3.0ms"), + "beta row lost its last sample: {beta}" + ); assert!(text.contains("timing - first target")); assert!(text.contains("sample - first target")); } From 17ade8e6cbab37359c4a3a5f96957fb53370b3a8 Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Sat, 15 Aug 2026 02:12:23 +0700 Subject: [PATCH 4/4] test(tui): scope render assertions to their owning panels The frame echoes most panel content elsewhere - the session token appears in recent events, a replying target names a chart series, and the sample panel's last-warning row satisfies a loose warning search - so whole-frame assertions kept claiming coverage they did not have. Panels are now addressed structurally: has_panel matches a title in a top border, panel_rows slices a panel's interior to its own columns, and assertions run against the owning panel. That fixes the session token, recent-events warning line, graph-view panel exclusions and graph-view target rows, and converts the remaining whole-frame checks in the module so the class of defect does not come back. Each tightened assertion was confirmed to fail against a deliberate regression of the behavior it protects. --- crates/irtt-cli/src/cmd/tui/ui.rs | 185 +++++++++++++++++++----------- 1 file changed, 117 insertions(+), 68 deletions(-) diff --git a/crates/irtt-cli/src/cmd/tui/ui.rs b/crates/irtt-cli/src/cmd/tui/ui.rs index beea963..81dabad 100644 --- a/crates/irtt-cli/src/cmd/tui/ui.rs +++ b/crates/irtt-cli/src/cmd/tui/ui.rs @@ -2960,25 +2960,93 @@ mod tests { render_rows(state, width, height).join("\n") } - /// The target table's own rows, sliced to that panel's columns. + /// Whether a bordered panel with this title is on screen. /// - /// The table shares its band with the session panel, so a whole-row search - /// would happily match a label rendered on the left. Rows are cut at the - /// panel separator and stop at the band's bottom border. - fn target_table_rows(rows: &[String]) -> Vec { - let header = rows + /// 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 { + let needle: Vec = format!("\u{250c}{title}").chars().collect(); + let (top, start) = rows .iter() - .position(|row| { - row.contains("target") && row.contains("status") && row.contains("warn") + .enumerate() + .find_map(|(index, row)| { + let chars: Vec = row.chars().collect(); + chars + .windows(needle.len()) + .position(|window| window == needle.as_slice()) + .map(|column| (index, column)) }) - .expect("the multi-target layout renders a target table header"); - rows[header + 1..] + .unwrap_or_else(|| panic!("no panel titled {title} in:\n{}", rows.join("\n"))); + let title_row: Vec = rows[top].chars().collect(); + let end = title_row[start..] .iter() - .take_while(|row| row.contains("\u{2502}\u{2502}")) - .filter_map(|row| row.rsplit("\u{2502}\u{2502}").next().map(str::to_owned)) + .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::() + }) + .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. @@ -3099,18 +3167,18 @@ mod tests { let state = active_dashboard_state(); let rows = render_rows(&state, 140, 40); - let text = rows.join("\n"); - // Session identity from the header. - assert!(text.contains("0xabcd")); - assert!(text.contains("127.0.0.1:2112")); - // The most recent sample, with its effective RTT on the sample panel's - // own metric row rather than anywhere on the frame. - assert!(text.contains("last seq: 11")); - let sample_metrics = rows + // 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 large layout renders a sample metric row"); + .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. @@ -3118,16 +3186,20 @@ mod tests { 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... - assert!(rows - .iter() - .any(|row| row.contains("warning") && row.contains("wrong token"))); + // 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 = rows + let last_warning = sample .iter() .find(|row| row.contains("last warning:")) - .expect("the large layout renders a last-warning row"); + .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}" @@ -3156,42 +3228,13 @@ mod tests { 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!(text.contains("targets")); - // Every target gets its own table row. Searching the whole frame would - // not prove this: the first target is also named in the session header, - // and a target with a reply appears in recent events and the legend. - let table = target_table_rows(&rows); - for label in ["alpha", "beta", "gamma"] { - let row = 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{text}")); - assert!( - row.contains("opening"), - "target row for {label} lost its status column: {row}" - ); - } - // The target that replied shows its last sample; the others do not. - let beta = table - .iter() - .find(|row| { - row.trim_start_matches('\u{2502}') - .trim_start() - .starts_with("beta") - }) - .expect("beta has a target table row"); - assert!( - beta.contains("3.0ms"), - "beta row lost its last sample: {beta}" - ); - assert!(text.contains("timing - first target")); - assert!(text.contains("sample - first target")); + 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] @@ -3205,8 +3248,13 @@ mod tests { 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:")); + // 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] @@ -3217,11 +3265,12 @@ mod tests { ); state.process_target_event(&target_instance("beta"), &reply(4, 3_000_000)); - let text = render_text(&state, 140, 40); + let rows = render_rows(&state, 140, 40); + let text = rows.join("\n"); - assert!(text.contains("targets")); - assert!(text.contains("alpha")); - assert!(text.contains("beta")); + 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())); }