diff --git a/PLANS.md b/PLANS.md index b6f3591..d79b5f0 100644 --- a/PLANS.md +++ b/PLANS.md @@ -122,6 +122,7 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo | 10 | Visible styled keyboard quick reference in bottom panel | 8; existing M7 TUI | accepted | | 11 | Valid subnet routes shown as a capability limitation | 10 | accepted | | 12 | Aligned, responsive client table | 11 | accepted | +| 13 | Compact terminal QR disclosure | 12 | accepted | ### Milestone 1 — Rust foundation, settings, and domain states @@ -3544,8 +3545,57 @@ 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. +### Milestone 13 — Compact terminal QR disclosure + +Status: accepted (2026-10-27T21:13:30+02:00). M12 is accepted; M9 remains retired and +unaccepted. This is the user's scoped terminal disclosure repair. + +#### Outcome and scope + +Render authorized QR modules with two vertical modules per terminal cell, +explicit dark/light colors, and the complete four-module quiet zone. Fit must +account for the actual bordered disclosure layout and wrapped delivery notice; +retain file-backed fallback and Esc cleanup when it cannot fit. Existing file +delivery, password authorization, and draft/saved/applied behavior remain intact. + +#### Tasks and acceptance + +| Task | Owner | State | Evidence required | +| --- | --- | --- | --- | +| M13.1 | implementer | complete for implementation scope | Compact rendering/layout and focused deterministic tests in `src/tui/render.rs` and test-only QR fixture in `src/delivery/qr.rs` | +| M13.2 | independent reviewer; repairer | complete | R1–R5 repaired and independently re-reviewed; no actionable scoped finding remains | +| M13.3 | coordinator | complete | Integrated files checked; pinned Rust gates passed; source and artifact recorded in [M13 evidence](docs/evidence/milestone-13.md) | + +- [x] M13-A1: all four two-module cell combinations, odd-height light padding, + explicit colors and full quiet zone render correctly. +- [x] M13-A2: a deterministic nonsecret 73-module matrix uses 73 columns and 37 + QR rows, with exact fit/fallback dimensions from bordered layout and wrapped notice. +- [x] M13-A3: fallback points to saved file; resizing redraws the same authorized + QR; Esc clears it; no matrix or configuration enters logs, Debug or snapshots. +- [x] M13-A4: independent review closes findings and focused plus required Rust + completion gates pass. Automated render evidence is distinct from manual scanning. + +Planned checks: `cargo test --locked tui::render::tests::`, focused QR tests, +`cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, +`cargo test --locked`, `cargo build --locked --release`, and `git diff --check`. +Synthetic tests require no Docker or VM; real systemd is outside this scope. +Evidence will be recorded in `docs/evidence/milestone-13.md`. + +#### Outcome and handoff + +M13-R1–R5 (P2, closed): fallback visibility at 34×12/20×8, Draft/Saved/Applied +status, all quiet-zone edges, exact available-area reporting and uncertain +delivery wording were repaired and independently re-reviewed. [M13 evidence](docs/evidence/milestone-13.md) +records focused and integrated results. No production or host service was used. + +Accepted for the automated TUI rendering scope. A 73-module matrix needs 73×37 +QR cells; the tested bordered layout with a one-row notice fits at 75×44. +Longer notices increase the required terminal height. Physical terminal and +phone/decoder scanning remain unverified; M9 systemd acceptance remains retired. + ## Current progress and resume point +- Milestone 13 is accepted (2026-10-27): compact, explicitly colored QR disclosure, complete quiet zone and dimensioned fallback passed synthetic rendering tests, independent R5 closure and pinned Rust gates. [M13 evidence](docs/evidence/milestone-13.md) records the reviewed source and release artifact. No manual scan, Alpine rerun, VM or production validation is claimed. - Bounded TUI maintenance TUI-TS-001 (2026-10-27): new-client form creation timestamps now have whole-second UTC precision before strict metadata rendering. A fixed fractional-second instant and a synthetic blank-IP form-to-draft workflow passed; saved bytes stayed unchanged. Independent review found no actionable issue, and pinned-toolchain Rust completion gates passed. [Timestamp repair evidence](docs/evidence/tui-creation-timestamp.md) identifies the source and release artifact. This repairs M7 behavior without claiming new Alpine or VM validation; earlier artifact identities remain historical after this Rust change. - 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. @@ -3564,7 +3614,7 @@ Stop after this milestone; do not resume VM work, commit or deploy. - 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: scoped timestamp repair is complete; await user direction. Do not restart the VM, access production, commit, or deploy. +- Next action: await user direction after M13. Do not restart the VM, access production, commit, or deploy. ## Plan maintenance diff --git a/docs/evidence/milestone-13.md b/docs/evidence/milestone-13.md new file mode 100644 index 0000000..23124e2 --- /dev/null +++ b/docs/evidence/milestone-13.md @@ -0,0 +1,59 @@ +# Milestone 13 acceptance evidence: compact terminal QR disclosure + +Validated 2026-10-27T21:13:30+02:00 on the native +`x86_64-unknown-linux-gnu` development host. This is synthetic terminal +rendering evidence, not a manual scan or production integration result. + +## Behavior and review + +The QR renderer packs two vertical modules into one cell using space, `▀`, +`▄`, and `█`, with explicit black foreground and white background. It preserves +the encoded four-module quiet zone on all four edges and treats the absent +partner of an odd final row as light. The QR is rendered in a separate, +unwrapped region. The fit check accounts for its bordered area and actual +wrapped delivery notice. A deterministic nonsecret 73-module fixture uses +73 columns and 37 QR rows; with a one-row notice, the exact tested terminal +layout is 75×44. Longer notices require more terminal height. + +When the complete QR does not fit, a compact fallback shows required and +available dimensions, actual terminal dimensions, saved-file guidance, delivery +status, and Esc. Tests inspect rendered buffers at 74×45, 34×12 and 20×8; +resize redraws the held QR and Esc clears it. The compact notice preserves +uncertain publication and destination-inspection guidance. File delivery, +password authorization, and draft/saved/applied transitions were not modified. +`QrCodeOutput` retains redacted Debug and the fixture matrix is nonsecret. + +An independent GPT-6-Luna/high reviewer examined the actual changes and +closed M13-R1–R5 after targeted repairs: short fallback clipping, hidden +saved/applied summary, incomplete quiet-zone edge coverage, inaccurate +available-area dimensions, and misleading uncertain-delivery compaction. No +actionable scoped findings remain. + +## Commands and source identity + +Pinned rustc/Cargo 1.97.1, rustfmt 1.9.0, and Clippy 0.1.97 were verified. +The following commands ran from the repository root and exited 0: + +- `cargo test --locked tui::render::tests::`: 27 passed. +- `cargo test --locked delivery::qr::tests::`: 2 passed. +- `cargo fmt --all -- --check`. +- `cargo clippy --locked --all-targets -- -D warnings`. +- `cargo test --locked`: 352 passed, 3 explicitly ignored, 0 failed, + including 2 documentation tests. +- `cargo build --locked --release`. +- `git diff --check`. + +Base commit: `af2d061880a8532314f57d4f1ec6eb5265520091`. Dirty Rust inputs +at validation were `src/tui/render.rs` and `src/delivery/qr.rs`; `PLANS.md` +was also dirty with this milestone's execution record. SHA256 inputs: + +- `src/tui/render.rs`: `98ffa4d396194c201cfe20b6c6dcbd80b6689886fc625c8cb83efa6c12564e8a` +- `src/delivery/qr.rs`: `1f2012c6d1583178ca964df1d444c8afeae35607dfafac05bc538911be480748` + +Native release artifact `target/release/wireguard-admin` SHA256: +`0c2fc010138c7db27c3126d0eca6bd83da3be600c1c4e0f8c8beeef77d11e43a`. + +No physical terminal, phone/decoder scan, Alpine integration rerun, disposable +systemd VM, or production operation was performed. The 80×24 size remains a +dimensioned fallback for a 73-module QR. Previous integration artifacts do not +identify this revision; M9 systemd acceptance remains retired and unverified. diff --git a/src/delivery/qr.rs b/src/delivery/qr.rs index c1115b7..c6ecb8e 100644 --- a/src/delivery/qr.rs +++ b/src/delivery/qr.rs @@ -90,6 +90,20 @@ pub(crate) fn synthetic_test_output() -> QrCodeOutput { encode_bytes(b"synthetic QR disclosure fixture").expect("synthetic QR payload encodes") } +/// Deterministic, non-secret matrix for terminal layout and glyph tests. +#[cfg(test)] +pub(crate) fn synthetic_test_matrix(side: usize) -> QrCodeOutput { + let mut modules = Zeroizing::new(vec![0; side.saturating_mul(side)]); + for y in QUIET_ZONE..side.saturating_sub(QUIET_ZONE) { + for x in QUIET_ZONE..side.saturating_sub(QUIET_ZONE) { + if (x * 3 + y * 5) % 7 < 3 { + modules[y * side + x] = 1; + } + } + } + QrCodeOutput { side, modules } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/tui/render.rs b/src/tui/render.rs index bd59510..a728b9f 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -15,14 +15,13 @@ use super::state::{Screen, SessionView, UiState}; pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) { let area = frame.area(); + if let Some(qr) = forms.qr_output() { + render_qr_screen(frame, area, state, qr, &forms.display_lines()); + return; + } if area.width < 34 || area.height < 8 { - let message = if forms.qr_output().is_some() { - "Terminal too small for QR. Resize to at least 34 × 8, or use the saved client file; Esc clears the QR." - } else { - "Terminal too small. Resize to at least 34 × 8." - }; frame.render_widget( - Paragraph::new(message) + Paragraph::new("Terminal too small. Resize to at least 34 × 8.") .block( Block::default() .borders(Borders::ALL) @@ -72,28 +71,24 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) { ); if forms.is_active() { - if let Some(qr) = forms.qr_output() { - render_qr_disclosure(frame, rows[1], qr, &forms.display_lines()); + let lines = if forms.is_draft_review() { + draft_review_lines(state) } else { - let lines = if forms.is_draft_review() { - draft_review_lines(state) - } else { - forms.display_lines() - }; - frame.render_widget( - Paragraph::new(Text::from( - lines.into_iter().map(Line::from).collect::>(), - )) - .block( - Block::default() - .borders(Borders::ALL) - .title("Management form"), - ) - .wrap(Wrap { trim: false }) - .scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)), - rows[1], - ); - } + forms.display_lines() + }; + frame.render_widget( + Paragraph::new(Text::from( + lines.into_iter().map(Line::from).collect::>(), + )) + .block( + Block::default() + .borders(Borders::ALL) + .title("Management form"), + ) + .wrap(Wrap { trim: false }) + .scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)), + rows[1], + ); } else { match state.screen { Screen::Help => render_help(frame, rows[1]), @@ -376,48 +371,314 @@ fn render_keyboard_panel(frame: &mut Frame<'_>, area: Rect, rows: &[Vec], ); } -fn render_qr_disclosure( +const QR_HEADER_HEIGHT: u16 = 1; +const QR_FOOTER_HEIGHT: u16 = 3; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct QrFit { + terminal_width: usize, + terminal_height: usize, + available_width: usize, + available_height: usize, + required_width: usize, + required_height: usize, + fits: bool, +} + +fn qr_fit(area: Rect, side: usize, notice_rows: usize) -> QrFit { + let terminal_width = area.width as usize; + let terminal_height = area.height as usize; + let available_width = area.width.saturating_sub(2) as usize; + let header_height = QR_HEADER_HEIGHT.min(area.height); + let footer_height = QR_FOOTER_HEIGHT.min(area.height.saturating_sub(header_height)); + let body_height = area + .height + .saturating_sub(header_height) + .saturating_sub(footer_height); + let available_height = (body_height as usize).saturating_sub(2 + notice_rows); + let qr_rows = side.div_ceil(2); + let required_height = + QR_HEADER_HEIGHT as usize + QR_FOOTER_HEIGHT as usize + 2 + notice_rows + qr_rows; + let required_width = side + 2; + QrFit { + terminal_width, + terminal_height, + available_width, + available_height, + required_width, + required_height, + fits: side <= available_width && qr_rows <= available_height, + } +} + +fn render_qr_screen( frame: &mut Frame<'_>, - area: ratatui::layout::Rect, + area: Rect, + state: &UiState, qr: &crate::delivery::QrCodeOutput, lines: &[String], ) { - let inner_width = area.width.saturating_sub(2) as usize; - let header_rows = lines.len(); - let available_height = (area.height as usize).saturating_sub(2 + header_rows); - let side = qr.side(); - let mut text = lines.iter().map(|line| safe_text(line)).collect::>(); - if !qr_fits(side, inner_width, available_height) { - text.push("QR is too large for this terminal. Resize to fit the complete code or use the saved client file.".to_owned()); - } else { - for y in 0..side { - let mut row = String::with_capacity(side * 2); - for x in 0..side { - row.push_str(if qr.module(x, y).unwrap_or(false) { - "██" - } else { - " " - }); + let header_height = QR_HEADER_HEIGHT.min(area.height); + let footer_height = QR_FOOTER_HEIGHT.min(area.height.saturating_sub(header_height)); + let body_height = area + .height + .saturating_sub(header_height) + .saturating_sub(footer_height); + let header = Rect { + height: header_height, + ..area + }; + let body = Rect { + y: area.y.saturating_add(header_height), + height: body_height, + ..area + }; + let footer = Rect { + y: body.y.saturating_add(body_height), + height: footer_height, + ..area + }; + let status = state + .view + .as_ref() + .map_or("Loading saved state".to_owned(), state_summary); + let status = qr_state_summary(state, area.width as usize).unwrap_or(status); + frame.render_widget(Paragraph::new(status), header); + + let inner_width = body.width.saturating_sub(2) as usize; + let notice_lines = lines + .iter() + .filter(|line| { + !line.starts_with("Client configuration QR disclosure (") + && line.as_str() != "Esc closes and clears the QR." + }) + .cloned() + .collect::>(); + let notice_rows = wrap_notice_lines(¬ice_lines, inner_width); + let fit = qr_fit(area, qr.side(), notice_rows.len()); + if !fit.fits { + render_compact_qr_fallback(frame, area, header_height, qr.side(), ¬ice_lines, &fit); + return; + } + let mut content = notice_rows.into_iter().map(Line::from).collect::>(); + if fit.fits { + for y in (0..qr.side()).step_by(2) { + let mut spans = Vec::with_capacity(qr.side()); + for x in 0..qr.side() { + let top = qr.module(x, y).unwrap_or(false); + let bottom = qr.module(x, y + 1).unwrap_or(false); + let (symbol, style) = qr_cell(top, bottom); + spans.push(Span::styled(symbol, style)); } - text.push(row); + content.push(Line::from(spans)); } + } else { + content.push(Line::from(format!( + "QR needs {}×{} cells; terminal is {}×{}; available QR area is {}×{}. Use the saved client file.", + qr.side(), + qr.side().div_ceil(2), + fit.terminal_width, + fit.terminal_height, + fit.available_width, + fit.available_height + ))); } frame.render_widget( - Paragraph::new(Text::from( - text.into_iter().map(Line::from).collect::>(), + Paragraph::new(Text::from(content)) + .block( + Block::default() + .borders(Borders::ALL) + .title("Secret disclosure · Esc clears"), + ) + .wrap(Wrap { trim: false }), + body, + ); + let mut hints = vec![vec![Hint { + key: "Esc", + action: "clear QR", + kind: HintKind::Destructive, + }]]; + if footer.height == 0 { + hints.clear(); + } + render_keyboard_panel(frame, footer, &hints, false); +} + +fn qr_state_summary(state: &UiState, width: usize) -> Option { + let view = state.view.as_ref()?; + let pending = view + .client_states + .values() + .filter(|row| row.draft != row.saved) + .count(); + let saved = view + .client_states + .values() + .filter(|row| row.saved.is_some()) + .count(); + let expanded = state_summary(view); + if Span::raw(expanded.as_str()).width() <= width { + Some(expanded) + } else { + Some(format!( + "Draft {pending} Saved {saved} Applied {:?}", + view.applied )) - .block( - Block::default() - .borders(Borders::ALL) - .title("Secret disclosure · Esc clears"), - ) - .wrap(Wrap { trim: false }), - area, + } +} + +fn render_compact_qr_fallback( + frame: &mut Frame<'_>, + area: Rect, + header_height: u16, + side: usize, + notice_lines: &[String], + fit: &QrFit, +) { + let body = Rect { + y: area.y.saturating_add(header_height), + height: area.height.saturating_sub(header_height), + ..area + }; + let width = body.width as usize; + let required_line = format!("QR needs {}×{}", side, side.div_ceil(2)); + let terminal_line = format!("Terminal {}×{}", fit.terminal_width, fit.terminal_height); + let file_line = if width >= 22 { + "Use saved client file." + } else { + "Use saved file." + }; + let fixed_lines = [ + required_line, + terminal_line, + "Available QR".to_owned(), + file_line.to_owned(), + "Esc clears QR.".to_owned(), + ]; + let fixed_rows = fixed_lines + .iter() + .map(|line| wrap_notice_lines(std::slice::from_ref(line), width).len()) + .sum::(); + let max_notice_rows = (body.height as usize).saturating_sub(fixed_rows); + let raw_notice = notice_lines.join(" "); + let mut notice_rows = wrap_notice_lines(notice_lines, width); + if notice_rows.len() > max_notice_rows { + let compact_notice = compact_delivery_notice(&raw_notice); + notice_rows = wrap_notice_lines(&[compact_notice], width); + } + if notice_rows.len() > max_notice_rows { + notice_rows.truncate(max_notice_rows); + if let Some(last) = notice_rows.last_mut() { + *last = truncate_display_line(last, width.saturating_sub(1)); + last.push('…'); + } + } + // Report the same bordered QR area used by the fit decision, even though + // this fallback text itself uses the full unbordered body. + let available_width = fit.available_width; + let available_height = fit.available_height; + let mut lines = notice_rows.into_iter().map(Line::from).collect::>(); + for (index, line) in fixed_lines.into_iter().enumerate() { + let line = if index == 2 { + format!("Available QR: {available_width}×{available_height}") + } else { + line + }; + lines.extend( + wrap_notice_lines(&[line], width) + .into_iter() + .map(Line::from), + ); + } + frame.render_widget( + Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), + body, ); } -fn qr_fits(side: usize, inner_width: usize, available_height: usize) -> bool { - side.saturating_mul(2) <= inner_width && side <= available_height +fn compact_delivery_notice(notice: &str) -> String { + if notice.contains("may have been published") || notice.contains("bookkeeping is uncertain") { + "Delivery uncertain; inspect destination.".to_owned() + } else if notice.contains("not verified as active") || notice.contains("not yet active") { + "File delivered; access unverified.".to_owned() + } else if notice.contains("file delivered") || notice.contains("reissue completed") { + "Client file delivery completed.".to_owned() + } else { + notice.to_owned() + } +} + +fn truncate_display_line(line: &str, max_width: usize) -> String { + let mut result = String::new(); + let mut used = 0usize; + for character in line.chars() { + let character_width = Span::raw(character.to_string()).width(); + if used.saturating_add(character_width) > max_width { + break; + } + result.push(character); + used += character_width; + } + result +} + +fn wrap_notice_lines(lines: &[String], width: usize) -> Vec { + let width = width.max(1); + let mut wrapped = Vec::new(); + for line in lines { + let safe = safe_text(line); + let mut current = String::new(); + let mut used = 0usize; + for word in safe.split_whitespace() { + let word_width = Span::raw(word).width(); + let gap = usize::from(!current.is_empty()); + if used + gap + word_width <= width { + if gap != 0 { + current.push(' '); + used += 1; + } + current.push_str(word); + used += word_width; + } else if word_width <= width { + if !current.is_empty() { + wrapped.push(std::mem::take(&mut current)); + } + current.push_str(word); + used = word_width; + } else { + if !current.is_empty() { + wrapped.push(std::mem::take(&mut current)); + } + let mut chunk = String::new(); + let mut chunk_width = 0; + for character in word.chars() { + let char_width = Span::raw(character.to_string()).width(); + if chunk_width + char_width > width { + wrapped.push(std::mem::take(&mut chunk)); + chunk_width = 0; + } + chunk.push(character); + chunk_width += char_width; + } + current = chunk; + used = chunk_width; + } + } + if !current.is_empty() || safe.is_empty() { + wrapped.push(current); + } + } + wrapped +} + +fn qr_cell(top: bool, bottom: bool) -> (&'static str, Style) { + let symbol = match (top, bottom) { + (false, false) => " ", + (true, false) => "▀", + (false, true) => "▄", + (true, true) => "█", + }; + (symbol, Style::default().fg(Color::Black).bg(Color::White)) } fn draft_review_lines(state: &UiState) -> Vec { @@ -1438,9 +1699,9 @@ mod tests { .draw(|frame| draw(frame, &UiState::default(), &forms)) .unwrap(); let text = rendered_text(&terminal); - assert!(text.contains("Secret disclosure")); - assert!(text.contains("Esc clears QR") || text.contains("Esc clear QR")); - assert!(text.contains("clear QR")); + assert!(text.contains("saved client file")); + assert!(text.contains("Esc clears QR")); + assert!(text.contains("clears QR")); assert!(!text.contains("synthetic QR disclosure fixture")); let mut compact = Terminal::new(TestBackend::new(34, 8)).unwrap(); @@ -1448,10 +1709,229 @@ mod tests { .draw(|frame| draw(frame, &UiState::default(), &forms)) .unwrap(); let compact_text = rendered_text(&compact); - assert!(compact_text.contains("clear QR")); + assert!(compact_text.contains("Esc clears QR.")); assert!(!compact_text.contains("synthetic QR disclosure fixture")); } + #[test] + fn compact_qr_cell_covers_every_module_pair_with_explicit_colors() { + for (top, bottom, expected) in [ + (false, false, " "), + (true, false, "▀"), + (false, true, "▄"), + (true, true, "█"), + ] { + let (symbol, style) = qr_cell(top, bottom); + assert_eq!(symbol, expected); + assert_eq!(style.fg, Some(Color::Black)); + assert_eq!(style.bg, Some(Color::White)); + } + assert_eq!(qr_cell(true, false).0, "▀"); + assert_eq!(qr_cell(false, false).0, " "); + } + + #[test] + fn seventy_three_module_qr_fits_exact_bordered_layout_and_keeps_quiet_zone() { + let fixture = crate::delivery::qr::synthetic_test_matrix(73); + let exact = Rect::new(0, 0, 75, 44); + let fit = qr_fit(exact, fixture.side(), 1); + assert_eq!(fit.required_width, 75); + assert_eq!(fit.required_height, 44); + assert_eq!((fit.available_width, fit.available_height), (73, 37)); + assert!(fit.fits); + + let mut forms = FormController::default(); + forms.open_disclosure(fixture, "Activation pending until restart.".to_owned()); + let mut terminal = Terminal::new(TestBackend::new(75, 44)).unwrap(); + terminal + .draw(|frame| draw(frame, &UiState::default(), &forms)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let assert_light_module = |x: usize, y: usize| { + let terminal_x = (1 + x) as u16; + let terminal_y = (3 + y / 2) as u16; + let cell = &buffer[(terminal_x, terminal_y)]; + let (top, bottom) = match cell.symbol() { + " " => (false, false), + "▀" => (true, false), + "▄" => (false, true), + "█" => (true, true), + other => panic!("unexpected compact QR cell {other:?}"), + }; + assert_eq!(cell.fg, Color::Black); + assert_eq!(cell.bg, Color::White); + assert!(!if y.is_multiple_of(2) { top } else { bottom }); + }; + for edge in 0..4 { + for coordinate in 0..73 { + assert_light_module(coordinate, edge); + assert_light_module(coordinate, 72 - edge); + assert_light_module(edge, coordinate); + assert_light_module(72 - edge, coordinate); + } + } + // The odd final module row shares a cell with an absent, light row. + let odd_last_row = &buffer[(73, 39)]; + assert_eq!(odd_last_row.symbol(), " "); + assert_eq!(odd_last_row.fg, Color::Black); + assert_eq!(odd_last_row.bg, Color::White); + assert!(rendered_text(&terminal).contains("Esc clear QR")); + } + + #[test] + fn narrow_notice_wraps_and_resize_redraws_the_same_authorized_matrix() { + let notice = "Access remains inactive until the saved configuration is applied by an explicit restart."; + let wrapped = wrap_notice_lines(&[notice.to_owned()], 18); + assert!(wrapped.len() > 1); + assert!( + wrapped + .iter() + .all(|line| Span::raw(line.as_str()).width() <= 18) + ); + + let fixture = crate::delivery::qr::synthetic_test_matrix(73); + let mut forms = FormController::default(); + forms.open_disclosure(fixture, notice.to_owned()); + let side_before = forms.qr_output().map(|matrix| matrix.side()); + + let mut narrow = Terminal::new(TestBackend::new(74, 45)).unwrap(); + narrow + .draw(|frame| draw(frame, &UiState::default(), &forms)) + .unwrap(); + let fallback = rendered_text(&narrow); + assert!(fallback.contains("QR needs 73×37")); + assert!(fallback.contains("Terminal 74×45")); + assert!(fallback.contains("Available QR:")); + assert!(fallback.contains("Available QR: 72×37")); + assert!(fallback.contains("saved client file")); + assert!(fallback.contains("clears QR")); + + let mut resized = Terminal::new(TestBackend::new(75, 60)).unwrap(); + resized + .draw(|frame| draw(frame, &UiState::default(), &forms)) + .unwrap(); + assert!(rendered_text(&resized).contains("▀")); + assert_eq!(forms.qr_output().map(|matrix| matrix.side()), side_before); + + assert!(matches!( + forms.handle_key(crossterm::event::KeyCode::Esc), + super::super::forms::FormAction::Cancel + )); + assert!(forms.qr_output().is_none()); + } + + #[test] + fn short_qr_fallback_keeps_dimensions_delivery_status_file_and_esc_visible() { + let mut forms = FormController::default(); + forms.open_disclosure( + crate::delivery::qr::synthetic_test_matrix(73), + "Activation pending until restart.".to_owned(), + ); + let mut terminal = Terminal::new(TestBackend::new(34, 8)).unwrap(); + terminal + .draw(|frame| draw(frame, &UiState::default(), &forms)) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let text = rendered_text(&terminal); + assert!(text.contains("QR needs 73×37")); + assert!(text.contains("Terminal 34×8")); + assert!(text.contains("Available QR: 32×0")); + assert!(text.contains("Activation pending until restart.")); + assert!(text.contains("Use saved client file.")); + assert!(text.contains("Esc clears QR.")); + assert!(buffer[(0, 0)].symbol().starts_with('L')); + } + + #[test] + fn qr_fallback_reserves_guidance_before_delivery_notice_and_redraws_on_resize() { + let notice = "Client file delivered. Access is not verified as active yet. QR result: Ok. Password-authorized file reissue completed. Access is not verified as active yet. QR result: Ok."; + let mut forms = FormController::default(); + forms.open_disclosure( + crate::delivery::qr::synthetic_test_matrix(73), + notice.to_owned(), + ); + let state = UiState { + view: Some(empty_view()), + ..UiState::default() + }; + + let mut medium = Terminal::new(TestBackend::new(34, 12)).unwrap(); + medium.draw(|frame| draw(frame, &state, &forms)).unwrap(); + let medium_text = rendered_text(&medium); + assert!(medium_text.contains("QR needs 73×37")); + assert!(medium_text.contains("Terminal 34×12")); + assert!(medium_text.contains("Available QR: 32×0")); + assert!(medium_text.contains("Use saved client file.")); + assert!(medium_text.contains("Esc clears QR.")); + assert!(medium_text.contains("not verified as active")); + let status = (0..medium.backend().buffer().area.width) + .map(|x| medium.backend().buffer()[(x, 0)].symbol()) + .collect::(); + assert!(status.contains("Saved 0")); + assert!(status.contains("Applied Unknown")); + + let mut narrow = Terminal::new(TestBackend::new(20, 8)).unwrap(); + narrow.draw(|frame| draw(frame, &state, &forms)).unwrap(); + let narrow_text = rendered_text(&narrow); + assert!(narrow_text.contains("QR needs 73×37")); + assert!(narrow_text.contains("Terminal 20×8")); + assert!(narrow_text.contains("Available QR: 18×0")); + assert!(narrow_text.contains("Use saved file.")); + assert!(narrow_text.contains("Esc clears QR.")); + assert!(narrow_text.contains("access unverified")); + + let mut resized = Terminal::new(TestBackend::new(75, 60)).unwrap(); + resized.draw(|frame| draw(frame, &state, &forms)).unwrap(); + assert!(rendered_text(&resized).contains("▀")); + } + + #[test] + fn uncertain_first_delivery_notice_keeps_inspection_guidance_in_short_fallback() { + let notice = "Client file may have been published but delivery bookkeeping is uncertain. Inspect the destination; fresh password authorization is required. Access is not verified as active yet. QR result: Encoded."; + let mut forms = FormController::default(); + forms.open_disclosure( + crate::delivery::qr::synthetic_test_matrix(73), + notice.to_owned(), + ); + let mut terminal = Terminal::new(TestBackend::new(20, 8)).unwrap(); + terminal + .draw(|frame| draw(frame, &UiState::default(), &forms)) + .unwrap(); + + let text = rendered_text(&terminal); + assert!(text.contains("Delivery uncertain;")); + assert!(text.contains("inspect destination.")); + assert!(!text.contains("File delivered; access unverified.")); + assert!(text.contains("QR needs 73×37")); + assert!(text.contains("Available QR: 18×0")); + assert!(text.contains("Use saved file.")); + assert!(text.contains("Esc clears QR.")); + } + + #[test] + fn qr_screen_keeps_saved_and_applied_summary_visible_in_buffer() { + let mut forms = FormController::default(); + forms.open_disclosure( + crate::delivery::qr::synthetic_test_output(), + "Activation pending".to_owned(), + ); + let state = UiState { + view: Some(empty_view()), + ..UiState::default() + }; + let mut terminal = Terminal::new(TestBackend::new(34, 8)).unwrap(); + terminal.draw(|frame| draw(frame, &state, &forms)).unwrap(); + + let buffer = terminal.backend().buffer(); + let status_row = (0..buffer.area.width) + .map(|x| buffer[(x, 0)].symbol()) + .collect::(); + assert!(status_row.contains("Draft 0")); + assert!(status_row.contains("Saved 0")); + assert!(status_row.contains("Applied Unknown")); + } + #[test] fn below_minimum_terminal_shows_resize_message_without_drawing_footer() { let mut terminal = Terminal::new(TestBackend::new(33, 7)).unwrap(); @@ -1582,10 +2062,10 @@ mod tests { } #[test] - fn qr_fit_requires_the_complete_quiet_zone_in_both_dimensions() { - assert!(qr_fits(25, 50, 25)); - assert!(!qr_fits(25, 49, 25)); - assert!(!qr_fits(25, 50, 24)); + fn qr_fit_requires_complete_compact_matrix_inside_bordered_layout() { + assert!(qr_fit(Rect::new(0, 0, 27, 20), 25, 0).fits); + assert!(!qr_fit(Rect::new(0, 0, 26, 20), 25, 0).fits); + assert!(!qr_fit(Rect::new(0, 0, 27, 18), 25, 0).fits); } #[test]