Fix QR-code generation

This commit is contained in:
2026-10-27 21:21:14 +02:00
parent af2d061880
commit 4bb975f122
4 changed files with 669 additions and 66 deletions
+51 -1
View File
@@ -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 | | 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 | | 11 | Valid subnet routes shown as a capability limitation | 10 | accepted |
| 12 | Aligned, responsive client table | 11 | accepted | | 12 | Aligned, responsive client table | 11 | accepted |
| 13 | Compact terminal QR disclosure | 12 | accepted |
### Milestone 1 — Rust foundation, settings, and domain states ### 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. evidence does not establish physical terminal, Alpine or systemd behavior.
Stop after this milestone; do not resume VM work, commit or deploy. 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 ## 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 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. - 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. - 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 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. - 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 ## Plan maintenance
+59
View File
@@ -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.
+14
View File
@@ -90,6 +90,20 @@ pub(crate) fn synthetic_test_output() -> QrCodeOutput {
encode_bytes(b"synthetic QR disclosure fixture").expect("synthetic QR payload encodes") 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+523 -43
View File
@@ -15,14 +15,13 @@ use super::state::{Screen, SessionView, UiState};
pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) { pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
let area = frame.area(); 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 { 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( frame.render_widget(
Paragraph::new(message) Paragraph::new("Terminal too small. Resize to at least 34 × 8.")
.block( .block(
Block::default() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
@@ -72,9 +71,6 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
); );
if forms.is_active() { if forms.is_active() {
if let Some(qr) = forms.qr_output() {
render_qr_disclosure(frame, rows[1], qr, &forms.display_lines());
} else {
let lines = if forms.is_draft_review() { let lines = if forms.is_draft_review() {
draft_review_lines(state) draft_review_lines(state)
} else { } else {
@@ -93,7 +89,6 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
.scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)), .scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)),
rows[1], rows[1],
); );
}
} else { } else {
match state.screen { match state.screen {
Screen::Help => render_help(frame, rows[1]), Screen::Help => render_help(frame, rows[1]),
@@ -376,48 +371,314 @@ fn render_keyboard_panel(frame: &mut Frame<'_>, area: Rect, rows: &[Vec<Hint>],
); );
} }
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<'_>, frame: &mut Frame<'_>,
area: ratatui::layout::Rect, area: Rect,
state: &UiState,
qr: &crate::delivery::QrCodeOutput, qr: &crate::delivery::QrCodeOutput,
lines: &[String], lines: &[String],
) { ) {
let inner_width = area.width.saturating_sub(2) as usize; let header_height = QR_HEADER_HEIGHT.min(area.height);
let header_rows = lines.len(); let footer_height = QR_FOOTER_HEIGHT.min(area.height.saturating_sub(header_height));
let available_height = (area.height as usize).saturating_sub(2 + header_rows); let body_height = area
let side = qr.side(); .height
let mut text = lines.iter().map(|line| safe_text(line)).collect::<Vec<_>>(); .saturating_sub(header_height)
if !qr_fits(side, inner_width, available_height) { .saturating_sub(footer_height);
text.push("QR is too large for this terminal. Resize to fit the complete code or use the saved client file.".to_owned()); let header = Rect {
} else { height: header_height,
for y in 0..side { ..area
let mut row = String::with_capacity(side * 2); };
for x in 0..side { let body = Rect {
row.push_str(if qr.module(x, y).unwrap_or(false) { y: area.y.saturating_add(header_height),
"██" height: body_height,
} else { ..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::<Vec<_>>();
let notice_rows = wrap_notice_lines(&notice_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(), &notice_lines, &fit);
return;
} }
text.push(row); let mut content = notice_rows.into_iter().map(Line::from).collect::<Vec<_>>();
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));
} }
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( frame.render_widget(
Paragraph::new(Text::from( Paragraph::new(Text::from(content))
text.into_iter().map(Line::from).collect::<Vec<_>>(),
))
.block( .block(
Block::default() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.title("Secret disclosure · Esc clears"), .title("Secret disclosure · Esc clears"),
) )
.wrap(Wrap { trim: false }), .wrap(Wrap { trim: false }),
area, 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<String> {
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
))
}
}
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::<usize>();
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::<Vec<_>>();
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 { fn compact_delivery_notice(notice: &str) -> String {
side.saturating_mul(2) <= inner_width && side <= available_height 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<String> {
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<String> { fn draft_review_lines(state: &UiState) -> Vec<String> {
@@ -1438,9 +1699,9 @@ mod tests {
.draw(|frame| draw(frame, &UiState::default(), &forms)) .draw(|frame| draw(frame, &UiState::default(), &forms))
.unwrap(); .unwrap();
let text = rendered_text(&terminal); let text = rendered_text(&terminal);
assert!(text.contains("Secret disclosure")); assert!(text.contains("saved client file"));
assert!(text.contains("Esc clears QR") || text.contains("Esc clear QR")); assert!(text.contains("Esc clears QR"));
assert!(text.contains("clear QR")); assert!(text.contains("clears QR"));
assert!(!text.contains("synthetic QR disclosure fixture")); assert!(!text.contains("synthetic QR disclosure fixture"));
let mut compact = Terminal::new(TestBackend::new(34, 8)).unwrap(); let mut compact = Terminal::new(TestBackend::new(34, 8)).unwrap();
@@ -1448,10 +1709,229 @@ mod tests {
.draw(|frame| draw(frame, &UiState::default(), &forms)) .draw(|frame| draw(frame, &UiState::default(), &forms))
.unwrap(); .unwrap();
let compact_text = rendered_text(&compact); 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")); 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::<String>();
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::<String>();
assert!(status_row.contains("Draft 0"));
assert!(status_row.contains("Saved 0"));
assert!(status_row.contains("Applied Unknown"));
}
#[test] #[test]
fn below_minimum_terminal_shows_resize_message_without_drawing_footer() { fn below_minimum_terminal_shows_resize_message_without_drawing_footer() {
let mut terminal = Terminal::new(TestBackend::new(33, 7)).unwrap(); let mut terminal = Terminal::new(TestBackend::new(33, 7)).unwrap();
@@ -1582,10 +2062,10 @@ mod tests {
} }
#[test] #[test]
fn qr_fit_requires_the_complete_quiet_zone_in_both_dimensions() { fn qr_fit_requires_complete_compact_matrix_inside_bordered_layout() {
assert!(qr_fits(25, 50, 25)); assert!(qr_fit(Rect::new(0, 0, 27, 20), 25, 0).fits);
assert!(!qr_fits(25, 49, 25)); assert!(!qr_fit(Rect::new(0, 0, 26, 20), 25, 0).fits);
assert!(!qr_fits(25, 50, 24)); assert!(!qr_fit(Rect::new(0, 0, 27, 18), 25, 0).fits);
} }
#[test] #[test]