1use crate::complete;
10use crate::edit::LineEdit;
11use crossterm::event::KeyCode;
12#[cfg(test)]
13use improv_core_model::Coordinate;
14use improv_core_model::{
15 parser, CategoryId, Filter, Formula, ItemId, MeasureId, MeasureKind, Model, Name, ParseError,
16 Value, ValueType, View, ViewId,
17};
18use improv_engine::session::Engine;
19use improv_engine::{decode_coord, encode_coord, CellValue, CoordKey};
20use improv_storage_csv::{export_measure_csv, import_csv, ColumnRef, DimensionMapping, ImportSpec};
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23
24pub type Snapshot = improv_engine::session::Snapshot;
28
29pub struct Grid {
31 pub measure: MeasureId,
32 pub measure_name: String,
33 pub row_cat: Option<(CategoryId, String)>,
35 pub col_cat: Option<(CategoryId, String)>,
37 pub rows: Vec<(ItemId, String)>,
40 pub cols: Vec<(ItemId, String)>,
42 pub pages: Vec<PageDim>,
44 pub cells: Vec<Vec<Option<CellValue>>>,
48}
49
50pub struct PageDim {
54 pub cat: CategoryId,
55 pub cat_name: String,
56 pub item_name: String,
57 pub item: ItemId,
58 pub item_index: usize,
59 pub item_count: usize,
60}
61
62#[derive(Clone, Copy, Debug)]
65pub struct GridGeom {
66 pub x0: u16,
68 pub y0: u16,
69 pub col_w: u16,
71 pub spacing: u16,
72 pub row0: usize,
74 pub col0: usize,
75 pub rows: usize,
77 pub cols: usize,
78}
79
80impl Grid {
81 pub fn n_rows(&self) -> usize {
82 self.rows.len()
83 }
84 pub fn n_cols(&self) -> usize {
85 self.cols.len()
86 }
87 pub fn value_at(&self, row: usize, col: usize) -> Option<f64> {
90 self.cell_at(row, col).and_then(|v| v.as_num())
91 }
92 pub fn cell_at(&self, row: usize, col: usize) -> Option<&CellValue> {
94 self.cells
95 .get(row)
96 .and_then(|r| r.get(col))
97 .and_then(|c| c.as_ref())
98 }
99 pub fn display_at(&self, row: usize, col: usize) -> String {
101 match self.cell_at(row, col) {
102 Some(v) => v.to_string(),
103 None => String::new(),
104 }
105 }
106 pub fn coord_key(&self, row: usize, col: usize) -> CoordKey {
108 cell_key(
109 &self.row_cat,
110 &self.col_cat,
111 &self.rows,
112 &self.cols,
113 &self.pages,
114 row,
115 col,
116 )
117 }
118}
119
120fn items_of(model: &Model, cat: CategoryId, filters: &[Filter]) -> Vec<(ItemId, String)> {
124 let keep = |id: ItemId| match filters.iter().find(|f| f.category == cat) {
125 Some(f) => f.items.contains(&id),
126 None => true,
127 };
128 model
129 .categories
130 .get(&cat)
131 .map(|c| {
132 c.items
133 .iter()
134 .filter(|id| keep(**id))
135 .map(|id| {
136 let name = model
137 .items
138 .get(id)
139 .map(|it| it.name.0.clone())
140 .unwrap_or_else(|| id.0.to_string());
141 (*id, name)
142 })
143 .collect()
144 })
145 .unwrap_or_default()
146}
147
148fn values_for(
154 model: &Model,
155 measure: MeasureId,
156 snapshot: &Snapshot,
157) -> HashMap<CoordKey, CellValue> {
158 let is_derived = model.measures.get(&measure).map(|m| m.is_derived());
159 match is_derived {
160 Some(true) => snapshot
162 .get(&measure)
163 .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
164 .unwrap_or_default(),
165 _ => model
167 .inputs
168 .iter()
169 .filter(|((mid, _), _)| *mid == measure)
170 .filter_map(|((_, coord), v)| {
171 CellValue::from_model_value(v).map(|cv| (encode_coord(coord), cv))
172 })
173 .collect(),
174 }
175}
176
177pub fn build_grid(model: &Model, measure: MeasureId, snapshot: &Snapshot) -> Grid {
180 build_grid_paged(model, measure, snapshot, &[])
181}
182
183pub fn build_grid_paged(
188 model: &Model,
189 measure: MeasureId,
190 snapshot: &Snapshot,
191 page_idx: &[usize],
192) -> Grid {
193 build_grid_pivoted(model, measure, snapshot, page_idx, None, &[])
194}
195
196pub fn build_grid_pivoted(
203 model: &Model,
204 measure: MeasureId,
205 snapshot: &Snapshot,
206 page_idx: &[usize],
207 axis_order: Option<&[CategoryId]>,
208 filters: &[Filter],
209) -> Grid {
210 let m = model.measures.get(&measure);
211 let measure_name = m
212 .map(|m| m.name.0.clone())
213 .unwrap_or_else(|| measure.0.to_string());
214 let natural: Vec<CategoryId> = m.map(|m| m.categories.clone()).unwrap_or_default();
215 let cats: Vec<CategoryId> = match axis_order {
218 Some(order)
219 if order.len() == natural.len() && natural.iter().all(|c| order.contains(c)) =>
220 {
221 order.to_vec()
222 }
223 _ => natural,
224 };
225
226 let cat_name = |c: CategoryId| {
227 model
228 .categories
229 .get(&c)
230 .map(|x| x.name.0.clone())
231 .unwrap_or_else(|| c.0.to_string())
232 };
233
234 let row_cat = cats.first().map(|c| (*c, cat_name(*c)));
235 let col_cat = cats.get(1).map(|c| (*c, cat_name(*c)));
236
237 let rows = match row_cat {
239 Some((c, _)) => items_of(model, c, filters),
240 None => vec![(ItemId(0), String::new())],
241 };
242 let cols = match col_cat {
243 Some((c, _)) => items_of(model, c, filters),
244 None => vec![(ItemId(0), String::new())],
245 };
246
247 let mut pages = Vec::new();
249 for c in cats.iter().skip(2) {
250 let its = items_of(model, *c, filters);
251 if its.is_empty() {
252 continue;
253 }
254 let sel = page_idx
257 .get(pages.len())
258 .copied()
259 .unwrap_or(0)
260 .min(its.len() - 1);
261 let (id, name) = its[sel].clone();
262 pages.push(PageDim {
263 cat: *c,
264 cat_name: cat_name(*c),
265 item_name: name,
266 item: id,
267 item_index: sel,
268 item_count: its.len(),
269 });
270 }
271
272 let values = values_for(model, measure, snapshot);
273
274 let mut cells = Vec::with_capacity(rows.len());
276 for r in 0..rows.len() {
277 let mut row_cells = Vec::with_capacity(cols.len());
278 for c in 0..cols.len() {
279 let key = cell_key(&row_cat, &col_cat, &rows, &cols, &pages, r, c);
280 row_cells.push(values.get(&key).cloned());
281 }
282 cells.push(row_cells);
283 }
284
285 Grid {
286 measure,
287 measure_name,
288 row_cat,
289 col_cat,
290 rows,
291 cols,
292 pages,
293 cells,
294 }
295}
296
297#[allow(clippy::too_many_arguments)]
300fn cell_key(
301 row_cat: &Option<(CategoryId, String)>,
302 col_cat: &Option<(CategoryId, String)>,
303 rows: &[(ItemId, String)],
304 cols: &[(ItemId, String)],
305 pages: &[PageDim],
306 row: usize,
307 col: usize,
308) -> CoordKey {
309 let mut key: CoordKey = Vec::new();
310 if let (Some((c, _)), Some((item, _))) = (row_cat, rows.get(row)) {
311 key.push((c.0, item.0));
312 }
313 if let (Some((c, _)), Some((item, _))) = (col_cat, cols.get(col)) {
314 key.push((c.0, item.0));
315 }
316 for p in pages {
317 key.push((p.cat.0, p.item.0));
318 }
319 key.sort();
320 key
321}
322
323const NO_CELL: &str = "no cell here (an axis is empty or fully filtered)";
325
326#[derive(Debug, Clone, Copy, PartialEq, Eq)]
330pub enum CommandKind {
331 ImportCsv,
332 ExportCsv,
333}
334
335pub fn parse_import_line(line: &str) -> Result<ImportSpec, String> {
344 let has_header = !line.split_whitespace().any(|t| t == "--no-header");
345 let tsv_flag = line.split_whitespace().any(|t| t == "--tsv");
346 let positional: Vec<&str> = line
347 .split_whitespace()
348 .filter(|t| *t != "--tsv" && *t != "--no-header")
349 .collect();
350 if positional.len() < 5 {
351 return Err("usage: <file.csv> <measure-id> <measure-name> <value-col> \
352 <dim-col:cat-id:cat-name> [...] [--tsv] [--no-header]"
353 .to_string());
354 }
355 let path = positional[0];
356 let measure_id = positional[1]
357 .parse::<u32>()
358 .map_err(|_| format!("<measure-id> must be a number, got '{}'", positional[1]))?;
359 let measure_name = positional[2].to_string();
360 let value_col = positional[3];
361
362 let tsv = tsv_flag || path.ends_with(".tsv");
363 let delimiter = if tsv { b'\t' } else { b',' };
364 let col_ref = |s: &str| -> ColumnRef {
365 if has_header {
366 ColumnRef::Name(s.to_string())
367 } else {
368 s.parse::<usize>()
369 .map(ColumnRef::Index)
370 .unwrap_or_else(|_| ColumnRef::Name(s.to_string()))
371 }
372 };
373
374 let mut dimensions = Vec::new();
375 for spec in &positional[4..] {
376 let parts: Vec<&str> = spec.splitn(3, ':').collect();
377 if parts.len() != 3 {
378 return Err(format!(
379 "dimension spec '{spec}' must be <col>:<cat-id>:<cat-name>"
380 ));
381 }
382 let cat_id = parts[1]
383 .parse::<u32>()
384 .map_err(|_| format!("<cat-id> must be a number, got '{}'", parts[1]))?;
385 dimensions.push(DimensionMapping {
386 column: col_ref(parts[0]),
387 category_id: CategoryId(cat_id),
388 category_name: parts[2].to_string(),
389 });
390 }
391
392 Ok(ImportSpec {
393 path: PathBuf::from(path),
394 delimiter,
395 has_header,
396 measure_id: MeasureId(measure_id),
397 measure_name,
398 value_type: ValueType::Number,
399 value_column: col_ref(value_col),
400 dimensions,
401 item_id_base: 1_000_000, max_records: None,
403 })
404}
405
406pub fn parse_export_line(line: &str) -> Result<(String, MeasureId, u8), String> {
410 let tsv_flag = line.split_whitespace().any(|t| t == "--tsv");
411 let positional: Vec<&str> = line.split_whitespace().filter(|t| *t != "--tsv").collect();
412 if positional.len() < 2 {
413 return Err("usage: <target.csv> <measure-id> [--tsv]".to_string());
414 }
415 let target = positional[0].to_string();
416 let measure_id = positional[1]
417 .parse::<u32>()
418 .map_err(|_| format!("<measure-id> must be a number, got '{}'", positional[1]))?;
419 let tsv = tsv_flag || target.ends_with(".tsv");
420 let delimiter = if tsv { b'\t' } else { b',' };
421 Ok((target, MeasureId(measure_id), delimiter))
422}
423
424pub fn parse_typed(text: &str, vt: ValueType) -> Result<Value, String> {
426 match vt {
427 ValueType::Number => text
428 .parse()
429 .map(Value::Number)
430 .map_err(|_| format!("not a number: '{text}'")),
431 ValueType::Boolean => match text {
432 "true" => Ok(Value::Boolean(true)),
433 "false" => Ok(Value::Boolean(false)),
434 _ => Err(format!("not true|false: '{text}'")),
435 },
436 ValueType::Text => Ok(Value::Text(text.to_string())),
437 other => Err(format!("cannot edit a {other:?} cell here")),
438 }
439}
440
441pub fn parse_formula_text(model: &Model, text: &str) -> Result<Formula, ParseError> {
445 parser::parse_expr(model, text).or_else(|e| {
446 improv_nl_formula::parse_nl_formula(&improv_nl_formula::NlContext::new(model), text)
447 .map_err(|_| e)
448 })
449}
450
451pub fn measure_order(model: &Model) -> Vec<MeasureId> {
453 let mut ids: Vec<MeasureId> = model.measures.keys().copied().collect();
454 ids.sort_by_key(|id| {
455 let derived = model
456 .measures
457 .get(id)
458 .map(|m| m.is_derived())
459 .unwrap_or(false);
460 (!derived, id.0) });
462 ids
463}
464
465pub struct App {
474 pub model: Model,
475 pub measures: Vec<MeasureId>,
476 pub selected: usize, pub grid: Grid,
478 pub cursor_row: usize,
479 pub cursor_col: usize,
480 pub should_quit: bool,
481 pub engine: Option<Engine>,
483 pub snapshot: Snapshot,
485 pub applied_view: Option<ViewId>,
487 pub edit: Option<LineEdit>,
490 pub status: Option<String>,
492 pub save_token: improv_store::SaveToken,
495 pub page_idx: Vec<usize>,
498 pub axis_order: Vec<CategoryId>,
502 pub filters: Vec<Filter>,
506 pub grid_geom: Option<GridGeom>,
508 pub anchor: Option<(usize, usize)>,
510 pub command: Option<(CommandKind, String)>,
514 pub help: Option<crate::fnhelp::FnHelp>,
516 pub tab_cycle: Option<complete::Cycle>,
518 pub diagnostic: Option<crate::diagnose::Diagnostic>,
521}
522
523impl App {
524 pub fn new(model: Model) -> Result<App, String> {
525 let measures = measure_order(&model);
526 if measures.is_empty() {
527 return Err("model has no measures to display".into());
528 }
529
530 let derived: Vec<MeasureId> = measures
532 .iter()
533 .copied()
534 .filter(|id| {
535 model
536 .measures
537 .get(id)
538 .map(|m| m.is_derived())
539 .unwrap_or(false)
540 })
541 .collect();
542 let (engine, snapshot) = if derived.is_empty() {
543 (None, Snapshot::default())
544 } else {
545 let (e, s) = Engine::new(&model, &derived).map_err(|e| e.to_string())?;
546 (Some(e), s)
547 };
548
549 let selected = 0;
550 let natural_axes = |mid: MeasureId| -> Vec<CategoryId> {
551 model
552 .measures
553 .get(&mid)
554 .map(|m| m.categories.clone())
555 .unwrap_or_default()
556 };
557 let axis_order = natural_axes(measures[selected]);
558 let grid = build_grid(&model, measures[selected], &snapshot);
559 let page_idx = vec![0; grid.pages.len()];
560 Ok(App {
561 model,
562 measures,
563 selected,
564 grid,
565 cursor_row: 0,
566 cursor_col: 0,
567 should_quit: false,
568 engine,
569 snapshot,
570 applied_view: None,
571 edit: None,
572 status: None,
573 save_token: improv_store::SaveToken::default(),
574 page_idx,
575 axis_order,
576 filters: Vec::new(),
577 grid_geom: None,
578 anchor: None,
579 command: None,
580 help: None,
581 tab_cycle: None,
582 diagnostic: None,
583 })
584 }
585
586 fn reselect(&mut self) {
587 self.grid = build_grid_pivoted(
588 &self.model,
589 self.measures[self.selected],
590 &self.snapshot,
591 &self.page_idx,
592 Some(&self.axis_order),
593 &self.filters,
594 );
595 self.cursor_row = self.cursor_row.min(self.grid.n_rows().saturating_sub(1));
596 self.cursor_col = self.cursor_col.min(self.grid.n_cols().saturating_sub(1));
597 self.anchor = None;
598 }
599
600 fn natural_axis_order(&self) -> Vec<CategoryId> {
602 self.model
603 .measures
604 .get(&self.measures[self.selected])
605 .map(|m| m.categories.clone())
606 .unwrap_or_default()
607 }
608
609 pub fn pivot(&mut self) {
614 if self.axis_order.len() < 2 {
615 self.status = Some("nothing to pivot (measure has < 2 dimensions)".into());
616 return;
617 }
618 self.axis_order.rotate_left(1);
619 self.page_idx.clear();
621 self.reselect();
622 self.page_idx = self.grid.pages.iter().map(|p| p.item_index).collect();
623 let names: Vec<String> = self
624 .axis_order
625 .iter()
626 .map(|c| {
627 self.model
628 .categories
629 .get(c)
630 .map(|x| x.name.0.clone())
631 .unwrap_or_else(|| c.0.to_string())
632 })
633 .collect();
634 self.status = Some(format!(
635 "pivot: rows={} cols={}",
636 names[0],
637 names.get(1).cloned().unwrap_or_default()
638 ));
639 }
640
641 pub fn page(&mut self, delta: isize) {
644 let Some(pd) = self.grid.pages.first() else {
645 self.status = Some("no extra dimensions to page".into());
646 return;
647 };
648 let next = (pd.item_index as isize + delta).rem_euclid(pd.item_count as isize) as usize;
651 self.page_idx = self.grid.pages.iter().map(|p| p.item_index).collect();
652 self.page_idx[0] = next;
653 self.reselect();
654 }
655
656 pub fn next_measure(&mut self) {
658 self.selected = (self.selected + 1) % self.measures.len();
659 self.axis_order = self.natural_axis_order();
661 self.page_idx.clear();
662 self.filters.clear();
663 self.reselect();
664 self.page_idx = self.grid.pages.iter().map(|p| p.item_index).collect();
666 }
667
668 pub fn step(&mut self, drow: isize, dcol: isize, extend: bool) {
671 self.anchor = extend.then(|| self.anchor.unwrap_or((self.cursor_row, self.cursor_col)));
672 self.move_cursor(drow, dcol);
673 }
674
675 pub fn is_selected(&self, row: usize, col: usize) -> bool {
677 let (ar, ac) = self.anchor.unwrap_or((self.cursor_row, self.cursor_col));
678 let (cr, cc) = (self.cursor_row, self.cursor_col);
679 (ar.min(cr)..=ar.max(cr)).contains(&row) && (ac.min(cc)..=ac.max(cc)).contains(&col)
680 }
681
682 pub fn selection_sum(&self) -> Option<f64> {
685 let g = &self.grid;
686 let nums: Vec<f64> = (0..g.n_rows())
687 .flat_map(|r| (0..g.n_cols()).map(move |c| (r, c)))
688 .filter(|&(r, c)| self.is_selected(r, c))
689 .filter_map(|(r, c)| g.value_at(r, c))
690 .collect();
691 (!nums.is_empty()).then(|| nums.iter().sum())
692 }
693
694 pub fn formula_text(&self) -> Option<String> {
697 match &self.model.measures.get(&self.grid.measure)?.kind {
698 improv_core_model::MeasureKind::Derived(f) => {
699 Some(improv_nl_formula::describe_formula(
700 &improv_nl_formula::NlContext::new(&self.model),
701 f,
702 ))
703 }
704 _ => None,
705 }
706 }
707
708 pub fn move_cursor(&mut self, drow: isize, dcol: isize) {
710 let max_row = self.grid.n_rows().saturating_sub(1) as isize;
711 let max_col = self.grid.n_cols().saturating_sub(1) as isize;
712 self.cursor_row = (self.cursor_row as isize + drow).clamp(0, max_row) as usize;
713 self.cursor_col = (self.cursor_col as isize + dcol).clamp(0, max_col) as usize;
714 }
715
716 pub fn click_at(&mut self, col: u16, row: u16) {
721 let Some(g) = self.grid_geom else { return };
722 if row < g.y0 || col < g.x0 {
723 return;
724 }
725 let r = (row - g.y0) as usize;
727 let stride = (g.col_w + g.spacing) as usize;
729 let c = ((col - g.x0) as usize) / stride.max(1);
730 let (r, c) = (g.row0 + r, g.col0 + c);
731 let in_window = r < g.row0 + g.rows && c < g.col0 + g.cols;
732 if in_window && r < self.grid.n_rows() && c < self.grid.n_cols() {
733 self.anchor = None;
734 self.cursor_row = r;
735 self.cursor_col = c;
736 }
737 }
738
739 fn addressable(&self, row: usize, col: usize) -> bool {
744 let dims = self
745 .model
746 .measures
747 .get(&self.grid.measure)
748 .map_or(0, |m| m.categories.len());
749 self.grid.coord_key(row, col).len() == dims
750 }
751
752 pub fn viewed_is_input(&self) -> bool {
754 self.model
755 .measures
756 .get(&self.grid.measure)
757 .map(|m| m.is_input())
758 .unwrap_or(false)
759 }
760
761 pub fn editing_formula(&self) -> bool {
763 self.edit.is_some() && !self.viewed_is_input()
764 }
765
766 pub fn begin_edit(&mut self) {
769 if !self.viewed_is_input() {
770 self.edit = Some(self.formula_text().unwrap_or_default().into());
771 self.status = None;
772 return;
773 }
774 if !self.addressable(self.cursor_row, self.cursor_col) {
775 self.status = Some(NO_CELL.into());
776 return;
777 }
778 let seed = match self.grid.value_at(self.cursor_row, self.cursor_col) {
781 Some(n) if n.fract() == 0.0 => format!("{n:.0}"),
782 Some(n) => format!("{n}"),
783 None => self.grid.display_at(self.cursor_row, self.cursor_col),
785 };
786 self.edit = Some(seed.into());
787 self.status = None;
788 }
789
790 pub fn edit_key(&mut self, code: KeyCode) {
794 let formula = !self.viewed_is_input();
795 let Some(e) = &mut self.edit else { return };
796 if code == KeyCode::Tab {
797 if formula {
798 let before = e.text.clone();
799 self.status = complete::tab(&self.model, e, &mut self.tab_cycle);
800 if e.text != before {
801 self.diagnostic = None;
802 }
803 }
804 return;
805 }
806 self.tab_cycle = None;
807 if e.key(code) {
808 self.status = None;
809 self.diagnostic = None;
810 }
811 }
812
813 pub fn cancel_edit(&mut self) {
815 self.edit = None;
816 self.tab_cycle = None;
817 self.diagnostic = None;
818 self.status = None;
819 }
820
821 pub fn commit_edit(&mut self) {
826 let Some(buf) = self.edit.as_ref().map(|e| e.text.clone()) else {
827 return;
828 };
829 if !self.viewed_is_input() {
830 self.diagnostic = None;
831 match parse_formula_text(&self.model, &buf) {
832 Err(e) => {
833 self.status = None;
834 self.diagnostic = Some(crate::diagnose::diagnose(&self.model, &buf, &e));
835 }
836 Ok(f) => match self.set_formula(f) {
837 Ok(()) => {
838 self.edit = None;
839 self.tab_cycle = None;
840 self.status = None;
841 }
842 Err(e) => self.status = Some(e),
843 },
844 }
845 return;
846 }
847 let vt = self
848 .model
849 .measures
850 .get(&self.grid.measure)
851 .map_or(ValueType::Number, |m| m.value_type);
852 let value = match parse_typed(buf.trim(), vt) {
853 Ok(v) => v,
854 Err(e) => {
855 self.status = Some(e);
856 return;
857 }
858 };
859 match self.apply_value(self.cursor_row, self.cursor_col, value) {
860 Ok(()) => {
861 self.edit = None;
862 self.status = None;
863 }
864 Err(e) => self.status = Some(e),
865 }
866 }
867
868 #[cfg(test)]
873 pub fn apply_edit(&mut self, row: usize, col: usize, value: f64) -> Result<(), String> {
874 self.apply_value(row, col, Value::Number(value))
875 }
876
877 pub fn apply_value(&mut self, row: usize, col: usize, value: Value) -> Result<(), String> {
882 if !self.viewed_is_input() {
883 return Err("derived cells are computed, not editable".into());
884 }
885 let measure = self.grid.measure;
886 if !self.addressable(row, col) {
887 return Err(NO_CELL.into());
888 }
889 let key = self.grid.coord_key(row, col);
890 let coord = decode_coord(&key);
891
892 let cell = self
893 .model
894 .measures
895 .get(&measure)
896 .and_then(|m| improv_engine::session::input_cell(m.value_type, &value));
897 self.model.set_input(measure, coord, value);
898
899 if let Some(engine) = &mut self.engine {
902 self.snapshot = engine
903 .apply(vec![improv_engine::session::Edit {
904 measure,
905 coord: key,
906 value: cell,
907 }])
908 .map_err(|e| e.to_string())?;
909 }
910 self.reselect();
911 Ok(())
912 }
913
914 pub fn set_formula(&mut self, formula: Formula) -> Result<(), String> {
919 let mut candidate = self.model.clone();
920 let m = candidate
921 .measures
922 .get_mut(&self.grid.measure)
923 .ok_or("the measure no longer exists")?;
924 m.kind = MeasureKind::Derived(formula);
925 let derived: Vec<MeasureId> = self
926 .measures
927 .iter()
928 .copied()
929 .filter(|id| candidate.measures.get(id).is_some_and(|m| m.is_derived()))
930 .collect();
931 let (engine, snapshot) = Engine::new(&candidate, &derived).map_err(|e| e.to_string())?;
932 self.model = candidate;
933 self.engine = Some(engine);
934 self.snapshot = snapshot;
935 self.reselect();
936 Ok(())
937 }
938
939 pub fn begin_import_csv(&mut self) {
944 self.command = Some((CommandKind::ImportCsv, String::new()));
945 self.status = None;
946 }
947
948 pub fn begin_export_csv(&mut self) {
950 self.command = Some((CommandKind::ExportCsv, String::new()));
951 self.status = None;
952 }
953
954 pub fn cancel_command(&mut self) {
956 self.command = None;
957 self.status = None;
958 }
959
960 pub fn commit_command(&mut self) {
966 let Some((kind, line)) = self.command.take() else {
967 return;
968 };
969 self.status = Some(match kind {
970 CommandKind::ImportCsv => self.run_import_csv(&line),
971 CommandKind::ExportCsv => self.run_export_csv(&line),
972 });
973 }
974
975 fn run_import_csv(&mut self, line: &str) -> String {
980 let spec = match parse_import_line(line) {
981 Ok(s) => s,
982 Err(e) => return format!("import-csv: {e}"),
983 };
984 let measure_name = spec.measure_name.clone();
985 match import_csv(&mut self.model, &spec) {
986 Ok(n) => {
987 let msg = format!("imported {n} cell(s) into measure '{measure_name}'");
988 match self.rebuild_after_structural_change() {
989 Ok(()) => msg,
990 Err(e) => format!("{msg}; formulas not recomputed: {e}"),
991 }
992 }
993 Err(e) => format!("import-csv: {e}"),
994 }
995 }
996
997 fn run_export_csv(&mut self, line: &str) -> String {
999 let (target, measure_id, delimiter) = match parse_export_line(line) {
1000 Ok(t) => t,
1001 Err(e) => return format!("export-csv: {e}"),
1002 };
1003 match export_measure_csv(&self.model, measure_id, Path::new(&target), delimiter) {
1004 Ok(n) => format!(
1005 "exported {n} row(s) from measure {} to {target}",
1006 measure_id.0
1007 ),
1008 Err(e) => format!("export-csv: {e}"),
1009 }
1010 }
1011
1012 fn rebuild_after_structural_change(&mut self) -> Result<(), String> {
1020 let viewed = self.measures.get(self.selected).copied();
1021 self.measures = measure_order(&self.model);
1022 let derived: Vec<MeasureId> = self
1023 .measures
1024 .iter()
1025 .copied()
1026 .filter(|id| {
1027 self.model
1028 .measures
1029 .get(id)
1030 .map(|m| m.is_derived())
1031 .unwrap_or(false)
1032 })
1033 .collect();
1034 let mut built = Ok(());
1035 let (engine, snapshot) = if derived.is_empty() {
1036 (None, Snapshot::default())
1037 } else {
1038 match Engine::new(&self.model, &derived) {
1039 Ok((e, s)) => (Some(e), s),
1040 Err(e) => {
1041 built = Err(e.to_string());
1042 (None, Snapshot::default())
1043 }
1044 }
1045 };
1046 self.engine = engine;
1047 self.snapshot = snapshot;
1048 self.selected = viewed
1049 .and_then(|v| self.measures.iter().position(|m| *m == v))
1050 .unwrap_or(0);
1051 self.axis_order = self.natural_axis_order();
1052 self.page_idx.clear();
1053 self.filters.clear();
1054 self.reselect();
1055 self.page_idx = self.grid.pages.iter().map(|p| p.item_index).collect();
1056 built
1057 }
1058
1059 fn viewed_measure(&self) -> MeasureId {
1063 self.measures[self.selected]
1064 }
1065
1066 pub fn build_view(&self, id: ViewId, name: &str) -> View {
1069 let page_items = self.grid.pages.iter().map(|p| (p.cat, p.item)).collect();
1070 View {
1071 id,
1072 name: Name(name.to_string()),
1073 measure: self.viewed_measure(),
1074 axis_order: self.axis_order.clone(),
1075 n_rows: 1,
1076 n_cols: 1,
1077 page_items,
1078 filters: self.filters.clone(),
1079 rect: Default::default(),
1082 placements: vec![],
1083 }
1084 }
1085
1086 fn next_view_id(&self) -> ViewId {
1088 ViewId(
1089 self.model
1090 .views
1091 .keys()
1092 .map(|v| v.0)
1093 .max()
1094 .map(|m| m + 1)
1095 .unwrap_or(1),
1096 )
1097 }
1098
1099 pub fn save_view(&mut self, name: &str, path: Option<&str>) -> ViewId {
1102 let id = self.next_view_id();
1103 let view = self.build_view(id, name);
1104 self.model.add_view(view);
1105 if let Some(path) = path {
1106 match improv_store::Store::open(path).and_then(|mut s| {
1107 s.set_save_token(&self.save_token);
1108 s.save_model(&self.model)?;
1109 self.save_token = s.save_token();
1110 Ok(())
1111 }) {
1112 Ok(()) => self.status = Some(format!("saved view '{name}'")),
1113 Err(e) => self.status = Some(format!("save failed: {e}")),
1114 }
1115 } else {
1116 self.status = Some(format!("saved view '{name}'"));
1117 }
1118 id
1119 }
1120
1121 pub fn apply_view(&mut self, view: &View) {
1125 let Some(idx) = self.measures.iter().position(|m| *m == view.measure) else {
1126 self.status = Some("view's measure no longer exists".into());
1127 return;
1128 };
1129 self.selected = idx;
1130 self.axis_order = if view.axis_order.is_empty() {
1131 self.natural_axis_order()
1132 } else {
1133 view.axis_order.clone()
1134 };
1135 self.filters = view.filters.clone();
1136 self.reselect();
1137 self.page_idx = self
1139 .grid
1140 .pages
1141 .iter()
1142 .map(|p| {
1143 view.page_items
1144 .iter()
1145 .find(|(c, _)| *c == p.cat)
1146 .and_then(|(_, it)| {
1147 items_of(&self.model, p.cat, &self.filters)
1148 .iter()
1149 .position(|(id, _)| id == it)
1150 })
1151 .unwrap_or(p.item_index)
1152 })
1153 .collect();
1154 self.reselect();
1155 }
1156
1157 pub fn cycle_view(&mut self) {
1160 let mut ids: Vec<ViewId> = self.model.views.keys().copied().collect();
1161 if ids.is_empty() {
1162 self.status = Some("no saved views (press S to save one)".into());
1163 return;
1164 }
1165 ids.sort_by_key(|v| v.0);
1166 let start = self
1168 .applied_view
1169 .and_then(|cur| ids.iter().position(|v| *v == cur))
1170 .map(|p| (p + 1) % ids.len())
1171 .unwrap_or(0);
1172 let id = ids[start];
1173 self.applied_view = Some(id);
1174 let view = self.model.views[&id].clone();
1175 self.apply_view(&view);
1176 self.status = Some(format!("view: {}", view.name.0));
1177 }
1178
1179 pub fn toggle_filter_item(&mut self, category: CategoryId, item: ItemId) {
1184 let all: Vec<ItemId> = self
1185 .model
1186 .categories
1187 .get(&category)
1188 .map(|c| c.items.clone())
1189 .unwrap_or_default();
1190 let pos = self.filters.iter().position(|f| f.category == category);
1191 match pos {
1192 None => {
1193 let items: Vec<ItemId> = all.into_iter().filter(|i| *i != item).collect();
1195 self.filters.push(Filter { category, items });
1196 }
1197 Some(i) => {
1198 let f = &mut self.filters[i];
1199 if let Some(p) = f.items.iter().position(|x| *x == item) {
1200 f.items.remove(p);
1201 } else {
1202 f.items.push(item);
1203 }
1204 if f.items.len() == all.len() && all.iter().all(|i| f.items.contains(i)) {
1206 self.filters.remove(i);
1207 }
1208 }
1209 }
1210 self.reselect();
1211 }
1212
1213 pub fn clear_filters(&mut self) {
1215 self.filters.clear();
1216 self.reselect();
1217 }
1218 #[cfg(test)]
1220 pub fn cursor_coord(&self) -> (MeasureId, Coordinate) {
1221 let key = self.grid.coord_key(self.cursor_row, self.cursor_col);
1222 (self.grid.measure, decode_coord(&key))
1223 }
1224}
1225
1226#[cfg(test)]
1227pub(crate) mod tests {
1228 use super::*;
1229 use improv_core_model::{
1230 BinaryOp, DimensionSpec, Expr, Formula, Measure, MeasureKind, Name, ValueType,
1231 };
1232
1233 pub(crate) fn revenue_model() -> Model {
1235 let mut m = Model::new();
1236 let (time, product) = (CategoryId(1), CategoryId(2));
1237 m.add_category(time, "Time");
1238 m.add_category(product, "Product");
1239 m.add_item(ItemId(10), time, "2025");
1240 m.add_item(ItemId(11), time, "2026");
1241 m.add_item(ItemId(20), product, "WidgetA");
1242 m.add_item(ItemId(21), product, "WidgetB");
1243
1244 m.add_measure(Measure {
1245 id: MeasureId(100),
1246 name: Name("Price".into()),
1247 value_type: ValueType::Number,
1248 categories: vec![product],
1249 kind: MeasureKind::Input,
1250 description: None,
1251 });
1252 m.add_measure(Measure {
1253 id: MeasureId(101),
1254 name: Name("Quantity".into()),
1255 value_type: ValueType::Number,
1256 categories: vec![time, product],
1257 kind: MeasureKind::Input,
1258 description: None,
1259 });
1260 m.add_measure(Measure {
1261 id: MeasureId(102),
1262 name: Name("Revenue".into()),
1263 value_type: ValueType::Number,
1264 categories: vec![time, product],
1265 kind: MeasureKind::Derived(Formula::new(Expr::BinaryOp(
1266 BinaryOp::Mul,
1267 Box::new(Expr::Ref(MeasureId(100), DimensionSpec::default())),
1268 Box::new(Expr::Ref(MeasureId(101), DimensionSpec::default())),
1269 ))),
1270 description: None,
1271 });
1272
1273 let coord = |p: &[(CategoryId, ItemId)]| Coordinate::from_pairs(p.iter().copied());
1274 m.set_input(
1275 MeasureId(100),
1276 coord(&[(product, ItemId(20))]),
1277 Value::Number(10.0),
1278 );
1279 m.set_input(
1280 MeasureId(100),
1281 coord(&[(product, ItemId(21))]),
1282 Value::Number(20.0),
1283 );
1284 for (t, p, q) in [
1285 (ItemId(10), ItemId(20), 100.0),
1286 (ItemId(10), ItemId(21), 50.0),
1287 (ItemId(11), ItemId(20), 120.0),
1288 (ItemId(11), ItemId(21), 80.0),
1289 ] {
1290 m.set_input(
1291 MeasureId(101),
1292 coord(&[(time, t), (product, p)]),
1293 Value::Number(q),
1294 );
1295 }
1296 m
1297 }
1298
1299 #[test]
1300 fn derived_grid_has_right_dims_and_values() {
1301 let model = revenue_model();
1302 let app = App::new(model).unwrap();
1303 let grid = build_grid(&app.model, MeasureId(102), &app.snapshot); assert_eq!(grid.n_rows(), 2, "two Time items");
1305 assert_eq!(grid.n_cols(), 2, "two Product items");
1306 assert_eq!(grid.row_cat.as_ref().unwrap().1, "Time");
1307 assert_eq!(grid.col_cat.as_ref().unwrap().1, "Product");
1308
1309 assert_eq!(grid.value_at(0, 0), Some(1000.0));
1312 assert_eq!(grid.value_at(1, 1), Some(1600.0));
1314 }
1315
1316 #[test]
1317 fn text_input_cell_renders_typed_not_nan() {
1318 use improv_core_model::{Measure, MeasureKind, Name, ValueType};
1320 let mut model = Model::new();
1321 let p = CategoryId(2);
1322 model.add_category(p, "Product");
1323 model.add_item(ItemId(20), p, "WidgetA");
1324 model.add_measure(Measure {
1325 id: MeasureId(300),
1326 name: Name("Label".into()),
1327 value_type: ValueType::Text,
1328 categories: vec![p],
1329 kind: MeasureKind::Input,
1330 description: None,
1331 });
1332 model.set_input(
1333 MeasureId(300),
1334 Coordinate::from_pairs([(p, ItemId(20))]),
1335 Value::Text("hello".into()),
1336 );
1337 let app = App::new(model).unwrap();
1338 let grid = build_grid(&app.model, MeasureId(300), &app.snapshot);
1339 assert_eq!(grid.display_at(0, 0), "hello");
1340 assert_eq!(grid.value_at(0, 0), None, "text cell has no numeric value");
1341 }
1342
1343 #[test]
1344 fn input_grid_single_axis() {
1345 let model = revenue_model();
1346 let grid = build_grid(&model, MeasureId(100), &Snapshot::default()); assert_eq!(
1348 grid.n_rows(),
1349 2,
1350 "two Product items on the single (row) axis"
1351 );
1352 assert_eq!(
1353 grid.n_cols(),
1354 1,
1355 "no column axis => single synthetic column"
1356 );
1357 assert!(grid.col_cat.is_none());
1358 assert_eq!(grid.value_at(0, 0), Some(10.0)); assert_eq!(grid.value_at(1, 0), Some(20.0)); }
1361
1362 #[test]
1363 fn measure_cycling_wraps_and_derived_first() {
1364 let model = revenue_model();
1365 let mut app = App::new(model).unwrap();
1366 assert_eq!(app.grid.measure, MeasureId(102));
1368 app.next_measure();
1369 assert_eq!(app.grid.measure, MeasureId(100)); app.next_measure();
1371 assert_eq!(app.grid.measure, MeasureId(101)); app.next_measure();
1373 assert_eq!(app.grid.measure, MeasureId(102)); }
1375
1376 #[test]
1377 fn cursor_stays_in_bounds() {
1378 let model = revenue_model();
1379 let mut app = App::new(model).unwrap();
1380 app.move_cursor(-5, -5);
1382 assert_eq!((app.cursor_row, app.cursor_col), (0, 0));
1383 app.move_cursor(100, 100);
1384 assert_eq!((app.cursor_row, app.cursor_col), (1, 1));
1385
1386 app.selected = app
1388 .measures
1389 .iter()
1390 .position(|m| *m == MeasureId(100))
1391 .unwrap();
1392 app.reselect();
1393 assert_eq!(app.cursor_col, 0, "column re-clamped to the narrower grid");
1394 assert!(app.cursor_row <= 1);
1395 }
1396
1397 pub(crate) fn view(app: &mut App, measure: MeasureId) {
1399 app.selected = app.measures.iter().position(|m| *m == measure).unwrap();
1400 app.reselect();
1401 }
1402
1403 #[test]
1404 fn editing_input_recomputes_derived_via_engine() {
1405 let mut app = App::new(revenue_model()).unwrap();
1406 view(&mut app, MeasureId(101));
1408 let (m, coord) = {
1410 app.cursor_row = 0;
1411 app.cursor_col = 0;
1412 app.cursor_coord()
1413 };
1414 assert_eq!(m, MeasureId(101));
1415 assert_eq!(coord.get(CategoryId(1)), Some(ItemId(10))); assert_eq!(coord.get(CategoryId(2)), Some(ItemId(20))); app.apply_edit(0, 0, 200.0).unwrap();
1420 assert_eq!(
1421 app.model.input(MeasureId(101), &coord),
1422 Some(&Value::Number(200.0)),
1423 "model input updated"
1424 );
1425
1426 let rev_key = {
1428 let mut k = vec![(1u32, 10u32), (2, 20)];
1429 k.sort();
1430 k
1431 };
1432 assert_eq!(
1433 app.snapshot
1434 .get(&MeasureId(102))
1435 .unwrap()
1436 .get(&rev_key)
1437 .and_then(|v| v.as_num()),
1438 Some(2000.0),
1439 "derived Revenue recomputed incrementally"
1440 );
1441 }
1442
1443 #[test]
1444 fn editing_derived_cell_is_rejected() {
1445 let mut app = App::new(revenue_model()).unwrap();
1446 view(&mut app, MeasureId(102)); let before = app.model.clone();
1448 let err = app.apply_edit(0, 0, 42.0);
1449 assert!(err.is_err(), "derived edit must be rejected");
1450 assert_eq!(app.model, before, "model unchanged on rejected edit");
1451 assert!(!app.editing_formula());
1453 app.edit_key(KeyCode::Char('x'));
1454 assert!(app.edit.is_none());
1455
1456 app.begin_edit();
1458 assert!(app.editing_formula());
1459 assert_eq!(app.edit.as_deref(), Some("Price times Quantity"));
1460 }
1461
1462 #[test]
1463 fn editing_a_formula_recomputes_or_reports_the_error() {
1464 let mut app = App::new(revenue_model()).unwrap();
1465 view(&mut app, MeasureId(102));
1466 app.begin_edit();
1467 app.commit_edit();
1469 assert!(app.edit.is_none());
1470 assert!(app.status.is_none());
1471 assert_eq!(app.grid.display_at(0, 0), "1000");
1472 app.begin_edit();
1473 app.edit = Some("Price * Quantity * 2".into());
1474 app.commit_edit();
1475 assert!(app.edit.is_none());
1476 assert_eq!(app.grid.display_at(0, 0), "2000");
1477 assert_eq!(
1478 app.formula_text().as_deref(),
1479 Some("Price times Quantity times 2")
1480 );
1481 let before = app.model.clone();
1483 app.begin_edit();
1484 app.edit = Some("Price * Quantty".into());
1485 app.commit_edit();
1486 assert!(app.editing_formula());
1487 let d = app.diagnostic.clone().unwrap();
1488 assert_eq!(
1489 (d.span, d.message.as_str()),
1490 (Some(8..15), "unknown measure: Quantty")
1491 );
1492 assert_eq!(app.model, before);
1493 app.edit_key(KeyCode::Tab);
1495 assert!(app.diagnostic.is_some());
1496 app.edit_key(KeyCode::Left);
1497 assert!(app.diagnostic.is_some(), "moving the caret keeps it");
1498 app.edit_key(KeyCode::Char('i'));
1499 assert!(app.diagnostic.is_none());
1500 app.edit = Some("Price * Quantty".into());
1501 app.commit_edit();
1502 app.edit.as_mut().unwrap().caret = 12;
1503 app.edit_key(KeyCode::Tab);
1504 assert_eq!(app.edit.as_deref(), Some("Price * Quantitytty"));
1505 assert!(app.diagnostic.is_none(), "a completion edits the text");
1506 app.cancel_edit();
1507 app.begin_edit();
1509 app.edit = Some("Revenue + 1".into());
1510 app.commit_edit();
1511 assert!(app.editing_formula(), "{:?}", app.status);
1512 assert!(app.status.is_some());
1513 assert!(app.diagnostic.is_none());
1514 assert_eq!(app.model, before);
1515 view(&mut app, MeasureId(101));
1517 app.begin_edit();
1518 assert!(app.edit.is_some());
1519 assert!(!app.editing_formula());
1520 }
1521
1522 #[test]
1523 fn cursor_maps_to_expected_coordinate() {
1524 let mut app = App::new(revenue_model()).unwrap();
1525 view(&mut app, MeasureId(101)); app.cursor_row = 1;
1529 app.cursor_col = 1;
1530 let key = app.grid.coord_key(1, 1);
1531 let mut expect = vec![(1u32, 11u32), (2, 21)];
1532 expect.sort();
1533 assert_eq!(key, expect, "cursor cell -> CoordKey");
1534
1535 let (_m, coord) = app.cursor_coord();
1537 assert_eq!(improv_engine::encode_coord(&coord), key);
1538 assert_eq!(coord.get(CategoryId(1)), Some(ItemId(11)));
1539 assert_eq!(coord.get(CategoryId(2)), Some(ItemId(21)));
1540 }
1541
1542 #[test]
1543 fn paging_changes_the_pinned_extra_dimension() {
1544 let mut m = Model::new();
1547 let (time, product, region) = (CategoryId(1), CategoryId(2), CategoryId(3));
1548 m.add_category(time, "Time");
1549 m.add_category(product, "Product");
1550 m.add_category(region, "Region");
1551 m.add_item(ItemId(10), time, "2025");
1552 m.add_item(ItemId(20), product, "WidgetA");
1553 m.add_item(ItemId(30), region, "North");
1554 m.add_item(ItemId(31), region, "South");
1555 m.add_measure(Measure {
1556 id: MeasureId(200),
1557 name: Name("Sales".into()),
1558 value_type: ValueType::Number,
1559 categories: vec![time, product, region],
1560 kind: MeasureKind::Input,
1561 description: None,
1562 });
1563 let c = |pairs: &[(CategoryId, ItemId)]| Coordinate::from_pairs(pairs.iter().copied());
1564 m.set_input(
1565 MeasureId(200),
1566 c(&[
1567 (time, ItemId(10)),
1568 (product, ItemId(20)),
1569 (region, ItemId(30)),
1570 ]),
1571 Value::Number(100.0),
1572 );
1573 m.set_input(
1574 MeasureId(200),
1575 c(&[
1576 (time, ItemId(10)),
1577 (product, ItemId(20)),
1578 (region, ItemId(31)),
1579 ]),
1580 Value::Number(250.0),
1581 );
1582
1583 let mut app = App::new(m).unwrap();
1584 assert_eq!(app.grid.pages.len(), 1);
1586 assert_eq!(app.grid.pages[0].item_name, "North");
1587 assert_eq!(app.grid.pages[0].item_count, 2);
1588 assert_eq!(app.grid.value_at(0, 0), Some(100.0)); app.page(1);
1592 assert_eq!(app.grid.pages[0].item_name, "South");
1593 assert_eq!(app.grid.value_at(0, 0), Some(250.0));
1594
1595 app.page(1);
1597 assert_eq!(app.grid.pages[0].item_name, "North");
1598 assert_eq!(app.grid.value_at(0, 0), Some(100.0));
1599
1600 app.page(1);
1603 app.next_measure();
1604 assert_eq!(app.grid.pages[0].item_name, "North");
1605 assert_eq!(app.page_idx, vec![0]);
1606 app.pivot();
1607 assert_eq!(
1608 app.status.as_deref(),
1609 Some("pivot: rows=Product cols=Region")
1610 );
1611 assert_eq!(app.grid.pages[0].cat_name, "Time");
1612 assert_eq!(app.page_idx, vec![0]);
1613 }
1614
1615 #[test]
1618 fn engine_only_for_formulas_that_build() {
1619 let mut m = revenue_model();
1620 m.measures.remove(&MeasureId(102));
1621 let mut app = App::new(m).unwrap();
1622 assert!(app.engine.is_none());
1623 view(&mut app, MeasureId(101));
1624 app.apply_edit(0, 0, 7.0).unwrap();
1625 assert_eq!(app.grid.value_at(0, 0), Some(7.0));
1626
1627 let mut m = revenue_model();
1628 m.measures.get_mut(&MeasureId(102)).unwrap().kind = MeasureKind::Derived(Formula::new(
1629 Expr::Ref(MeasureId(102), DimensionSpec::default()),
1630 ));
1631 let want = Engine::new(&m, &[MeasureId(102)])
1632 .err()
1633 .unwrap()
1634 .to_string();
1635 assert_eq!(App::new(m).err(), Some(want));
1636 }
1637
1638 #[test]
1639 fn pivot_swaps_axes() {
1640 let mut app = App::new(revenue_model()).unwrap();
1642 view(&mut app, MeasureId(101)); assert_eq!(app.grid.row_cat.as_ref().unwrap().1, "Time");
1644 assert_eq!(app.grid.col_cat.as_ref().unwrap().1, "Product");
1645
1646 app.pivot();
1648 assert_eq!(app.grid.row_cat.as_ref().unwrap().1, "Product");
1649 assert_eq!(app.grid.col_cat.as_ref().unwrap().1, "Time");
1650
1651 app.pivot();
1653 assert_eq!(app.grid.row_cat.as_ref().unwrap().1, "Time");
1654 assert_eq!(app.grid.col_cat.as_ref().unwrap().1, "Product");
1655 }
1656
1657 #[test]
1658 fn text_and_boolean_cells_are_editable() {
1659 let mut m = revenue_model();
1662 let time = CategoryId(1);
1663 for (id, name, vt) in [
1664 (200, "Note", ValueType::Text),
1665 (201, "Flag", ValueType::Boolean),
1666 ] {
1667 m.add_measure(Measure {
1668 id: MeasureId(id),
1669 name: Name(name.into()),
1670 value_type: vt,
1671 categories: vec![time],
1672 kind: MeasureKind::Input,
1673 description: None,
1674 });
1675 }
1676 let mut app = App::new(m).unwrap();
1677 let at = Coordinate::from_pairs([(time, ItemId(10))]);
1678 view(&mut app, MeasureId(200));
1679 app.edit = Some("hi there".into());
1680 app.commit_edit();
1681 assert_eq!(app.status, None);
1682 assert_eq!(
1683 app.model.input(MeasureId(200), &at),
1684 Some(&Value::Text("hi there".into()))
1685 );
1686 app.begin_edit(); assert_eq!(app.edit.as_deref(), Some("hi there"));
1688
1689 view(&mut app, MeasureId(201));
1690 app.edit = Some("maybe".into());
1691 app.commit_edit();
1692 assert_eq!(app.status.as_deref(), Some("not true|false: 'maybe'"));
1693 assert!(app.edit.is_some(), "bad input keeps the buffer open");
1694 app.edit = Some("true".into());
1695 app.commit_edit();
1696 assert_eq!(
1697 app.model.input(MeasureId(201), &at),
1698 Some(&Value::Boolean(true))
1699 );
1700 view(&mut app, MeasureId(102));
1702 assert_eq!(app.grid.value_at(0, 0), Some(1000.0));
1703 assert!(parse_typed("x", ValueType::DateTime).is_err());
1704 }
1705
1706 #[test]
1710 fn typed_edits_are_incremental() {
1711 let mut m = revenue_model();
1712 let time = CategoryId(1);
1713 m.add_measure(Measure {
1714 id: MeasureId(201),
1715 name: Name("Flag".into()),
1716 value_type: ValueType::Boolean,
1717 categories: vec![time],
1718 kind: MeasureKind::Input,
1719 description: None,
1720 });
1721 let at = Coordinate::from_pairs([(time, ItemId(10))]);
1722 m.set_input(MeasureId(201), at.clone(), Value::Boolean(false));
1723 let hot = improv_core_model::parser::parse_formula(&m, "Hot = NOT Flag").unwrap();
1724 m.add_measure(Measure {
1725 id: MeasureId(202),
1726 name: Name("Hot".into()),
1727 value_type: ValueType::Boolean,
1728 categories: vec![time],
1729 kind: MeasureKind::Derived(hot.formula),
1730 description: None,
1731 });
1732 let mut app = App::new(m).unwrap();
1733 let key = encode_coord(&at);
1734 let hot_at = |app: &App| app.snapshot[&MeasureId(202)].get(&key).cloned();
1735 assert_eq!(hot_at(&app), Some(CellValue::Bool(true)));
1736 let revenue = app.snapshot[&MeasureId(102)].clone();
1737 view(&mut app, MeasureId(201));
1738 app.edit = Some("true".into());
1739 app.commit_edit();
1740 assert_eq!(app.status, None);
1741 assert_eq!(hot_at(&app), Some(CellValue::Bool(false)));
1742 assert!(std::sync::Arc::ptr_eq(
1743 &revenue,
1744 &app.snapshot[&MeasureId(102)]
1745 ));
1746 }
1747
1748 fn model_with_empty_page() -> Model {
1751 let mut m = revenue_model();
1752 m.add_category(CategoryId(3), "Empty");
1753 m.add_category(CategoryId(4), "Region");
1754 m.add_item(ItemId(40), CategoryId(4), "North");
1755 m.add_item(ItemId(41), CategoryId(4), "South");
1756 m.add_measure(Measure {
1757 id: MeasureId(300),
1758 name: Name("Sales".into()),
1759 value_type: ValueType::Number,
1760 categories: (1..=4).map(CategoryId).collect(),
1761 kind: MeasureKind::Input,
1762 description: None,
1763 });
1764 m
1765 }
1766
1767 #[test]
1768 fn paging_past_an_empty_page_dim_wraps_and_edits_are_refused() {
1769 let mut app = App::new(model_with_empty_page()).unwrap();
1770 view(&mut app, MeasureId(300));
1771 app.page_idx.clear(); app.page(-1);
1773 assert_eq!(app.grid.pages.len(), 1);
1774 assert_eq!(app.grid.pages[0].item_name, "South", "wrapped backwards");
1775 app.page(1);
1776 assert_eq!(app.grid.pages[0].item_name, "North");
1777 app.begin_edit();
1779 assert_eq!(app.status.as_deref(), Some(NO_CELL));
1780 app.edit = Some("5".into());
1782 app.status = None;
1783 app.commit_edit();
1784 assert_eq!(app.edit.as_deref(), Some("5"));
1785 assert_eq!(app.status.as_deref(), Some(NO_CELL));
1786 }
1787
1788 #[test]
1789 fn small_branches_pivot_click_seed_and_no_buffer_commits() {
1790 let mut app = App::new(revenue_model()).unwrap();
1791 view(&mut app, MeasureId(100));
1793 app.axis_order = app.natural_axis_order();
1794 app.pivot();
1795 assert!(app
1796 .status
1797 .as_deref()
1798 .unwrap()
1799 .starts_with("nothing to pivot"));
1800 app.click_at(50, 50);
1802 app.grid_geom = Some(GridGeom {
1803 x0: 10,
1804 y0: 5,
1805 col_w: 12,
1806 spacing: 1,
1807 row0: 0,
1808 col0: 0,
1809 rows: 1,
1810 cols: 1,
1811 });
1812 app.click_at(5, 6);
1813 app.click_at(10, 6); assert_eq!((app.cursor_row, app.cursor_col), (0, 0));
1815 view(&mut app, MeasureId(101));
1817 app.apply_edit(0, 0, 2.5).unwrap();
1818 app.begin_edit();
1819 assert_eq!(app.edit.as_deref(), Some("2.5"));
1820 app.edit = Some("abc".into());
1821 app.commit_edit();
1822 assert_eq!(app.status.as_deref(), Some("not a number: 'abc'"));
1823 app.edit = Some("false".into());
1824 assert_eq!(
1825 parse_typed("false", ValueType::Boolean),
1826 Ok(Value::Boolean(false))
1827 );
1828 app.edit = None;
1830 app.status = None;
1831 app.commit_edit();
1832 app.commit_command();
1833 assert_eq!(app.status, None);
1834 view(&mut app, MeasureId(102));
1836 app.edit = Some("1".into());
1837 app.commit_edit();
1838 assert_eq!(app.status, None);
1839 assert_eq!(app.formula_text().as_deref(), Some("1"));
1840 }
1841
1842 #[test]
1843 fn views_cycle_wrap_and_tolerate_missing_measures() {
1844 let mut app = App::new(revenue_model()).unwrap();
1845 app.cycle_view();
1846 assert!(app.status.as_deref().unwrap().starts_with("no saved views"));
1847 view(&mut app, MeasureId(101));
1848 let v1 = app.save_view("one", None);
1849 app.next_measure();
1850 let v2 = app.save_view("two", None);
1851 assert_eq!(v2.0, v1.0 + 1);
1852 app.cycle_view();
1853 assert_eq!(app.status.as_deref(), Some("view: one"));
1854 app.cycle_view();
1855 assert_eq!(app.status.as_deref(), Some("view: two"));
1856 app.cycle_view();
1857 assert_eq!(app.status.as_deref(), Some("view: one"), "wraps");
1858 let mut v = app.build_view(ViewId(99), "bare");
1861 v.axis_order.clear();
1862 app.apply_view(&v);
1863 assert_eq!(app.axis_order, app.natural_axis_order());
1864 v.measure = MeasureId(9999);
1865 app.apply_view(&v);
1866 assert_eq!(
1867 app.status.as_deref(),
1868 Some("view's measure no longer exists")
1869 );
1870 app.save_view("bad", Some("/nonexistent/dir/x.db"));
1872 assert!(app.status.as_deref().unwrap().starts_with("save failed"));
1873 }
1874
1875 #[test]
1876 fn re_showing_every_filtered_item_drops_the_filter() {
1877 let mut app = App::new(revenue_model()).unwrap();
1878 let time = CategoryId(1);
1879 app.toggle_filter_item(time, ItemId(10));
1880 app.toggle_filter_item(time, ItemId(11));
1881 app.toggle_filter_item(time, ItemId(10));
1882 assert_eq!(app.filters.len(), 1, "one item still hidden");
1883 app.toggle_filter_item(time, ItemId(11));
1884 assert!(app.filters.is_empty(), "all shown again => unfiltered");
1885 app.toggle_filter_item(time, ItemId(10));
1887 app.toggle_filter_item(time, ItemId(20));
1888 assert_eq!(app.filters.len(), 1, "2025 is still hidden");
1889 }
1890
1891 #[test]
1892 fn grid_falls_back_on_bad_axis_order_and_unknown_ids() {
1893 let m = revenue_model();
1894 let s = Snapshot::default();
1895 let g = build_grid_pivoted(&m, MeasureId(101), &s, &[], Some(&[CategoryId(2)]), &[]);
1897 assert_eq!(g.row_cat.unwrap().1, "Time");
1898 let g = build_grid(&m, MeasureId(77), &s);
1900 assert_eq!(
1901 (g.measure_name.as_str(), g.n_rows(), g.n_cols()),
1902 ("77", 1, 1)
1903 );
1904 let mut m = m;
1906 m.categories
1907 .get_mut(&CategoryId(2))
1908 .unwrap()
1909 .items
1910 .push(ItemId(29));
1911 let mut meas = m.measures[&MeasureId(101)].clone();
1912 meas.id = MeasureId(400);
1913 meas.categories = vec![CategoryId(2), CategoryId(8)];
1914 m.add_measure(meas);
1915 let g = build_grid(&m, MeasureId(400), &s);
1916 assert_eq!(g.rows.last().unwrap().1, "29");
1917 assert_eq!(g.col_cat.unwrap().1, "8");
1918 let mut app = App::new(m).unwrap();
1919 view(&mut app, MeasureId(400));
1920 app.axis_order = app.natural_axis_order();
1921 app.pivot();
1922 assert_eq!(app.status.as_deref(), Some("pivot: rows=8 cols=Product"));
1923 }
1924
1925 #[test]
1926 fn shift_arrows_select_a_range_and_sum_it() {
1927 let mut app = App::new(revenue_model()).unwrap();
1928 view(&mut app, MeasureId(101)); assert_eq!(app.selection_sum(), Some(100.0));
1930 app.step(1, 0, true);
1931 app.step(0, 1, true);
1932 assert_eq!(app.anchor, Some((0, 0)));
1933 assert!(app.is_selected(1, 0));
1934 assert!(app.is_selected(0, 1));
1935 assert_eq!(app.selection_sum(), Some(350.0));
1936 app.step(-1, 0, false);
1937 assert_eq!((app.anchor, app.selection_sum()), (None, Some(50.0)));
1938 app.step(0, -1, true);
1939 app.clear_filters(); assert_eq!(app.anchor, None);
1941 assert_eq!(app.formula_text(), None);
1943 view(&mut app, MeasureId(102));
1944 assert_eq!(app.formula_text().as_deref(), Some("Price times Quantity"));
1945 let mut m = revenue_model();
1946 m.inputs.clear();
1947 assert_eq!(App::new(m).unwrap().selection_sum(), None);
1948 }
1949
1950 #[test]
1951 fn edit_on_fully_filtered_axis_is_refused_not_written() {
1952 let mut app = App::new(revenue_model()).unwrap();
1955 view(&mut app, MeasureId(101));
1956 let time = CategoryId(1);
1957 app.toggle_filter_item(time, ItemId(10));
1958 app.toggle_filter_item(time, ItemId(11));
1959 assert!(app.grid.rows.is_empty());
1960 let before = app.model.inputs.len();
1961 app.begin_edit();
1962 assert!(app.edit.is_none());
1963 assert_eq!(app.status.as_deref(), Some(NO_CELL));
1964 assert_eq!(app.apply_edit(0, 0, 7.0), Err(NO_CELL.to_string()));
1965 assert_eq!(app.model.inputs.len(), before);
1966 }
1967
1968 #[test]
1969 fn click_moves_cursor_to_cell() {
1970 let mut app = App::new(revenue_model()).unwrap();
1971 view(&mut app, MeasureId(101)); app.grid_geom = Some(GridGeom {
1974 x0: 2,
1975 y0: 5,
1976 col_w: 12,
1977 spacing: 1,
1978 row0: 0,
1979 col0: 0,
1980 rows: 10,
1981 cols: 10,
1982 });
1983 app.click_at(2 + 13, 5 + 1);
1985 assert_eq!((app.cursor_row, app.cursor_col), (1, 1));
1986 app.click_at(2, 0);
1988 assert_eq!((app.cursor_row, app.cursor_col), (1, 1));
1989 app.click_at(2, 5 + 5);
1991 app.click_at(2 + 13 * 5, 5);
1992 assert_eq!((app.cursor_row, app.cursor_col), (1, 1));
1993 }
1994
1995 #[test]
1996 fn filter_hides_a_row_item_from_the_grid() {
1997 let mut app = App::new(revenue_model()).unwrap();
1998 view(&mut app, MeasureId(101)); assert_eq!(app.grid.n_rows(), 2);
2000 app.toggle_filter_item(CategoryId(1), ItemId(11));
2002 let rows: Vec<ItemId> = app.grid.rows.iter().map(|(id, _)| *id).collect();
2003 assert_eq!(rows, vec![ItemId(10)], "2026 filtered out of rows");
2004 assert_eq!(app.grid.n_cols(), 2);
2006 app.toggle_filter_item(CategoryId(1), ItemId(11));
2008 assert_eq!(app.grid.n_rows(), 2);
2009 assert!(app.filters.is_empty(), "unfiltered again");
2010 }
2011
2012 #[test]
2013 fn save_view_captures_axis_order_and_filters() {
2014 let mut app = App::new(revenue_model()).unwrap();
2015 view(&mut app, MeasureId(101)); app.pivot(); app.toggle_filter_item(CategoryId(2), ItemId(21)); let id = app.save_view("L1", None);
2020 let v = app.model.views.get(&id).expect("view saved");
2021 assert_eq!(v.measure, MeasureId(101));
2022 assert_eq!(v.axis_order, vec![CategoryId(2), CategoryId(1)]);
2023 let f = v
2024 .filters
2025 .iter()
2026 .find(|f| f.category == CategoryId(2))
2027 .unwrap();
2028 assert_eq!(f.items, vec![ItemId(20)]); assert_eq!(app.model.view_by_name("L1").map(|v| v.id), Some(id));
2030 }
2031
2032 #[test]
2033 fn apply_view_restores_axis_order_and_filters() {
2034 let mut src = App::new(revenue_model()).unwrap();
2036 view(&mut src, MeasureId(101));
2037 src.pivot(); src.toggle_filter_item(CategoryId(2), ItemId(21)); let v = src.build_view(ViewId(1), "L1");
2040
2041 let mut dst = App::new(revenue_model()).unwrap();
2042 assert_ne!(dst.grid.measure, MeasureId(101)); dst.apply_view(&v);
2044
2045 assert_eq!(dst.grid.measure, MeasureId(101));
2046 assert_eq!(dst.axis_order, vec![CategoryId(2), CategoryId(1)]);
2047 assert_eq!(dst.filters, v.filters);
2048 let rows: Vec<ItemId> = dst.grid.rows.iter().map(|(id, _)| *id).collect();
2050 assert_eq!(rows, vec![ItemId(20)], "only WidgetA on rows");
2051 assert_eq!(dst.grid.row_cat.as_ref().unwrap().1, "Product");
2052 assert_eq!(dst.grid.col_cat.as_ref().unwrap().1, "Time");
2053 }
2054
2055 #[test]
2056 fn apply_view_restores_page_item() {
2057 let mut m = Model::new();
2059 let (time, product, region) = (CategoryId(1), CategoryId(2), CategoryId(3));
2060 m.add_category(time, "Time");
2061 m.add_category(product, "Product");
2062 m.add_category(region, "Region");
2063 m.add_item(ItemId(10), time, "2025");
2064 m.add_item(ItemId(20), product, "WidgetA");
2065 m.add_item(ItemId(30), region, "North");
2066 m.add_item(ItemId(31), region, "South");
2067 m.add_measure(Measure {
2068 id: MeasureId(200),
2069 name: Name("Sales".into()),
2070 value_type: ValueType::Number,
2071 categories: vec![time, product, region],
2072 kind: MeasureKind::Input,
2073 description: None,
2074 });
2075 let c = |p: &[(CategoryId, ItemId)]| Coordinate::from_pairs(p.iter().copied());
2076 m.set_input(
2077 MeasureId(200),
2078 c(&[
2079 (time, ItemId(10)),
2080 (product, ItemId(20)),
2081 (region, ItemId(30)),
2082 ]),
2083 Value::Number(100.0),
2084 );
2085 m.set_input(
2086 MeasureId(200),
2087 c(&[
2088 (time, ItemId(10)),
2089 (product, ItemId(20)),
2090 (region, ItemId(31)),
2091 ]),
2092 Value::Number(250.0),
2093 );
2094
2095 let mut src = App::new(m.clone()).unwrap();
2096 src.page(1); assert_eq!(src.grid.pages[0].item_name, "South");
2098 let v = src.build_view(ViewId(1), "south");
2099 assert_eq!(v.page_items, vec![(region, ItemId(31))]);
2100
2101 let mut dst = App::new(m).unwrap();
2102 assert_eq!(dst.grid.pages[0].item_name, "North");
2103 dst.apply_view(&v);
2104 assert_eq!(dst.grid.pages[0].item_name, "South", "page item restored");
2105 }
2106
2107 pub(crate) fn tmp_path(suffix: &str) -> PathBuf {
2110 std::env::temp_dir().join(format!(
2111 "improv_tui_test_{}_{}{}",
2112 std::process::id(),
2113 std::time::SystemTime::now()
2114 .duration_since(std::time::UNIX_EPOCH)
2115 .unwrap()
2116 .as_nanos(),
2117 suffix
2118 ))
2119 }
2120
2121 struct TmpFile(PathBuf);
2122 impl Drop for TmpFile {
2123 fn drop(&mut self) {
2124 let _ = std::fs::remove_file(&self.0);
2125 }
2126 }
2127
2128 #[test]
2129 fn parse_import_line_builds_expected_spec() {
2130 let spec = parse_import_line("sales.csv 100 Revenue revenue time:1:Time product:2:Product")
2131 .unwrap();
2132 assert_eq!(spec.path, PathBuf::from("sales.csv"));
2133 assert_eq!(spec.delimiter, b',');
2134 assert!(spec.has_header);
2135 assert_eq!(spec.measure_id, MeasureId(100));
2136 assert_eq!(spec.measure_name, "Revenue");
2137 assert_eq!(spec.value_column, ColumnRef::Name("revenue".into()));
2138 assert_eq!(spec.dimensions.len(), 2);
2139 assert_eq!(spec.dimensions[0].column, ColumnRef::Name("time".into()));
2140 assert_eq!(spec.dimensions[0].category_id, CategoryId(1));
2141 assert_eq!(spec.dimensions[0].category_name, "Time");
2142 assert_eq!(spec.dimensions[1].column, ColumnRef::Name("product".into()));
2143 assert_eq!(spec.dimensions[1].category_id, CategoryId(2));
2144 assert_eq!(spec.dimensions[1].category_name, "Product");
2145 }
2146
2147 #[test]
2148 fn parse_import_line_flags_flip_delimiter_and_header() {
2149 let spec = parse_import_line("sales.tsv 100 Revenue revenue time:1:Time --tsv --no-header")
2150 .unwrap();
2151 assert_eq!(spec.delimiter, b'\t');
2152 assert!(!spec.has_header);
2153 assert_eq!(spec.value_column, ColumnRef::Name("revenue".into()));
2155 }
2156
2157 #[test]
2158 fn parse_import_line_no_header_uses_index_columns() {
2159 let spec = parse_import_line("sales.csv 100 Revenue 2 0:1:Time --no-header").unwrap();
2160 assert_eq!(spec.value_column, ColumnRef::Index(2));
2161 assert_eq!(spec.dimensions[0].column, ColumnRef::Index(0));
2162 }
2163
2164 #[test]
2165 fn parse_import_line_rejects_malformed_input() {
2166 assert!(parse_import_line("").is_err());
2167 assert!(parse_import_line("sales.csv 100 Revenue revenue").is_err()); assert!(parse_import_line("sales.csv notanumber Revenue revenue time:1:Time").is_err());
2169 assert!(parse_import_line("sales.csv 100 Revenue revenue time:notanumber:Time").is_err());
2170 assert!(parse_import_line("sales.csv 100 Revenue revenue badspec").is_err());
2171 }
2172
2173 #[test]
2174 fn parse_export_line_builds_expected_tuple() {
2175 let (target, mid, delim) = parse_export_line("out.csv 100").unwrap();
2176 assert_eq!(target, "out.csv");
2177 assert_eq!(mid, MeasureId(100));
2178 assert_eq!(delim, b',');
2179
2180 let (_, _, delim_tsv) = parse_export_line("out.csv 100 --tsv").unwrap();
2181 assert_eq!(delim_tsv, b'\t');
2182 }
2183
2184 #[test]
2185 fn parse_export_line_rejects_malformed_input() {
2186 assert!(parse_export_line("").is_err());
2187 assert!(parse_export_line("out.csv").is_err());
2188 assert!(parse_export_line("out.csv notanumber").is_err());
2189 }
2190
2191 fn starter_model() -> Model {
2195 let mut m = Model::new();
2196 let cat = CategoryId(9);
2197 m.add_category(cat, "Placeholder");
2198 m.add_measure(Measure {
2199 id: MeasureId(1),
2200 name: Name("Placeholder".into()),
2201 value_type: ValueType::Number,
2202 categories: vec![],
2203 kind: MeasureKind::Input,
2204 description: None,
2205 });
2206 m
2207 }
2208
2209 #[test]
2210 fn import_command_end_to_end_adds_measure_and_cells() {
2211 let csv_path = tmp_path(".csv");
2212 let _guard = TmpFile(csv_path.clone());
2213 std::fs::write(
2214 &csv_path,
2215 "time,product,revenue\n2025,WidgetA,1000\n2025,WidgetB,500\n2026,WidgetA,1200\n",
2216 )
2217 .unwrap();
2218
2219 let mut app = App::new(starter_model()).unwrap();
2220 app.begin_import_csv();
2221 assert_eq!(app.command.as_ref().unwrap().0, CommandKind::ImportCsv);
2222 app.command.as_mut().unwrap().1 = format!(
2223 "{} 100 Revenue revenue time:1:Time product:2:Product",
2224 csv_path.display()
2225 );
2226 app.commit_command();
2227
2228 assert!(app.command.is_none(), "command mode exited");
2229 let status = app.status.clone().unwrap_or_default();
2230 assert!(status.contains("imported 3"), "got: {status}");
2231
2232 let m = app.model.measure_by_name("Revenue").unwrap();
2233 assert!(m.is_input());
2234 assert_eq!(app.model.inputs.len(), 3);
2235 assert!(app.measures.contains(&MeasureId(100)));
2237 }
2238
2239 #[test]
2243 fn import_reports_formulas_it_breaks() {
2244 let csv_path = tmp_path(".csv");
2245 let _guard = TmpFile(csv_path.clone());
2246 std::fs::write(&csv_path, "region,v\nNorth,1\nSouth,2\n").unwrap();
2247 let import = |spec: &str| {
2248 let mut app = App::new(revenue_model()).unwrap();
2249 app.begin_import_csv();
2250 app.command.as_mut().unwrap().1 = format!("{} {spec}", csv_path.display());
2251 app.commit_command();
2252 app
2253 };
2254 let app = import("100 Price v region:7:Region");
2256 let s = app.status.clone().unwrap();
2257 assert!(
2258 s.starts_with("imported 2 cell(s) into measure 'Price'; formulas not recomputed: "),
2259 "{s}"
2260 );
2261 assert!(app.engine.is_none());
2262 let mut app = import("200 Sales v region:7:Region");
2264 assert_eq!(
2265 app.status.as_deref(),
2266 Some("imported 2 cell(s) into measure 'Sales'")
2267 );
2268 assert!(app.engine.is_some());
2269 view(&mut app, MeasureId(102));
2270 assert_eq!(app.grid.display_at(0, 0), "1000");
2271 let mut app = App::new(model_with_empty_page()).unwrap();
2273 view(&mut app, MeasureId(300));
2274 app.page(1);
2275 assert_eq!(app.grid.pages[0].item_name, "South");
2276 app.begin_import_csv();
2277 app.command.as_mut().unwrap().1 =
2278 format!("{} 201 Other v region:4:Region", csv_path.display());
2279 app.commit_command();
2280 assert_eq!(
2281 app.status.as_deref(),
2282 Some("imported 2 cell(s) into measure 'Other'")
2283 );
2284 assert_eq!(app.grid.measure, MeasureId(300));
2285 assert_eq!(app.grid.pages[0].item_name, "North");
2286 assert_eq!(app.page_idx, vec![0]);
2287 }
2288
2289 #[test]
2290 fn import_command_reports_parse_error_and_exits_mode() {
2291 let mut app = App::new(starter_model()).unwrap();
2292 app.begin_import_csv();
2293 app.command.as_mut().unwrap().1 = "sales.csv notanumber Revenue revenue".to_string();
2294 app.commit_command();
2295 assert!(app.command.is_none());
2296 assert!(app
2297 .status
2298 .as_deref()
2299 .unwrap_or("")
2300 .starts_with("import-csv:"));
2301 let missing = tmp_path(".missing.csv");
2303 app.begin_import_csv();
2304 app.command.as_mut().unwrap().1 = format!("{} 100 Revenue v t:9:P", missing.display());
2305 app.commit_command();
2306 assert!(app.status.as_deref().unwrap().starts_with("import-csv: "));
2307 assert!(app.model.measure_by_name("Revenue").is_none());
2308 }
2309
2310 #[test]
2311 fn export_command_end_to_end_round_trips() {
2312 let csv_path = tmp_path(".csv");
2314 let _csv_guard = TmpFile(csv_path.clone());
2315 std::fs::write(
2316 &csv_path,
2317 "time,product,revenue\n2025,WidgetA,1000\n2026,WidgetA,1200\n",
2318 )
2319 .unwrap();
2320
2321 let mut app = App::new(starter_model()).unwrap();
2322 app.begin_import_csv();
2323 app.command.as_mut().unwrap().1 = format!(
2324 "{} 100 Revenue revenue time:1:Time product:2:Product",
2325 csv_path.display()
2326 );
2327 app.commit_command();
2328 assert!(app.status.as_deref().unwrap_or("").contains("imported 2"));
2329
2330 let out_path = tmp_path(".out.csv");
2331 let _out_guard = TmpFile(out_path.clone());
2332 app.begin_export_csv();
2333 app.command.as_mut().unwrap().1 = format!("{} 100", out_path.display());
2334 app.commit_command();
2335 assert!(app.command.is_none());
2336 let status = app.status.clone().unwrap_or_default();
2337 assert!(status.contains("exported 2"), "got: {status}");
2338
2339 let contents = std::fs::read_to_string(&out_path).unwrap();
2340 assert!(contents.contains("Time"));
2341 assert!(contents.contains("Product"));
2342 assert!(contents.contains("Revenue"));
2343 assert_eq!(contents.lines().count(), 3); }
2345
2346 #[test]
2347 fn export_command_reports_error_for_unknown_measure() {
2348 let mut app = App::new(starter_model()).unwrap();
2349 app.begin_export_csv();
2350 app.command.as_mut().unwrap().1 = "out.csv 999".to_string();
2351 app.commit_command();
2352 assert!(app.command.is_none());
2353 assert!(app
2354 .status
2355 .as_deref()
2356 .unwrap_or("")
2357 .starts_with("export-csv:"));
2358 }
2359
2360 #[test]
2361 fn cancel_command_clears_state_without_side_effects() {
2362 let mut app = App::new(starter_model()).unwrap();
2363 app.begin_import_csv();
2364 app.command.as_mut().unwrap().1 = "whatever".to_string();
2365 app.cancel_command();
2366 assert!(app.command.is_none());
2367 assert!(app.status.is_none());
2368 }
2369}