Add aligned users table

This commit is contained in:
2026-10-26 23:41:24 +02:00
parent ecc5c83d78
commit 6d8d6d572d
3 changed files with 608 additions and 61 deletions
+79 -1
View File
@@ -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
+56
View File
@@ -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.
+473 -60
View File
@@ -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<ListItem<'_>> = 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<Row<'static>> = 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::<Vec<_>>(),
)
.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<ClientColumn> {
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::<Vec<_>>(),
)
}
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::<String>()
.contains("State")
})
.unwrap();
let header_line = (0..buffer.area.width)
.map(|x| buffer[(x, header_y)].symbol())
.collect::<String>();
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::<String>()
.contains("State")
})
.unwrap();
let header_line = (0..buffer.area.width)
.map(|x| buffer[(x, header_y)].symbol())
.collect::<String>();
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::<Vec<_>>()
.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 }];