diff --git a/PLANS.md b/PLANS.md index ee647e0..0571a2f 100644 --- a/PLANS.md +++ b/PLANS.md @@ -3465,12 +3465,90 @@ HEAD to `18f28ee` while M11.1 was underway; the review covered committed and uncommitted M11 inputs. This team created no commit. No Alpine, VM or production behavior is claimed. Stop after this milestone. +### Milestone 12 — Aligned, responsive client table + +Status: accepted (2026-10-26T21:39:45Z). M11 is accepted; M9 remains +retired and unaccepted. The user requested this scoped presentation change. + +#### Outcome and scope + +Render client records with Ratatui's native `Table` and explicit column +constraints. Show `State`, `Name`, `Public key`, `Mode`, and `Allowed IPs` +headers when width permits. Keep columns aligned across short/long and Unicode +aliases, supported and inspection-only peers, and all draft states. Truncate +predictably by terminal cell width. At narrow widths prioritize State and Name, +then omit or shrink secondary columns while preserving Enter access to full +peer details. Retain diagnostics, document-level issue rows, draft-removal +information, selected-row highlight, keyboard navigation, and the M10 bottom +shortcut panel. This milestone changes rendering only; parser, projection, +validation, persistence, allocation, restart and VPN behavior are unchanged. + +#### Context and steps + +`src/tui/render.rs::render_list` currently builds variable-length `ListItem` +strings, so columns drift with alias/state length. `UiState` and +`src/tui/terminal.rs` select real peers by `peer_index`; draft-removal and +document-issue rows are appended after peer rows and are not peer selections. +Keep that row order and mapping when replacing the widget with `Table`. +`detail_lines` already retains full peer public key, alias, routes and issues. +The list must continue escaping untrusted display text. Use the existing +approved `ratatui` dependency and its terminal-width APIs; do not add a direct +Unicode-width crate or literal tab delimiters. + +| Task | Owner | Scope and expected result | State | +| --- | --- | --- | --- | +| M12.1 | implementer | Replace list rendering with responsive Ratatui Table, preserve peer selection mapping, state/diagnostic information and footer; add focused mixed-width/Unicode/long-route/empty/narrow tests. | complete: render 20/20, terminal 8/8, TUI workflow 6/6 on final integrated source | +| M12.2 | reviewer / repairer | Fresh independent review of layout, truncation, details access, draft/document rows and navigation; repair and re-review actionable findings. | complete: R1–R4 repaired and independently re-reviewed closed; no remaining actionable finding | +| M12.3 | coordinator | Verify integrated files, run focused and Rust completion gates, record evidence and accept only if criteria pass. | complete: all Rust completion gates passed; source fingerprints/results in [M12 evidence](docs/evidence/milestone-12.md) | + +#### Acceptance and validation + +- [x] M12-A1: native Table has the five requested headers at ordinary width; + row columns align across mixed alias lengths, states, duplicate aliases, + supported and inspection-only peers, with no literal tabs. +- [x] M12-A2: Unicode aliases and long values truncate at display-cell + boundaries without corrupting adjacent columns; full peer alias, public key, + routes and diagnostic explanations remain in details. +- [x] M12-A3: 120×30, 80×24, 40×8, 34×8 and below-minimum layouts retain + visible State/Name at supported sizes, adapt or omit secondary columns, + preserve the M10 footer and readable resize fallback. +- [x] M12-A4: Up/Down highlight and Enter-to-details stay tied to the selected + peer (including unsupported/malformed peers), without enabling mutation; + empty lists, draft additions/removals and document issues remain readable + and do not disturb selection alignment. +- [x] M12-A5: focused synthetic render/navigation tests and applicable Rust + completion gates pass; independent review closes actionable findings. + +Planned checks: `cargo test --locked tui::render`, +`cargo test --locked tui::terminal`, `cargo test --locked --test tui_workflow`, +`cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D +warnings`, `cargo test --locked`, `cargo build --locked --release`, and +`git diff --check`. Synthetic Ratatui/PTY evidence only; no Alpine, VM, +production, or live VPN checks are required for this presentation change. + +#### Outcome + +Accepted for scoped TUI presentation. **M12-R1 (P2, closed):** compact bordered table hid all peers; +repair uses a borderless compact table with a visible selected row and footer. +**M12-R2 (P2, closed):** empty/document-only views falsely highlighted row +zero; selection is now `None`. **M12-R3 (P2, closed):** five columns omitted +the two-cell marker budget; repair and buffer assertions cover the right edge. +Independent re-review closed R1–R3. **M12-R4 (P2, closed):** at widths 72–78 the +five-column threshold activated before the `Allowed IPs` header fit. Repair +uses the full-header minimum width and buffer tests at 72/78/79/80; +independent re-review closed R4 with no remaining finding. Final focused tests, +format, Clippy, locked full suite, release build and diff check passed on the +source in [M12 evidence](docs/evidence/milestone-12.md). Synthetic UI/PTY +evidence does not establish physical terminal, Alpine or systemd behavior. +Stop after this milestone; do not resume VM work, commit or deploy. + ## Current progress and resume point - Bounded TUI maintenance (2026-10-26): repaired list arrows opening client details. Up/Down now only move the highlight; Enter opens details and Esc returns. Initial navigation follows the renderer's first-row highlight. Independent review closed NAV-001 with no remaining scoped findings; validation is recorded in [navigation repair evidence](docs/evidence/tui-navigation.md). This does not advance M9 or supply VM/Alpine acceptance evidence; prior artifact identities remain historical after this Rust change. - Milestones 1–8 are accepted; milestone 9 is retired by user direction and unaccepted. Its real systemd/D-Bus checks remain unverified and are not a prerequisite to M10. - Milestone 10 is accepted: the styled keyboard quick reference passed independent review, focused synthetic/PTY checks and Rust completion gates. - Milestone 11 is accepted for valid but unsupported AllowedIPs route presentation; focused and full Rust gates and independent re-review passed. Real systemd/D-Bus and IPv6 behavior remain unverified. +- Milestone 12 is accepted: native responsive client table, independent R4 closure and all scoped Rust gates passed; source and synthetic UI/PTY evidence are linked above. - The approved dependency list is in [DEPENDENCIES.md](docs/DEPENDENCIES.md); container testing methodology is in [TESTING.md](docs/TESTING.md). - Milestone 1 is expanded and the Rust environment is pinned. - Milestone 2 delivered a lossless in-memory WireGuard configuration editor, versioned metadata, and behavioral preservation tests. @@ -3483,7 +3561,7 @@ production behavior is claimed. Stop after this milestone. - Milestone 8 delivered reviewed opt-in Alpine Testcontainers coverage for real IPv4 WireGuard peers, application lifecycle, saved/runtime separation, restoration, import/reissue, backup failures and the real Rustix reader. Host gates and three fresh real-container runs passed on one stable source fingerprint; M8-A1–A10 accepted. Real IPv6 behavior is unverified by user-authorized scope. - Milestone 9 is retired and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked. -- Next action: stop after M11 and await user direction. Do not restart the VM, access production, commit, or deploy. +- Next action: stop after M12 and await user direction. Do not restart the VM, access production, commit, or deploy. ## Plan maintenance diff --git a/docs/evidence/milestone-12.md b/docs/evidence/milestone-12.md new file mode 100644 index 0000000..a480dad --- /dev/null +++ b/docs/evidence/milestone-12.md @@ -0,0 +1,56 @@ +# Milestone 12 acceptance evidence + +Run: 2026-10-26T21:39:45Z, local development workspace. This report covers +the responsive client table presentation. It does not establish real terminal +emulator, Alpine WireGuard, systemd/VM or production behavior. + +## Source and environment + +- HEAD `ecc5c83d78a4428b8fa2c1826fe74ead144cf72b`; the working tree at + validation was dirty with `PLANS.md` and `src/tui/render.rs`. Relevant + integrated SHA-256 inputs: `render.rs` + `78be59f8550f94c573db9eab6b43968a9e0621bcabd7dd268b5ba7f9b9aba3ad`; + `state.rs` `7e893758b623b42f955358dcaba8100fdb6b54ca48c9dd9a1dea15090b84f371`; + `terminal.rs` `ed9a99932dddbaf508667c5f17886704bda749264c53cc60c7e29b889a56e074`; + `Cargo.lock` `b8549df27f971dc550218cfc28e788f6630a1e13e7df538aded38e969ab713b6`. +- Pinned `rustc`/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97; native + `x86_64-unknown-linux-gnu` host. No dependency or lockfile change. + +## Validation + +| Exact command | Actual result | +| --- | --- | +| `cargo test --locked tui::render` | Exit 0; 20 render tests passed. | +| `cargo test --locked tui::terminal` | Exit 0; 8 terminal tests passed. | +| `cargo test --locked --test tui_workflow` | Exit 0; 6 workflow tests passed, including isolated PTY smoke. | +| `cargo fmt --all -- --check` | Exit 0. | +| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0. | +| `cargo test --locked` | Exit 0; 279 library unit tests passed, 2 opt-in drivers ignored. All integration suites and 2 compile-fail doctests passed. Testcontainers target reported 25 passed and 1 ignored without starting Alpine. | +| `cargo build --locked --release` | Exit 0. | +| `git diff --check` | Exit 0. | + +Ratatui TestBackend coverage checks five full headers and column boundaries at +ordinary widths, State/Name priority across 72/78/79/80 widths, display-cell +Unicode truncation, long routes, mixed client modes, empty/draft/document rows, +no false non-peer selection, and visible selected peers with the Keys footer at +40×8 and 34×8. Below-minimum resize fallback remains covered. Existing +terminal tests cover Up/Down highlight and Enter-to-details by peer index. +These tests use synthetic buffers and fixtures; physical terminal display +differences were not tested. + +## Independent review + +Fresh read-only review found M12-R1 (compact table hid all peer rows), R2 +(empty/document-only views falsely selected row zero), and R3 (selector width +clipped the last column). A targeted repair added compact body visibility, +unselected non-peer views and marker-aware width budgeting; independent +re-review closed R1–R3. That re-review found R4: five-column mode began before +the `Allowed IPs` header fit at widths 72–78. A second targeted repair moved +the threshold to the full-header minimum and added boundary-buffer checks; +independent re-review closed R4 with no remaining actionable finding. + +M12-A1–A4 are supported by the inspected implementation and synthetic +render/navigation evidence. The integrated gates above satisfy M12-A5. +Configuration parsing, validation, persistence, allocation and VPN behavior +were outside scope and unchanged. Milestone 9 remains retired and unaccepted; +real systemd/D-Bus and IPv6 behavior remain unverified. diff --git a/src/tui/render.rs b/src/tui/render.rs index f0db189..bd59510 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -4,7 +4,7 @@ use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span, Text}; -use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}; +use ratatui::widgets::{Block, Borders, Paragraph, Row, Table, TableState, Wrap}; use crate::app::ClientDisplay; use crate::domain::PublicKey; @@ -480,19 +480,11 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta frame.render_widget(Paragraph::new("Loading client projection…"), area); return; }; - if view.clients.is_empty() { - let text = if view.document_issues.is_empty() { - "No peers are configured." - } else { - "No peer rows. Document issues are listed below." - }; - frame.render_widget( - Paragraph::new(text).block(Block::default().borders(Borders::ALL).title("Clients")), - area, - ); - return; - } - let mut items: Vec> = view + // At the supported minimum, the main panel is only three rows tall. Drop + // its border there so both the header and one client row remain visible. + let compact = area.height <= 3; + let columns = client_table_columns(area.width, compact); + let mut rows: Vec> = view .clients .iter() .map(|client| { @@ -511,7 +503,7 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta .identity .and_then(|identity| view.client_states.get(&identity)) .map_or_else( - || "state: unavailable".to_owned(), + || "Inspection only".to_owned(), |row| match (&row.saved, &row.draft) { (None, Some(_)) => "Draft added".to_owned(), (Some(saved), Some(draft)) if saved != draft => "Draft changed".to_owned(), @@ -520,26 +512,25 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta (None, None) => "State unavailable".to_owned(), }, ); - let anomaly_marker = if visible_issues.is_empty() { - String::new() + let row_state = if visible_issues.is_empty() { + row_state } else { - " ! anomaly".to_owned() + format!("{row_state} !") }; - let anomaly_details = if visible_issues.is_empty() { - String::new() - } else { - format!(" {}", safe_text(&format!("{:?}", visible_issues))) - }; - ListItem::new(Line::from(vec![ - Span::raw(format!("{row_state}{anomaly_marker} ")), - Span::styled(safe_text(&client.label), Style::default().fg(Color::Cyan)), - Span::raw(format!( - " [{identity}] {supported} {addresses}{anomaly_details}" - )), - ])) + client_table_row( + &columns, + [ + row_state, + safe_text(&client.label), + identity, + supported.to_owned(), + addresses, + ], + !visible_issues.is_empty(), + ) }) .collect(); - items.extend(view.client_states.iter().filter_map(|(identity, row)| { + rows.extend(view.client_states.iter().filter_map(|(identity, row)| { let saved = row.saved.as_ref()?; if row.draft.is_some() || view @@ -553,21 +544,44 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta .addresses .ipv6 .map_or_else(|| "none".to_owned(), |address| address.to_string()); - Some(ListItem::new(format!( - "Draft removed {} [{}] saved addresses: {}, {}", - safe_text(&saved.alias), - short_key(identity), - saved.addresses.ipv4, - ipv6 - ))) - })); - items.extend(view.document_issues.iter().map(|issue| { - ListItem::new(format!( - "Document issue: {}", - safe_text(&format!("{issue:?}")) + Some(client_table_row( + &columns, + [ + "Draft removed".to_owned(), + safe_text(&saved.alias), + identity.to_string(), + "saved".to_owned(), + format!("{}, {ipv6}", saved.addresses.ipv4), + ], + false, )) - .style(Style::default().fg(Color::Yellow)) })); + rows.extend(view.document_issues.iter().map(|issue| { + client_table_row( + &columns, + [ + "Document issue".to_owned(), + safe_text(&format!("{issue:?}")), + String::new(), + String::new(), + String::new(), + ], + true, + ) + })); + if rows.is_empty() { + rows.push(client_table_row( + &columns, + [ + "Empty".to_owned(), + "No peers".to_owned(), + String::new(), + String::new(), + String::new(), + ], + false, + )); + } let selected = state .selected_peer_index .and_then(|index| view.clients.iter().position(|row| row.peer_index == index)) @@ -578,16 +592,129 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta .position(|row| row.identity == Some(identity)) }) }) - .unwrap_or(0); - let list = List::new(items) - .block( - Block::default() - .borders(Borders::ALL) - .title("Clients and document issues"), - ) - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - let mut list_state = ListState::default().with_selected(Some(selected)); - frame.render_stateful_widget(list, area, &mut list_state); + .or_else(|| (!view.clients.is_empty()).then_some(0)); + let header = Row::new( + columns + .iter() + .map(|column| column.title) + .collect::>(), + ) + .style(Style::default().add_modifier(Modifier::BOLD)); + let widths = columns + .iter() + .map(|column| Constraint::Length(column.width)); + let table = Table::new(rows, widths) + .header(header) + .column_spacing(1) + .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .highlight_symbol("> "); + let table = if compact { + table + } else { + table.block(Block::default().borders(Borders::ALL).title("Clients")) + }; + let mut table_state = TableState::default().with_selected(selected); + frame.render_stateful_widget(table, area, &mut table_state); +} + +#[derive(Clone, Copy)] +struct ClientColumn { + title: &'static str, + index: usize, + width: u16, +} + +fn client_table_columns(width: u16, borderless: bool) -> Vec { + let inner = width.saturating_sub(if borderless { 0 } else { 2 }); + // Ratatui reserves two cells for the "> " selection marker in addition + // to inter-column spacing when a row is highlighted. + // The five headers need 77 cells: fixed columns, four gaps, the 2-cell + // highlight marker, and the 11-cell "Allowed IPs" heading. + if inner >= 77 { + let route_width = inner - 20 - 13 - 11 - 16 - 4 - 2; + return vec![ + ClientColumn { + title: "State", + index: 0, + width: 18, + }, + ClientColumn { + title: "Name", + index: 1, + width: 13, + }, + ClientColumn { + title: "Public key", + index: 2, + width: 11, + }, + ClientColumn { + title: "Mode", + index: 3, + width: 16, + }, + ClientColumn { + title: "Allowed IPs", + index: 4, + width: route_width, + }, + ]; + } + let mut columns = vec![ClientColumn { + title: "State", + index: 0, + width: 18, + }]; + if inner >= 24 { + columns.push(ClientColumn { + title: "Name", + index: 1, + width: inner - 23, + }); + } + columns +} + +fn client_table_row(columns: &[ClientColumn], values: [String; 5], anomaly: bool) -> Row<'static> { + Row::new( + columns + .iter() + .map(|column| { + let text = truncate_cells(&values[column.index], column.width as usize); + let style = if anomaly && column.index == 0 { + Style::default().fg(Color::Yellow) + } else if column.index == 1 { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }; + Span::styled(text, style) + }) + .collect::>(), + ) +} + +fn truncate_cells(value: &str, width: usize) -> String { + let value = safe_text(value); + if Span::raw(value.as_str()).width() <= width { + return value; + } + if width == 0 { + return String::new(); + } + let target = width.saturating_sub(1); + let mut result = String::new(); + let mut used = 0; + for character in value.chars() { + let character_width = Span::raw(character.to_string()).width(); + if used + character_width > target { + break; + } + result.push(character); + used += character_width; + } + result.push('…'); + result } fn render_details(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiState) { @@ -1010,6 +1137,295 @@ mod tests { assert!(text.contains("R restart") && text.contains("r rename")); } + #[test] + fn client_table_uses_aligned_columns_and_adapts_headers_by_width() { + let first = PublicKey::from_bytes([4; 32]); + let second = PublicKey::from_bytes([5; 32]); + let mut view = saved_client_view(first); + view.clients.push(ClientDisplay { + peer_index: 1, + line: 7, + identity: Some(second), + label: "宽い名 alias with a deliberately long suffix".to_owned(), + allowed_ips: vec!["10.0.0.5/32".to_owned(), "fd00::5/128".to_owned()], + has_subnet_routes: false, + issues: vec![crate::wireguard::PeerIssue::MissingIpv4HostRoute], + mutable_identity: None, + }); + let state = UiState { + selected_peer_index: Some(1), + view: Some(view), + ..UiState::default() + }; + for (width, height) in [(120, 30), (80, 24), (40, 8), (34, 8)] { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| draw(frame, &state, &FormController::default())) + .unwrap(); + let text = rendered_text(&terminal); + assert!( + text.contains("State") && text.contains("Name"), + "missing primary headers at {width}x{height}: {text}" + ); + assert!(!text.contains('\t')); + if width >= 80 { + for header in ["Public key", "Mode", "Allowed IPs"] { + assert!( + text.contains(header), + "missing {header} at {width}x{height}: {text}" + ); + } + } + if width >= 80 { + assert!( + text.contains("inspection"), + "missing inspection mode: {text}" + ); + assert!(text.contains("!"), "missing anomaly marker: {text}"); + } + } + } + + #[test] + fn compact_supported_sizes_show_headers_selected_peer_and_footer() { + let identity = PublicKey::from_bytes([6; 32]); + let state = UiState { + selected_peer_index: Some(0), + view: Some(saved_client_view(identity)), + ..UiState::default() + }; + for width in [34, 40] { + let mut terminal = Terminal::new(TestBackend::new(width, 8)).unwrap(); + terminal + .draw(|frame| draw(frame, &state, &FormController::default())) + .unwrap(); + let buffer = terminal.backend().buffer(); + let text = rendered_text(&terminal); + assert!( + text.contains("State") && text.contains("Name"), + "{width}x8: {text}" + ); + assert!( + text.contains("synthetic"), + "peer hidden at {width}x8: {text}" + ); + assert!(text.contains("Keys"), "footer hidden at {width}x8: {text}"); + assert!(buffer[(0, 7)].symbol().contains('└')); + assert!(buffer[(width - 1, 7)].symbol().contains('┘')); + } + } + + #[test] + fn ordinary_table_columns_fit_selection_marker_and_panel_boundary() { + let identity = PublicKey::from_bytes([7; 32]); + let state = UiState { + selected_peer_index: Some(0), + view: Some(saved_client_view(identity)), + ..UiState::default() + }; + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal + .draw(|frame| draw(frame, &state, &FormController::default())) + .unwrap(); + let buffer = terminal.backend().buffer(); + let header_y = (0..buffer.area.height) + .find(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, *y)].symbol()) + .collect::() + .contains("State") + }) + .unwrap(); + let header_line = (0..buffer.area.width) + .map(|x| buffer[(x, header_y)].symbol()) + .collect::(); + let positions = ["State", "Name", "Public key", "Mode", "Allowed IPs"].map(|header| { + header_line + .find(header) + .map(|position| position as u16) + .unwrap_or_else(|| panic!("missing header {header}: {header_line}")) + }); + assert!(positions.windows(2).all(|pair| pair[0] < pair[1])); + assert!(positions[0] >= 3, "selection marker was not accounted for"); + let allowed_end = positions[4] + "Allowed IPs".len() as u16; + assert!( + allowed_end < 79, + "last column reaches border: {allowed_end}; {header_line}" + ); + let row_y = header_y + 1; + assert!(buffer[(1, row_y)].symbol().contains('>')); + assert!(buffer[(79, row_y)].symbol().contains('│')); + } + + #[test] + fn five_column_headers_are_only_selected_when_allowed_ips_fits() { + let identity = PublicKey::from_bytes([10; 32]); + let state = UiState { + selected_peer_index: Some(0), + view: Some(saved_client_view(identity)), + ..UiState::default() + }; + let headers = ["State", "Name", "Public key", "Mode", "Allowed IPs"]; + for width in [72, 78, 79, 80] { + let mut terminal = Terminal::new(TestBackend::new(width, 12)).unwrap(); + terminal + .draw(|frame| render_list(frame, frame.area(), &state)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let header_y = (0..buffer.area.height) + .find(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, *y)].symbol()) + .collect::() + .contains("State") + }) + .unwrap(); + let header_line = (0..buffer.area.width) + .map(|x| buffer[(x, header_y)].symbol()) + .collect::(); + let five_columns = width >= 79; + assert!(header_line.contains("State") && header_line.contains("Name")); + for header in &headers[2..] { + assert_eq!( + header_line.contains(header), + five_columns, + "unexpected {header:?} header at {width} columns: {header_line}" + ); + } + if five_columns { + let starts = headers.map(|header| { + header_line + .find(header) + .unwrap_or_else(|| panic!("missing {header}: {header_line}")) + }); + assert!(starts.windows(2).all(|pair| pair[0] < pair[1])); + let end = starts[4] + headers[4].len(); + assert!( + end <= (width - 1) as usize, + "Allowed IPs reaches the right border at {width}: {header_line}" + ); + } + } + } + + #[test] + fn empty_and_document_only_tables_have_no_selected_peer_row() { + let mut cases = vec![empty_view()]; + let mut document_only = empty_view(); + document_only + .document_issues + .push(crate::wireguard::DocumentIssue::MalformedSectionHeader { line: 1 }); + cases.push(document_only); + for view in cases { + let state = UiState { + selected_peer_index: None, + selected_identity: None, + view: Some(view), + ..UiState::default() + }; + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal + .draw(|frame| draw(frame, &state, &FormController::default())) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert!(!(0..buffer.area.height).any(|y| { + (0..buffer.area.width).any(|x| buffer[(x, y)].symbol().contains('>')) + })); + } + } + + #[test] + fn client_table_truncates_by_display_cells_and_keeps_peer_details_full() { + let identity = PublicKey::from_bytes([9; 32]); + let mut view = saved_client_view(identity); + view.clients[0].label = "界界界界界界界界界界界界".to_owned(); + view.clients[0].allowed_ips = vec!["10.0.0.2/32, 2001:db8:1234:5678::2/128".to_owned()]; + let state = UiState { + selected_identity: Some(identity), + view: Some(view), + ..UiState::default() + }; + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal + .draw(|frame| draw(frame, &state, &FormController::default())) + .unwrap(); + let text = rendered_text(&terminal); + assert!(text.contains("界")); + // The details view keeps the full alias and routes even when list columns are narrow. + let mut details = state.clone(); + details.screen = Screen::ClientDetails; + let mut detail_terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + detail_terminal + .draw(|frame| draw(frame, &details, &FormController::default())) + .unwrap(); + let detail_text = rendered_text(&detail_terminal); + assert!(detail_text.contains("Allowed IPs")); + let complete_details = detail_lines( + &state.view.as_ref().unwrap().clients[0], + state.view.as_ref().unwrap(), + ) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(complete_details.contains("界界界界界界界界界界界界")); + assert!(complete_details.contains("2001:db8:1234:5678::2/128")); + assert!(truncate_cells("界界界界", 5).ends_with('…')); + assert_eq!(Span::raw(truncate_cells("界界界界", 5)).width(), 5); + } + + #[test] + fn client_table_keeps_empty_draft_removal_and_document_rows_aligned() { + let identity = PublicKey::from_bytes([8; 32]); + let mut view = empty_view(); + let removed = crate::domain::Client::new( + identity, + "duplicate alias", + crate::domain::ClientAddresses { + ipv4: Ipv4Addr::new(10, 0, 0, 8), + ipv6: None, + }, + ) + .unwrap(); + view.client_states.insert( + identity, + super::super::state::ClientStateRow { + saved: Some(removed), + draft: None, + }, + ); + view.document_issues + .push(crate::wireguard::DocumentIssue::MalformedSectionHeader { line: 17 }); + let state = UiState { + view: Some(view), + ..UiState::default() + }; + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal + .draw(|frame| draw(frame, &state, &FormController::default())) + .unwrap(); + let text = rendered_text(&terminal); + assert!(text.contains("Draft removed")); + assert!(text.contains("duplicate")); + assert!(text.contains("Document issue")); + assert!(text.contains("Malformed")); + + let mut empty_terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + empty_terminal + .draw(|frame| { + draw( + frame, + &UiState { + view: Some(empty_view()), + ..UiState::default() + }, + &FormController::default(), + ) + }) + .unwrap(); + assert!(rendered_text(&empty_terminal).contains("No peers")); + } + #[test] fn qr_disclosure_keeps_esc_clear_hint_and_never_renders_fixture_payload() { let mut forms = FormController::default(); @@ -1265,12 +1681,9 @@ mod tests { assert!(rendered.contains("inspection only")); assert!(rendered.contains("removed alias")); assert!(rendered.contains("Draft removed")); - assert!(rendered.contains("MalformedAllowedIp")); + assert!(rendered.contains("!")); assert!(!rendered.contains("UnsupportedAllowedIpShape")); - assert!( - rendered.contains("! anomaly"), - "mixed genuine error stays visible" - ); + assert!(rendered.contains("!"), "mixed genuine error stays visible"); let subnet_only = ClientDisplay { has_subnet_routes: true, issues: vec![crate::wireguard::PeerIssue::UnsupportedAllowedIpShape { @@ -1309,7 +1722,7 @@ mod tests { line.to_string() .contains("Peer issue: UnsupportedAllowedIpShape") })); - assert!(rendered.contains("odd\\u{1b}[31m peer")); + assert!(rendered.contains("odd\\u{1b}")); assert!(rendered.contains("bad\\nroute")); state.view.as_mut().unwrap().clients[1].issues = vec![crate::wireguard::PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 1 }];