Fix QR-code generation
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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::*;
|
||||
|
||||
+523
-43
@@ -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,9 +71,6 @@ 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());
|
||||
} else {
|
||||
let lines = if forms.is_draft_review() {
|
||||
draft_review_lines(state)
|
||||
} 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)),
|
||||
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<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<'_>,
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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;
|
||||
}
|
||||
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(
|
||||
Paragraph::new(Text::from(
|
||||
text.into_iter().map(Line::from).collect::<Vec<_>>(),
|
||||
))
|
||||
Paragraph::new(Text::from(content))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Secret disclosure · Esc clears"),
|
||||
)
|
||||
.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 {
|
||||
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<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> {
|
||||
@@ -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::<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]
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user