Files
improvise/src/ui/import_wizard_ui.rs
T
Claude (agent-faces) 12d976b432 feat(faces): the TUI draws axis, wizard-field, mode and minibuffer colours through face_style (improvise-nn4s.2)
axis_fg, minibuf_fg, field_kind_fg and mode_style are gone, and AxisColor
leaves improvise-core. The category-add prompt turns green, ADR 004
clause 8's one declared exception.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 21:58:01 -07:00

1307 lines
61 KiB
Rust

use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Clear, Widget},
};
use unicode_width::UnicodeWidthStr;
use crate::protocol::face::Face;
use crate::protocol::{WizardCache, WizardStepKind};
use crate::ui::text::wrap;
/// The import wizard, drawn from a [`WizardCache`] (improvise-764) and from
/// no `improvise-io` or model type. See `context/code/tui.md`.
pub struct ImportWizardWidget<'a> {
pub wizard: &'a WizardCache,
}
impl<'a> ImportWizardWidget<'a> {
pub fn new(wizard: &'a WizardCache) -> Self {
Self { wizard }
}
}
impl<'a> Widget for ImportWizardWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let popup_w = area.width.min(80);
let popup_h = area.height.min(30);
let x = area.x + area.width.saturating_sub(popup_w) / 2;
let y = area.y + area.height.saturating_sub(popup_h) / 2;
let popup_area = Rect::new(x, y, popup_w, popup_h);
Clear.render(popup_area, buf);
let title = match self.wizard.step {
WizardStepKind::Preview => " Import Wizard — Preview ",
WizardStepKind::SelectArrayPath => " Import Wizard — Select Array ",
WizardStepKind::ReviewProposals => " Import Wizard — Review Fields ",
WizardStepKind::ConfigureDates => " Import Wizard — Date Components ",
WizardStepKind::ResolveDuplicates => " Import Wizard — Duplicate Records ",
WizardStepKind::NameDuplicateCategory => " Import Wizard — Name the New Category ",
WizardStepKind::DefineFormulas => " Import Wizard — Formulas ",
WizardStepKind::NameModel => " Import Wizard — Name Model ",
WizardStepKind::Done => " Import Wizard — Confirm ",
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta))
.title(title);
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let mut panel = Panel { buf, clip: inner };
let mut y = inner.y;
let x = inner.x;
let w = inner.width as usize;
match &self.wizard.step {
WizardStepKind::Preview => {
let summary = &self.wizard.preview_summary;
panel.put(x, y, truncate(summary, w), Style::default());
y += 2;
panel.put(
x,
y,
"Press Enter to continue\u{2026}",
Style::default().fg(Color::Yellow),
);
}
WizardStepKind::SelectArrayPath => {
panel.put(
x,
y,
"Select the path containing records:",
Style::default().fg(Color::Yellow),
);
y += 1;
for (i, path) in self.wizard.array_paths.iter().enumerate() {
if y >= inner.bottom() {
break;
}
let is_sel = i == self.wizard.cursor;
let style = if is_sel {
Style::default()
.fg(Color::Black)
.bg(Color::Magenta)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
// The root path is already spelled "(root)" by the
// projection, so both ends say it the same way.
let label = format!(" {path}");
panel.put(x, y, truncate(&label, w), style);
y += 1;
}
y += 1;
panel.put(
x,
y,
"\u{2191}\u{2193} select Enter confirm \u{2190} back",
Style::default().fg(Color::DarkGray),
);
}
WizardStepKind::ReviewProposals => {
panel.put(
x,
y,
"Review field proposals (Space toggle, c cycle kind):",
Style::default().fg(Color::Yellow),
);
y += 1;
let header = format!(" {:<20} {:<22} {:<6}", "Field", "Kind", "Accept");
panel.put(
x,
y,
truncate(&header, w),
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::UNDERLINED),
);
y += 1;
for (i, proposal) in self.wizard.proposals.iter().enumerate() {
if y >= inner.bottom().saturating_sub(2) {
break;
}
let is_sel = i == self.wizard.cursor;
let kind_style = crate::draw::face_style(Face::WizardField(proposal.kind));
let accept_str = if proposal.accepted {
"[\u{2713}]"
} else {
"[ ]"
};
let row = format!(
" {:<20} {:<22} {}",
truncate(&proposal.field, 20),
truncate(&proposal.kind_label, 22),
accept_str
);
let style = if is_sel {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else if proposal.accepted {
kind_style
} else {
Style::default().fg(Color::DarkGray)
};
panel.put(x, y, truncate(&row, w), style);
y += 1;
}
let hint_y = inner.bottom().saturating_sub(1);
panel.bottom_line(
x,
hint_y,
w,
"Enter: next Space: toggle c: cycle kind \u{2190} back Esc: cancel",
Style::default().fg(Color::DarkGray),
);
}
WizardStepKind::ConfigureDates => {
panel.put(
x,
y,
"Select date components to extract (Space toggle):",
Style::default().fg(Color::Yellow),
);
y += 1;
let mut item_idx = 0;
for proposal in &self.wizard.date_fields {
if y >= inner.bottom().saturating_sub(2) {
break;
}
let header = format!(" {} (format: {})", proposal.field, proposal.format);
panel.put(
x,
y,
truncate(&header, w),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
);
y += 1;
// Components arrive in the wizard's own order, already
// paired with their state, so the cursor counts these rows.
for (label, enabled) in &proposal.components {
if y >= inner.bottom().saturating_sub(2) {
break;
}
let enabled = *enabled;
let check = if enabled { "[\u{2713}]" } else { "[ ]" };
let row = format!(" {} {}", check, label);
let is_sel = item_idx == self.wizard.cursor;
let style = if is_sel {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else if enabled {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
};
panel.put(x, y, truncate(&row, w), style);
y += 1;
item_idx += 1;
}
}
let hint_y = inner.bottom().saturating_sub(1);
panel.bottom_line(
x,
hint_y,
w,
"Space: toggle Enter: next \u{2190} back Esc: cancel",
Style::default().fg(Color::DarkGray),
);
}
WizardStepKind::ResolveDuplicates => {
let wiz = self.wizard;
let records: usize = wiz.collisions.iter().map(|c| c.records.len()).sum();
let intro = format!(
"{records} records share every coordinate with another, in {} group(s). \
What should the import do with them?",
wiz.collisions.len()
);
y = panel.wrapped(x, y, w, &intro, Style::default().fg(Color::Yellow));
y += 1;
for (i, choice) in wiz.duplicate_choices.iter().enumerate() {
let is_sel = i == wiz.cursor;
let marker = if is_sel { "> " } else { " " };
let label = match &choice.disabled {
Some(_) => format!("{marker}{} (unavailable)", choice.label),
None => format!("{marker}{}", choice.label),
};
let style = match (is_sel, &choice.disabled) {
(true, _) => Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
(false, Some(_)) => Style::default().fg(Color::DarkGray),
(false, None) => Style::default(),
};
y = panel.wrapped(x, y, w, &label, style);
if let Some(reason) = &choice.disabled {
let dim = Style::default().fg(Color::DarkGray);
y = panel.wrapped(x + 4, y, w.saturating_sub(4), reason, dim);
}
}
y += 1;
let rows: Vec<String> = wiz.collisions.iter().map(|c| c.describe()).collect();
let bottom = inner.bottom().saturating_sub(1);
panel.scrolling(
x,
(y, "Colliding records"),
y.saturating_add(1)..bottom,
w,
&rows,
wiz.scroll,
" ",
Style::default(),
);
panel.bottom_line(x,
bottom,
w,
"\u{2191}\u{2193} choose Enter: select PgUp/PgDn: scroll \u{2190} back Esc: cancel",
Style::default().fg(Color::DarkGray),
);
}
WizardStepKind::NameDuplicateCategory => {
panel.put(
x,
y,
"Name the category that tells duplicate records apart:",
Style::default().fg(Color::Yellow),
);
y += 1;
let input = format!("> {}\u{2588}", self.wizard.duplicate_category);
panel.put(x, y, truncate(&input, w), Style::default().fg(Color::Green));
y += 2;
panel.wrapped(
x,
y,
w,
"Records that share every coordinate are numbered 1, 2, 3\u{2026} in \
source order; every other record gets 1. The category starts off the \
grid, so totals look as they would summed.",
Style::default().fg(Color::DarkGray),
);
panel.bottom_line(
x,
inner.bottom().saturating_sub(1),
w,
"Enter: next \u{2190} back Esc: cancel",
Style::default().fg(Color::DarkGray),
);
}
WizardStepKind::DefineFormulas => {
panel.put(
x,
y,
"Define formulas (optional):",
Style::default().fg(Color::Yellow),
);
y += 1;
// Show existing formulas
if self.wizard.formulas.is_empty() && !self.wizard.formula_editing {
panel.put(
x,
y,
" (no formulas yet)",
Style::default().fg(Color::DarkGray),
);
y += 1;
}
for (i, formula) in self.wizard.formulas.iter().enumerate() {
if y >= inner.bottom().saturating_sub(5) {
break;
}
let is_sel = i == self.wizard.cursor && !self.wizard.formula_editing;
let style = if is_sel {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Green)
};
panel.put(x, y, truncate(&format!(" {}", formula), w), style);
y += 1;
}
// Formula input area
if self.wizard.formula_editing {
y += 1;
panel.put(
x,
y,
"Formula (e.g., Profit = Revenue - Cost):",
Style::default().fg(Color::Yellow),
);
y += 1;
let input = format!("> {}\u{2588}", self.wizard.formula_buffer);
panel.put(x, y, truncate(&input, w), Style::default().fg(Color::Green));
y += 1;
}
// Sample formulas
let samples = &self.wizard.sample_formulas;
if !samples.is_empty() {
y += 1;
panel.put(x, y, "Examples:", Style::default().fg(Color::DarkGray));
y += 1;
for sample in samples {
if y >= inner.bottom().saturating_sub(1) {
break;
}
panel.put(
x,
y,
truncate(&format!(" {}", sample), w),
Style::default().fg(Color::DarkGray),
);
y += 1;
}
}
let hint_y = inner.bottom().saturating_sub(1);
let hint = if self.wizard.formula_editing {
"Enter: add Esc: cancel"
} else {
"n: new formula d: delete Enter: next \u{2190} back Esc: cancel"
};
panel.bottom_line(x, hint_y, w, hint, Style::default().fg(Color::DarkGray));
}
WizardStepKind::NameModel => {
panel.put(x, y, "Model name:", Style::default().fg(Color::Yellow));
y += 1;
let name_str = format!("> {}\u{2588}", self.wizard.model_name);
panel.put(
x,
y,
truncate(&name_str, w),
Style::default().fg(Color::Green),
);
y += 2;
panel.put(
x,
y,
"Enter: build \u{2190} back Esc: cancel",
Style::default().fg(Color::DarkGray),
);
}
WizardStepKind::Done => {
// The workbook is built but not yet installed — show what the
// user is about to accept, warnings included (improvise-907).
match &self.wizard.built_summary {
Some(summary) => {
panel.put(
x,
y,
truncate(summary, w),
Style::default().fg(Color::Green),
);
y += 2;
let bottom = inner.bottom().saturating_sub(1);
let warnings = &self.wizard.built_warnings;
if !warnings.is_empty() {
panel.scrolling(
x,
(y, "Warnings"),
y.saturating_add(1)..bottom,
w,
warnings,
self.wizard.scroll,
"! ",
Style::default().fg(Color::Yellow),
);
}
}
None => panel.put(x, y, "Nothing to confirm.", Style::default().fg(Color::Red)),
}
let hint_y = inner.bottom().saturating_sub(1);
panel.bottom_line(
x,
hint_y,
w,
"Enter: accept \u{2190}/Esc: back",
Style::default().fg(Color::DarkGray),
);
}
}
// The wizard message takes the bottom line over the key hint, wrapping
// upward from there rather than being cut (improvise-cgqt).
if let Some(msg) = &self.wizard.message {
let lines = wrap(msg, w);
let bottom = inner.bottom().saturating_sub(1);
let first = bottom.saturating_sub(lines.len() as u16 - 1).max(inner.y);
for (row, line) in (first..=bottom).zip(&lines) {
panel.bottom_line(x, row, w, line, Style::default().fg(Color::Red));
}
}
}
}
/// The wizard's drawing surface. Every write is clipped to the panel's rows
/// and width, so no step can reach past the buffer (review B2 on #304).
struct Panel<'b> {
buf: &'b mut Buffer,
clip: Rect,
}
impl Panel<'_> {
fn put(&mut self, x: u16, y: u16, text: impl AsRef<str>, style: Style) {
let inside = (self.clip.y..self.clip.bottom()).contains(&y) && x < self.clip.right();
if inside {
let room = usize::from(self.clip.right() - x);
self.buf.set_stringn(x, y, text, room, style);
}
}
fn bottom_line(&mut self, x: u16, y: u16, w: usize, text: &str, style: Style) {
if (self.clip.y..self.clip.bottom()).contains(&y) {
set_bottom_line(self.buf, x, y, w, text, style);
}
}
/// Draw `text` wrapped to `w` cells from row `y`; returns the row after it.
fn wrapped(&mut self, x: u16, y: u16, w: usize, text: &str, style: Style) -> u16 {
let lines = wrap(text, w);
for (i, line) in lines.iter().enumerate() {
self.put(x, y.saturating_add(i as u16), line, style);
}
y.saturating_add(lines.len() as u16)
}
/// Draw `items` from `scroll` on, each wrapped whole, in `rows`. Which of
/// them are shown is said on the `header` row, where no item can cover it.
#[allow(clippy::too_many_arguments)]
fn scrolling(
&mut self,
x: u16,
header: (u16, &str),
rows: std::ops::Range<u16>,
w: usize,
items: &[String],
scroll: usize,
prefix: &str,
style: Style,
) {
let mut y = rows.start;
let mut shown = scroll;
for item in items.iter().skip(scroll) {
let lines = wrap(&format!("{prefix}{item}"), w);
let room = usize::from(rows.end.saturating_sub(y));
if lines.len() > room && shown > scroll {
break;
}
for line in lines.iter().take(room) {
self.put(x, y, line, style);
y += 1;
}
shown += 1;
}
let (row, title) = header;
let label = match (scroll, shown) {
(0, n) if n == items.len() => format!("{title}:"),
(first, last) => format!(
"{title} {}\u{2013}{last} of {} (PgUp/PgDn):",
first + 1,
items.len()
),
};
self.put(x, row, label, Style::default().fg(Color::Gray));
}
}
/// The ONE writer for the wizard's bottom row, padding in terminal cells
/// (improvise-q0vb). Both halves are argued in `context/code/tui.md`.
fn set_bottom_line(buf: &mut Buffer, x: u16, y: u16, w: usize, text: &str, style: Style) {
let text = crate::ui::text::truncate(text, w);
let pad = " ".repeat(w.saturating_sub(UnicodeWidthStr::width(text.as_str())));
buf.set_string(x, y, format!("{text}{pad}"), style);
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else if max > 1 {
format!("{}\u{2026}", prefix(s, max - 1))
} else {
prefix(s, max).to_string()
}
}
/// The longest prefix of `s` at most `max` **bytes** ending on a char
/// boundary. Bytes and not columns, deliberately — `context/code/tui.md`.
fn prefix(s: &str, max: usize) -> &str {
let mut end = max.min(s.len());
while !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
#[cfg(test)]
mod truncate_tests {
use super::truncate;
/// `"Régions"` is 8 bytes for 7 characters, so `max = 3` cuts inside the
/// é. The bug is in `context/test-evidence/tui.md`.
#[test]
fn a_cut_inside_a_multibyte_character_does_not_panic() {
assert_eq!(truncate("Régions", 3), "R\u{2026}");
}
/// Not one cut point, every cut point; and each result must be a PREFIX,
/// which is what says the search walked down. `context/test-evidence/tui.md`.
#[test]
fn no_cut_point_of_a_multibyte_string_panics() {
let s = "Ünïcödé";
for max in 0..=s.len() + 2 {
let out = truncate(s, max);
let body = out.strip_suffix('\u{2026}').unwrap_or(&out);
assert!(
s.starts_with(body),
"truncate({s:?}, {max}) = {out:?}, which is not a prefix of the input"
);
}
}
/// ASCII is unaffected, which is what lets the characterization fixtures
/// below keep asserting frames written before this change.
#[test]
fn ascii_truncation_is_unchanged() {
assert_eq!(truncate("cancel", 10), "cancel");
assert_eq!(truncate("cancel", 4), "can\u{2026}");
assert_eq!(truncate("cancel", 1), "c");
assert_eq!(truncate("cancel", 0), "");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::import::wizard::{ImportWizard, WizardStep};
use improvise_command::ui::project;
use ratatui::widgets::Widget;
use serde_json::json;
/// A category, a measure and a date column, so the analyzer proposes one
/// of each kind and the date step has something to configure.
fn wizard() -> ImportWizard {
ImportWizard::new(json!([
{"Region": "East", "Revenue": 100, "Cost": 60, "When": "2024-01-15"},
{"Region": "West", "Revenue": 50, "Cost": 20, "When": "2024-04-02"},
]))
}
fn buf_text(buf: &Buffer) -> String {
let w = buf.area().width as usize;
buf.content()
.chunks(w)
.map(|row| {
// A double-width character's second cell is padding, not text.
let mut skip = 0;
let mut text = String::new();
for cell in row {
match skip {
0 => {
text.push_str(cell.symbol());
skip = UnicodeWidthStr::width(cell.symbol()).saturating_sub(1);
}
_ => skip -= 1,
}
}
text.trim_end().to_string()
})
.collect::<Vec<_>>()
.join("\n")
.trim_end()
.to_string()
}
/// Drive a real `ImportWizard` through the projection — the whole path
/// the TUI takes, never a handcrafted cache (`context/test-evidence/tui.md`).
fn render(w: &ImportWizard) -> String {
let area = Rect::new(0, 0, 80, 22);
let mut buf = Buffer::empty(area);
let cache = project::wizard_cache(w);
ImportWizardWidget::new(&cache).render(area, &mut buf);
buf_text(&buf)
}
/// Every step the wizard can draw, byte for byte, against the
/// pre-improvise-764 path. `context/test-evidence/tui.md`.
#[test]
fn every_step_draws_what_it_drew_before() {
let mut w = wizard();
let mut seen = Vec::new();
// Bounded, and stops when `advance` no longer moves: the transitions
// are conditional (improvise-907) and a refusal must end the walk.
for _ in 0..16 {
seen.push(format!("{:?}\n{}", w.step, render(&w)));
let before = format!("{:?}", w.step);
match w.step {
// `NameModel` is terminal for `advance` (gotcha 22): building
// is what parks the workbook on `Done`.
WizardStep::NameModel => w.build(),
_ => w.advance(),
}
if format!("{:?}", w.step) == before {
break;
}
}
assert_eq!(seen.join("\n\n====\n\n"), EVERY_STEP);
}
/// The transient bits of `DefineFormulas`: a formula in the list, one
/// being typed, and a message overriding the hint line.
#[test]
fn the_formula_step_draws_its_transient_state() {
let mut w = wizard();
while !matches!(w.step, WizardStep::DefineFormulas) {
w.advance();
}
w.start_formula_edit();
for c in "Profit = Revenue - Cost".chars() {
w.push_formula_char(c);
}
w.confirm_formula();
w.start_formula_edit();
for c in "Marg".chars() {
w.push_formula_char(c);
}
w.message = Some("something went wrong".to_string());
assert_eq!(render(&w), FORMULA_STEP);
}
/// improvise-q0vb, on `ReviewProposals` because its hint is the longest
/// in the wizard. `context/test-evidence/tui.md`.
#[test]
fn a_message_shorter_than_the_hint_leaves_none_of_it() {
const HINT: &str = "Enter: next Space: toggle c: cycle kind ← back Esc: cancel";
let mut w = wizard();
assert!(matches!(w.step, WizardStep::ReviewProposals));
assert!(
render(&w).lines().any(|l| l.contains(HINT)),
"precondition: this step draws the long hint"
);
w.message = Some("short".to_string());
let drawn = render(&w);
let bottom = drawn.lines().nth_back(1).expect("the row above the border");
assert_eq!(
bottom.trim_end_matches([' ', '│']).trim_start_matches('│'),
"short"
);
assert!(
"short".len() < HINT.len(),
"precondition: the message is shorter than what it replaces"
);
}
/// Cells, not characters (Copilot, PR #22). Asserted against an `X`
/// sentinel, and why that is required: `context/test-evidence/tui.md`.
#[test]
fn set_bottom_line_clears_every_cell_it_covers_and_no_more() {
const W: usize = 18;
for text in [
"\u{5e7f}\u{5ea6}\u{5e7f}\u{5ea6}\u{5e7f}\u{5ea6}", // 6 chars, 12 cells
"e\u{0301}e\u{0301}e\u{0301}", // 6 chars, 3 cells
"short", // 5 chars, 5 cells
] {
let mut buf = Buffer::empty(Rect::new(0, 0, W as u16 + 2, 1));
buf.set_string(0, 0, "X".repeat(W + 2), Style::default());
set_bottom_line(&mut buf, 1, 0, W, text, Style::default());
let row: Vec<&str> = (0..W as u16 + 2).map(|i| buf[(i, 0)].symbol()).collect();
assert_eq!(row[0], "X", "{text:?} wrote left of the panel: {row:?}");
assert_eq!(
row[W + 1],
"X",
"{text:?} overran the panel's right border: {row:?}"
);
assert!(
!row[1..=W].contains(&"X"),
"{text:?} left a cell it should have cleared: {row:?}"
);
}
}
/// One of the two steps the flat-array fixture skips, and the only step
/// that draws `pipeline.array_paths`.
#[test]
fn the_array_path_step_draws_its_candidates() {
let w = ImportWizard::new(json!({
"sales": [{"Region": "East", "Revenue": 1}],
"costs": [{"Region": "West", "Cost": 2}],
}));
assert!(matches!(w.step, WizardStep::SelectArrayPath));
assert_eq!(render(&w), ARRAY_PATH_STEP);
}
/// The one step `every_step_draws_what_it_drew_before` never reaches
/// (improvise-g6s1) — and a wart it pins: `context/test-evidence/tui.md`.
#[test]
fn the_preview_step_draws_an_empty_document() {
let w = ImportWizard::new(json!([]));
assert!(
matches!(w.step, WizardStep::Preview),
"precondition: an empty array must enter at Preview, got {:?}",
w.step
);
assert_eq!(render(&w), PREVIEW_STEP);
}
/// Every whitespace-separated word of `text` is on screen, in order —
/// the property "wrapped, not truncated" (improvise-cgqt).
fn assert_drawn_whole(drawn: &str, text: &str) {
let flat: String = drawn
.lines()
.map(|l| l.trim_matches(|c| c == '│' || c == ' '))
.collect::<Vec<_>>()
.join(" ");
let mut rest = flat.as_str();
for word in text.split_whitespace() {
let at = rest
.find(word)
.unwrap_or_else(|| panic!("{word:?} of {text:?} is not drawn:\n{drawn}"));
rest = &rest[at + word.len()..];
}
}
fn records(pairs: &[(&str, f64)]) -> ImportWizard {
ImportWizard::new(serde_json::Value::Array(
pairs
.iter()
.map(|(r, v)| json!({"Region": r, "Revenue": v}))
.collect(),
))
}
/// Walk a colliding import to `Done` by summing, so it carries the long
/// collision warning. `context/test-evidence/tui.md`.
#[test]
fn a_warning_longer_than_the_panel_is_drawn_whole() {
let mut w = records(&[
("Quokka-Territory-North-Alpha", 1.0),
("Quokka-Territory-North-Alpha", 2.0),
("Wombat-Province-Southern-Beta", 3.0),
("Wombat-Province-Southern-Beta", 4.0),
]);
w.advance();
assert!(matches!(w.step, WizardStep::ResolveDuplicates), "setup");
w.choose_duplicate();
w.advance();
w.build();
assert!(matches!(w.step, WizardStep::Done), "setup: {:?}", w.message);
let warning = project::wizard_cache(&w).built_warnings[0].clone();
assert!(
warning.len() > 80,
"precondition: wider than the panel: {warning}"
);
assert_drawn_whole(&render(&w), &warning);
}
#[test]
fn a_message_longer_than_the_panel_is_drawn_whole() {
let mut w = wizard();
let msg = "Too many categories: thirteen proposed, and the model allows twelve. \
Mark fields as Measure (c) or toggle them off (Space) before going on.";
w.message = Some(msg.to_string());
assert_drawn_whole(&render(&w), msg);
}
#[test]
fn the_duplicates_step_lists_every_colliding_record() {
let mut w = records(&[
("East", 1.0),
("West", 2.0),
("East", 3.0),
("North", 4.0),
("West", 5.0),
("East", 6.0),
]);
w.advance();
let drawn = render(&w);
let cache = project::wizard_cache(&w);
assert_eq!(cache.collisions.len(), 2, "setup");
for c in &cache.collisions {
assert_drawn_whole(&drawn, &c.describe());
}
assert!(
drawn.contains("3 records (#1, #3, #6): Region=East"),
"{drawn}"
);
assert!(
!drawn.contains("North"),
"a record that collides with nothing: {drawn}"
);
}
#[test]
fn scrolling_reaches_the_last_collision() {
let pairs: Vec<(String, f64)> = (0..14)
.flat_map(|i| {
let r = format!("Region-{i:02}");
[(r.clone(), 1.0), (r, 2.0)]
})
.collect();
let borrowed: Vec<(&str, f64)> = pairs.iter().map(|(r, v)| (r.as_str(), *v)).collect();
let mut w = records(&borrowed);
w.advance();
let first = render(&w);
assert!(first.contains("Region=Region-00"), "{first}");
assert!(
!first.contains("Region=Region-13"),
"precondition: it overflows\n{first}"
);
assert!(first.contains("Colliding records 1\u{2013}"), "{first}");
assert!(first.contains(" of 14 (PgUp/PgDn):"), "{first}");
w.scroll(100);
let last = render(&w);
assert!(last.contains("Region=Region-13"), "{last}");
assert!(
last.contains("Colliding records 14\u{2013}14 of 14"),
"{last}"
);
}
/// Codex P2 on PR #304: an item that fills the list exactly left the "more
/// below" marker on the hint row, drawn over. `context/test-evidence/tui.md`.
#[test]
fn a_first_item_that_fills_the_list_still_says_more_follow() {
let area = Rect::new(0, 0, 10, 6);
let mut buf = Buffer::empty(area);
let mut panel = Panel {
buf: &mut buf,
clip: area,
};
let items = ["aaaa bbbb cccc".to_string(), "dddd".to_string()];
panel.scrolling(0, (0, "L"), 1..4, 4, &items, 0, "", Style::default());
set_bottom_line(&mut buf, 0, 4, 10, "hint", Style::default());
let drawn = buf_text(&buf);
let rows: Vec<&str> = drawn.lines().collect();
assert_eq!(
rows[1..4],
["aaaa", "bbbb", "cccc"],
"the first item whole:\n{drawn}"
);
assert!(rows[0].starts_with("L 1\u{2013}1 of 2"), "{drawn}");
}
#[test]
fn keeping_separate_at_the_cap_is_drawn_unavailable_with_its_reason() {
let recs: Vec<serde_json::Value> = (0..3)
.chain([0])
.map(|r| {
let mut m = serde_json::Map::new();
for i in 0..crate::model::MAX_CATEGORIES {
m.insert(format!("c{i:02}"), json!(format!("v{r}")));
}
m.insert("amount".into(), json!(1.0));
serde_json::Value::Object(m)
})
.collect();
let mut w = ImportWizard::new(serde_json::Value::Array(recs));
w.advance();
assert!(matches!(w.step, WizardStep::ResolveDuplicates), "setup");
let drawn = render(&w);
let reason = project::wizard_cache(&w).duplicate_choices[1]
.disabled
.clone()
.expect("setup: disabled at the cap");
assert!(drawn.contains("(unavailable)"), "{drawn}");
assert_drawn_whole(&drawn, &reason);
}
fn render_at(w: &ImportWizard, width: u16, height: u16) -> String {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
let cache = project::wizard_cache(w);
ImportWizardWidget::new(&cache).render(area, &mut buf);
buf_text(&buf)
}
/// The reported import: a full set of categories and a collision.
fn wizard_at_the_cap() -> ImportWizard {
let recs: Vec<serde_json::Value> = (0..3)
.chain([0])
.map(|r| {
let mut m = serde_json::Map::new();
for i in 0..crate::model::MAX_CATEGORIES {
m.insert(format!("c{i:02}"), json!(format!("v{r}")));
}
m.insert("amount".into(), json!(1.0));
serde_json::Value::Object(m)
})
.collect();
let mut w = ImportWizard::new(serde_json::Value::Array(recs));
w.advance();
assert!(matches!(w.step, WizardStep::ResolveDuplicates), "setup");
w
}
/// Review B1 on PR #304: the cap reason carries a `←`, three bytes wide,
/// and a byte-measured bottom row cut it. `context/test-evidence/tui.md`.
#[test]
fn a_multibyte_message_is_drawn_whole_at_every_width() {
let mut w = wizard_at_the_cap();
w.move_cursor(1);
w.choose_duplicate();
let reason = w
.message
.clone()
.expect("setup: choosing Separate at the cap");
assert!(reason.contains('\u{2190}'), "setup: {reason}");
let messages = [
reason,
"Région 日本語 🎉 naïve café — ← → Skip, Measure, Sum".repeat(3),
];
for msg in messages {
w.message = Some(msg.clone());
for width in 30..=100u16 {
let drawn = render_at(&w, width, 30);
let flat: String = drawn.lines().collect();
assert!(
!flat.contains('\u{2026}'),
"width {width} truncated:\n{drawn}"
);
assert_drawn_whole(&drawn, &msg);
}
}
}
/// Every step the wizard can reach, as the TUI would draw it: each a fresh
/// wizard walked by `advance`, `choose` or `build` in turn.
fn every_step() -> Vec<ImportWizard> {
#[derive(Clone, Copy)]
enum Op {
Advance,
Separate,
Sum,
Build,
}
let colliding = || {
ImportWizard::new(json!([
{"Region": "East", "Revenue": 1.0}, {"Region": "East", "Revenue": 2.0}
]))
};
let walk = |mut w: ImportWizard, ops: &[Op]| {
for op in ops {
match op {
Op::Advance => w.advance(),
Op::Separate => {
w.move_cursor(1);
w.choose_duplicate();
}
Op::Sum => w.choose_duplicate(),
Op::Build => w.build(),
}
}
w
};
use Op::*;
vec![
ImportWizard::new(json!([])),
ImportWizard::new(json!({
"sales": [{"Region": "East", "Revenue": 1}],
"costs": [{"Region": "West", "Cost": 2}],
})),
wizard(),
walk(wizard(), &[Advance]),
walk(wizard(), &[Advance, Advance]),
walk(wizard(), &[Advance, Advance, Advance]),
walk(wizard(), &[Advance, Advance, Advance, Build]),
walk(colliding(), &[Advance]),
walk(colliding(), &[Advance, Separate]),
walk(colliding(), &[Advance, Sum, Advance, Build]),
wizard_at_the_cap(),
walk(wizard_at_the_cap(), &[Separate]),
{
let mut w = walk(wizard(), &[Advance, Advance]);
for formula in ["Profit = Revenue - Cost", "Margin = Profit / Revenue"] {
w.start_formula_edit();
formula.chars().for_each(|c| w.push_formula_char(c));
w.confirm_formula();
}
w.start_formula_edit();
w.push_formula_char('M');
w
},
]
}
/// Review B2 on PR #304: the duplicates step wrote past the panel and
/// ratatui panicked. Every step, every small size. `context/test-evidence/tui.md`.
#[test]
fn no_step_panics_on_a_small_terminal() {
let steps = every_step();
let kinds: Vec<String> = steps.iter().map(|w| format!("{:?}", w.step)).collect();
for kind in [
"Preview",
"SelectArrayPath",
"ReviewProposals",
"ConfigureDates",
"ResolveDuplicates",
"NameDuplicateCategory",
"DefineFormulas",
"NameModel",
"Done",
] {
assert!(
kinds.iter().any(|k| k == kind),
"setup: no {kind} in {kinds:?}"
);
}
let sweep = |cache: &crate::protocol::WizardCache, kind: &str| {
let mut panics = vec![];
for width in 20..=100u16 {
for height in 3..=20u16 {
let drawn = std::panic::catch_unwind(|| {
let area = Rect::new(0, 0, width, height);
ImportWizardWidget::new(cache).render(area, &mut Buffer::empty(area));
});
if drawn.is_err() {
panics.push(format!("{kind} {width}x{height}"));
}
}
}
panics
};
let caches: Vec<_> = steps.iter().map(project::wizard_cache).collect();
let panics: Vec<String> = std::thread::scope(|scope| {
let running: Vec<_> = caches
.iter()
.zip(&kinds)
.map(|(cache, kind)| scope.spawn(move || sweep(cache, kind)))
.collect();
running
.into_iter()
.flat_map(|t| t.join().expect("the sweep itself does not panic"))
.collect()
});
assert!(panics.is_empty(), "{} panics: {:?}", panics.len(), panics);
}
const PREVIEW_STEP: &str = concat!(
"┌ Import Wizard — Preview ─────────────────────────────────────────────────────┐\n",
"│Array of 0 records. Sample keys: │\n",
"│ │\n",
"│Press Enter to continue… │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"└──────────────────────────────────────────────────────────────────────────────┘",
);
const ARRAY_PATH_STEP: &str = concat!(
"┌ Import Wizard — Select Array ────────────────────────────────────────────────┐\n",
"│Select the path containing records: │\n",
"│ costs │\n",
"│ sales │\n",
"│ │\n",
"│↑↓ select Enter confirm ← back │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"└──────────────────────────────────────────────────────────────────────────────┘",
);
const EVERY_STEP: &str = concat!(
"ReviewProposals\n",
"┌ Import Wizard — Review Fields ───────────────────────────────────────────────┐\n",
"│Review field proposals (Space toggle, c cycle kind): │\n",
"│ Field Kind Accept │\n",
"│ Cost Measure (numeric) [✓] │\n",
"│ Region Category (dimension) [✓] │\n",
"│ Revenue Measure (numeric) [✓] │\n",
"│ When Time Category [✓] │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│Enter: next Space: toggle c: cycle kind ← back Esc: cancel │\n",
"└──────────────────────────────────────────────────────────────────────────────┘\n",
"\n",
"====\n",
"\n",
"ConfigureDates\n",
"┌ Import Wizard — Date Components ─────────────────────────────────────────────┐\n",
"│Select date components to extract (Space toggle): │\n",
"│ When (format: %Y-%m-%d) │\n",
"│ [ ] Year │\n",
"│ [ ] Month │\n",
"│ [ ] Quarter │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│Space: toggle Enter: next ← back Esc: cancel │\n",
"└──────────────────────────────────────────────────────────────────────────────┘\n",
"\n",
"====\n",
"\n",
"DefineFormulas\n",
"┌ Import Wizard — Formulas ────────────────────────────────────────────────────┐\n",
"│Define formulas (optional): │\n",
"│ (no formulas yet) │\n",
"│ │\n",
"│Examples: │\n",
"│ Diff = Cost - Revenue │\n",
"│ Total = SUM(Cost) │\n",
"│ Ratio = Cost / Revenue │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│n: new formula d: delete Enter: next ← back Esc: cancel │\n",
"└──────────────────────────────────────────────────────────────────────────────┘\n",
"\n",
"====\n",
"\n",
"NameModel\n",
"┌ Import Wizard — Name Model ──────────────────────────────────────────────────┐\n",
"│Model name: │\n",
"│> Imported Model█ │\n",
"│ │\n",
"│Enter: build ← back Esc: cancel │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"└──────────────────────────────────────────────────────────────────────────────┘\n",
"\n",
"====\n",
"\n",
"Done\n",
"┌ Import Wizard — Confirm ─────────────────────────────────────────────────────┐\n",
"│Imported Model — 2 categories, 2 measures │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│Enter: accept ←/Esc: back │\n",
"└──────────────────────────────────────────────────────────────────────────────┘",
);
const FORMULA_STEP: &str = concat!(
"┌ Import Wizard — Formulas ────────────────────────────────────────────────────┐\n",
"│Define formulas (optional): │\n",
"│ Profit = Revenue - Cost │\n",
"│ │\n",
"│Formula (e.g., Profit = Revenue - Cost): │\n",
"│> Marg█ │\n",
"│ │\n",
"│Examples: │\n",
"│ Diff = Cost - Revenue │\n",
"│ Total = SUM(Cost) │\n",
"│ Ratio = Cost / Revenue │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
"│ │\n",
// improvise-q0vb: this line used to read `something went wrongcel`.
// The only one of the eight pinned frames that commit moved.
"│something went wrong │\n",
"└──────────────────────────────────────────────────────────────────────────────┘",
);
}