Fix QR-code generation
This commit is contained in:
@@ -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::*;
|
||||
|
||||
+545
-65
@@ -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::<Vec<_>>(),
|
||||
))
|
||||
.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::<Vec<_>>(),
|
||||
))
|
||||
.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<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;
|
||||
}
|
||||
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));
|
||||
}
|
||||
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::<Vec<_>>(),
|
||||
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<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
|
||||
))
|
||||
.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::<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