Skip to main content

improv_tui/
main.rs

1//! `improv-tui`: a VisiCalc-style pivot grid over an Improv model.
2//!
3//! Usage: `improv-tui <model.db | postgres://…?model=NAME>`
4//!
5//! The binary is a thin shell: [`run`] owns the real terminal (raw mode, alt
6//! screen, panic hook) and feeds `crossterm` events into [`event_loop`], which
7//! is generic over the ratatui backend and the event source so tests can drive
8//! it with a `TestBackend` and a scripted `Vec<Event>`.
9#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
10// The attribute is only used inside #[cfg(test)] modules, so non-test
11// coverage builds would otherwise warn that the feature is unused.
12#![cfg_attr(coverage_nightly, allow(unused_features))]
13
14mod app;
15mod complete;
16mod diagnose;
17mod edit;
18mod fnhelp;
19mod ui;
20
21use app::App;
22use crossterm::{
23    event::{
24        self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
25        KeyModifiers, MouseButton, MouseEventKind,
26    },
27    execute,
28    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
29};
30use improv_store::Store;
31use ratatui::{
32    backend::{Backend, CrosstermBackend},
33    Terminal,
34};
35use std::io;
36
37// Coverage exclusion: process entry point (argv + `process::exit`); its logic
38// lives in `load`/`save`, which are tested.
39#[cfg_attr(coverage_nightly, coverage(off))]
40fn main() {
41    let Some(path) = std::env::args().nth(1) else {
42        eprintln!("usage: improv-tui <model.db>");
43        std::process::exit(1);
44    };
45    // Load the model BEFORE touching the terminal, so errors print cleanly.
46    if let Err(e) = load(&path).and_then(|app| run(app, &path)) {
47        eprintln!("error: {e}");
48        std::process::exit(1);
49    }
50}
51
52/// Open the model at `path` and build the app state.
53fn load(path: &str) -> Result<App, String> {
54    let (model, save_token) = Store::open(path)
55        .and_then(|mut s| Ok((s.load_model()?, s.save_token())))
56        .map_err(|e| format!("cannot open model '{path}': {e}"))?;
57    let mut app = App::new(model)?;
58    app.save_token = save_token;
59    Ok(app)
60}
61
62/// Autosave the (possibly edited) model back to `path` on clean exit; edits
63/// are held in memory during the session.
64fn save(app: &App, path: &str) -> Result<(), String> {
65    Store::open(path)
66        .and_then(|mut s| {
67            s.set_save_token(&app.save_token);
68            s.save_model(&app.model)
69        })
70        .map_err(|e| format!("autosave failed: {e}"))
71}
72
73/// Enter the alt screen, run the loop, and always restore the terminal — even
74/// on error or panic — then autosave.
75// Coverage exclusion: needs a real tty (raw mode, alt screen, mouse capture,
76// panic-hook restore); everything it calls is covered via `event_loop`.
77#[cfg_attr(coverage_nightly, coverage(off))]
78fn run(mut app: App, path: &str) -> Result<(), String> {
79    enable_raw_mode().map_err(|e| e.to_string())?;
80    execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture).map_err(|e| e.to_string())?;
81
82    // Restore the terminal if the run loop panics, then re-raise.
83    let prev_hook = std::panic::take_hook();
84    std::panic::set_hook(Box::new(move |info| {
85        let _ = restore();
86        prev_hook(info);
87    }));
88
89    let result = Terminal::new(CrosstermBackend::new(io::stdout()))
90        .and_then(|mut term| {
91            event_loop(
92                &mut term,
93                &mut app,
94                path,
95                std::iter::repeat_with(event::read),
96            )
97        })
98        .map_err(|e| e.to_string());
99
100    // Always restore on the normal path too.
101    let _ = restore();
102    result?;
103    save(&app, path)
104}
105
106/// Undo terminal setup. Safe to call multiple times.
107// Coverage exclusion: real-terminal teardown (see `run`).
108#[cfg_attr(coverage_nightly, coverage(off))]
109fn restore() -> io::Result<()> {
110    execute!(io::stdout(), DisableMouseCapture, LeaveAlternateScreen)?;
111    disable_raw_mode()
112}
113
114/// Draw, then handle one event, until the app quits or `events` runs dry.
115fn event_loop<B: Backend>(
116    term: &mut Terminal<B>,
117    app: &mut App,
118    path: &str,
119    events: impl IntoIterator<Item = io::Result<Event>>,
120) -> io::Result<()> {
121    let mut events = events.into_iter();
122    while !app.should_quit {
123        term.draw(|f| ui::render(f, app))?;
124        let Some(ev) = events.next() else { break };
125        handle_event(app, ev?, path);
126    }
127    Ok(())
128}
129
130/// Dispatch one terminal event by mode (edit / command prompt / normal).
131fn handle_event(app: &mut App, ev: Event, path: &str) {
132    match ev {
133        Event::Key(k) if k.kind != KeyEventKind::Release && app.help.is_some() => {
134            help_key(app, k.code)
135        }
136        Event::Key(k) if k.kind != KeyEventKind::Release => match (k.code, &mut app.edit) {
137            (KeyCode::F(1), _) => app.help = Some(fnhelp::FnHelp::new()),
138            (KeyCode::Enter, Some(_)) => app.commit_edit(),
139            (KeyCode::Esc, Some(_)) => app.cancel_edit(),
140            (code, Some(_)) => app.edit_key(code),
141            (code, None) => match (code, &mut app.command) {
142                (KeyCode::Enter, Some(_)) => app.commit_command(),
143                (KeyCode::Esc, Some(_)) => app.cancel_command(),
144                (code, Some((_, buf))) => line_key(buf, code),
145                (_, None) => normal_key(app, k, path),
146            },
147        },
148        // Mouse: left-click selects the cell under the pointer (hit-tested
149        // against the last-rendered grid geometry stored on `app`).
150        Event::Mouse(m) if app.edit.is_none() && app.command.is_none() && app.help.is_none() => {
151            if let MouseEventKind::Down(MouseButton::Left) = m.kind {
152                app.click_at(m.column, m.row);
153            }
154        }
155        _ => {}
156    }
157}
158
159/// Line-editing keys for the command prompt (the cell edit has a caret:
160/// [`edit::LineEdit::key`]).
161fn line_key(buf: &mut String, code: KeyCode) {
162    match code {
163        KeyCode::Backspace => {
164            buf.pop();
165        }
166        KeyCode::Char(c) => buf.push(c),
167        _ => {}
168    }
169}
170
171/// Keys while the function help overlay is open: type to search, move the
172/// selection, Enter inserts `NAME(` into an edit in progress, Esc closes.
173fn help_key(app: &mut App, code: KeyCode) {
174    let Some(h) = &mut app.help else { return };
175    match code {
176        KeyCode::Esc => app.help = None,
177        KeyCode::Enter => {
178            if let (Some(d), Some(buf)) = (h.current(), &mut app.edit) {
179                buf.insert(&format!("{}(", d.name));
180                app.tab_cycle = None;
181                app.diagnostic = None; // its span is into the old text
182            }
183            app.help = None;
184        }
185        KeyCode::Up => h.step(-1),
186        KeyCode::Down => h.step(1),
187        KeyCode::PageUp => h.page_by(-1),
188        KeyCode::PageDown => h.page_by(1),
189        KeyCode::Backspace => h.pop(),
190        KeyCode::Char(c) => h.push(c),
191        _ => {}
192    }
193}
194
195/// Key handling in normal (navigation) mode.
196fn normal_key(app: &mut App, k: KeyEvent, path: &str) {
197    let shift = k.modifiers.contains(KeyModifiers::SHIFT);
198    match k.code {
199        KeyCode::Char('q') => app.should_quit = true,
200        KeyCode::Char('e') | KeyCode::Enter => app.begin_edit(),
201        KeyCode::Tab | KeyCode::Char('m') => app.next_measure(),
202        // Shift+arrow grows a range selection (its Sum shows in the status).
203        KeyCode::Up => app.step(-1, 0, shift),
204        KeyCode::Down => app.step(1, 0, shift),
205        KeyCode::Left => app.step(0, -1, shift),
206        KeyCode::Right => app.step(0, 1, shift),
207        KeyCode::Char('[') => app.page(-1),
208        KeyCode::Char(']') => app.page(1),
209        KeyCode::Char('p') => app.pivot(),
210        // Filters: 'f' hides/shows the row item under the cursor; 'F' clears.
211        KeyCode::Char('f') => filter_row_under_cursor(app),
212        KeyCode::Char('F') => app.clear_filters(),
213        // Views: 'S' saves the current layout; 'v' cycles saved views.
214        KeyCode::Char('S') => save_current_view(app, path),
215        KeyCode::Char('v') => app.cycle_view(),
216        // CSV import/export: 'I' opens the import-csv prompt, 'E' export-csv.
217        KeyCode::Char('I') => app.begin_import_csv(),
218        KeyCode::Char('E') => app.begin_export_csv(),
219        // 'R' reverts to the saved model (e.g. after another writer saved).
220        KeyCode::Char('R') => revert(app, path),
221        KeyCode::Char('?') => app.help = Some(fnhelp::FnHelp::new()),
222        KeyCode::Esc => app.status = None,
223        _ => {}
224    }
225}
226
227/// Reload the model from `path`, dropping this session's unsaved edits. The
228/// way out of a save conflict: the quit autosave refuses to clobber another
229/// writer, so revert, pick up their changes, and redo yours. On failure the
230/// session is untouched.
231fn revert(app: &mut App, path: &str) {
232    match load(path) {
233        Ok(fresh) => {
234            *app = fresh;
235            app.status = Some("reverted to the saved model".into());
236        }
237        Err(e) => app.status = Some(format!("revert failed: {e}")),
238    }
239}
240
241/// Toggle the visibility of the row item under the cursor within its (row)
242/// category. Repeated 'f' on different rows narrows the row axis to a subset.
243fn filter_row_under_cursor(app: &mut App) {
244    let (Some((cat, _)), Some((item, _))) = (
245        app.grid.row_cat.clone(),
246        app.grid.rows.get(app.cursor_row).cloned(),
247    ) else {
248        app.status = Some("no row category to filter".into());
249        return;
250    };
251    app.toggle_filter_item(cat, item);
252}
253
254/// Save the current layout as a generated "view N" name and persist to `path`.
255/// (Inline text entry is intentionally omitted; the view can be renamed via a
256/// future dialog. Documented simplification.)
257fn save_current_view(app: &mut App, path: &str) {
258    let n = app.model.views.len() + 1;
259    app.save_view(&format!("view {n}"), Some(path));
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::app::tests::{revenue_model, tmp_path, view};
266    use crossterm::event::MouseEvent;
267    use improv_core_model::{CategoryId, Coordinate, ItemId, MeasureId, Value};
268    use ratatui::backend::TestBackend;
269
270    fn key(code: KeyCode) -> io::Result<Event> {
271        Ok(Event::Key(KeyEvent::new(code, KeyModifiers::NONE)))
272    }
273    fn chars(s: &str) -> Vec<io::Result<Event>> {
274        s.chars().map(|c| key(KeyCode::Char(c))).collect()
275    }
276    fn click(kind: MouseEventKind, column: u16, row: u16) -> io::Result<Event> {
277        Ok(Event::Mouse(MouseEvent {
278            kind,
279            column,
280            row,
281            modifiers: KeyModifiers::NONE,
282        }))
283    }
284    fn load_err(p: &str) -> String {
285        load(p).err().expect("load should fail")
286    }
287    /// Run `events` against `app` on a 100x30 test terminal.
288    fn run_events(app: &mut App, path: &str, events: Vec<io::Result<Event>>) -> io::Result<()> {
289        let mut term = Terminal::new(TestBackend::new(100, 30)).unwrap();
290        event_loop(&mut term, app, path, events)
291    }
292
293    #[test]
294    fn typing_an_edit_updates_the_model() {
295        let mut app = App::new(revenue_model()).unwrap();
296        view(&mut app, MeasureId(101));
297        let mut ev = vec![key(KeyCode::Enter), key(KeyCode::Backspace)];
298        ev.extend(chars("x"));
299        ev.extend([key(KeyCode::Backspace), key(KeyCode::Backspace)]);
300        ev.extend(chars("7"));
301        ev.extend([key(KeyCode::F(5)), key(KeyCode::Enter)]);
302        run_events(&mut app, "", ev).unwrap();
303        let at = Coordinate::from_pairs([(CategoryId(1), ItemId(10)), (CategoryId(2), ItemId(20))]);
304        assert_eq!(
305            app.model.input(MeasureId(101), &at),
306            Some(&Value::Number(17.0))
307        );
308        // Esc cancels an edit without writing.
309        run_events(
310            &mut app,
311            "",
312            vec![key(KeyCode::Char('e')), key(KeyCode::Esc)],
313        )
314        .unwrap();
315        assert!(app.edit.is_none());
316    }
317
318    #[test]
319    fn navigation_keys_and_release_events() {
320        let mut app = App::new(revenue_model()).unwrap();
321        view(&mut app, MeasureId(101));
322        let mut release = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
323        release.kind = KeyEventKind::Release;
324        let ev = vec![
325            key(KeyCode::Down),
326            key(KeyCode::Right),
327            Ok(Event::Key(release)), // ignored
328            Ok(Event::FocusGained),  // ignored
329        ];
330        run_events(&mut app, "", ev).unwrap();
331        assert_eq!((app.cursor_row, app.cursor_col), (1, 1));
332        run_events(&mut app, "", vec![key(KeyCode::Up), key(KeyCode::Left)]).unwrap();
333        assert_eq!((app.cursor_row, app.cursor_col), (0, 0));
334        // An unbound key does nothing.
335        let before = (app.selected, app.cursor_row, app.cursor_col);
336        run_events(&mut app, "", vec![key(KeyCode::Char('z'))]).unwrap();
337        assert_eq!((app.selected, app.cursor_row, app.cursor_col), before);
338        assert!(!app.should_quit);
339        // Tab and m both cycle the measure; q quits before later events run.
340        let before = app.selected;
341        let ev = vec![
342            key(KeyCode::Tab),
343            key(KeyCode::Char('m')),
344            key(KeyCode::Char('q')),
345            key(KeyCode::Tab),
346        ];
347        run_events(&mut app, "", ev).unwrap();
348        assert!(app.should_quit);
349        assert_eq!(app.selected, (before + 2) % app.measures.len());
350    }
351
352    #[test]
353    fn layout_keys_page_pivot_filter_and_status() {
354        let mut app = App::new(revenue_model()).unwrap();
355        view(&mut app, MeasureId(101));
356        let ev = vec![
357            key(KeyCode::Char('[')),
358            key(KeyCode::Char(']')),
359            key(KeyCode::Esc),
360        ];
361        run_events(&mut app, "", ev).unwrap();
362        assert_eq!(app.status, None, "Esc clears the paging message");
363        run_events(&mut app, "", chars("f")).unwrap();
364        assert_eq!(app.grid.n_rows(), 1);
365        run_events(&mut app, "", chars("F")).unwrap();
366        assert_eq!(app.grid.n_rows(), 2);
367        run_events(&mut app, "", chars("p")).unwrap();
368        assert_eq!(app.grid.row_cat.as_ref().unwrap().1, "Product");
369        run_events(&mut app, "", chars("v")).unwrap();
370        assert!(app.status.as_deref().unwrap().starts_with("no saved views"));
371        // A scalar measure has no row category to filter.
372        let mut m = improv_core_model::Model::new();
373        m.add_measure(improv_core_model::Measure {
374            id: MeasureId(1),
375            name: improv_core_model::Name("S".into()),
376            value_type: improv_core_model::ValueType::Number,
377            categories: vec![],
378            kind: improv_core_model::MeasureKind::Input,
379            description: None,
380        });
381        let mut scalar = App::new(m).unwrap();
382        run_events(&mut scalar, "", chars("f")).unwrap();
383        assert_eq!(scalar.status.as_deref(), Some("no row category to filter"));
384    }
385
386    #[test]
387    fn mouse_selects_only_in_normal_mode() {
388        let mut app = App::new(revenue_model()).unwrap();
389        view(&mut app, MeasureId(101));
390        // Status box is 4 rows here (title + 2 help + border); data starts
391        // after the grid border + header. Click well inside row 1, col 1.
392        run_events(&mut app, "", vec![]).unwrap();
393        let g = app.grid_geom.unwrap();
394        let (x, y) = (g.x0 + g.col_w + g.spacing + 1, g.y0 + 1);
395        let ev = vec![
396            click(MouseEventKind::Moved, x, y),
397            key(KeyCode::Char('e')),
398            click(MouseEventKind::Down(MouseButton::Left), x, y), // ignored while editing
399            key(KeyCode::Esc),
400        ];
401        run_events(&mut app, "", ev).unwrap();
402        assert_eq!((app.cursor_row, app.cursor_col), (0, 0));
403        run_events(
404            &mut app,
405            "",
406            vec![click(MouseEventKind::Down(MouseButton::Left), x, y)],
407        )
408        .unwrap();
409        assert_eq!((app.cursor_row, app.cursor_col), (1, 1));
410    }
411
412    #[test]
413    fn shift_arrows_extend_the_selection() {
414        let mut app = App::new(revenue_model()).unwrap();
415        view(&mut app, MeasureId(101));
416        let shift = |c| Ok(Event::Key(KeyEvent::new(c, KeyModifiers::SHIFT)));
417        let ev = vec![
418            shift(KeyCode::Down),
419            shift(KeyCode::Right),
420            shift(KeyCode::Up),
421            shift(KeyCode::Left),
422        ];
423        run_events(&mut app, "", ev).unwrap();
424        assert_eq!(app.anchor, Some((0, 0)));
425        run_events(
426            &mut app,
427            "",
428            vec![shift(KeyCode::Right), shift(KeyCode::Down)],
429        )
430        .unwrap();
431        assert_eq!(app.selection_sum(), Some(350.0));
432        run_events(&mut app, "", vec![key(KeyCode::Left)]).unwrap();
433        assert_eq!(app.anchor, None);
434    }
435
436    #[test]
437    fn command_prompt_keys() {
438        let mut app = App::new(revenue_model()).unwrap();
439        let mut ev = vec![key(KeyCode::Char('E'))];
440        ev.extend(chars("xy"));
441        ev.extend([
442            key(KeyCode::Backspace),
443            key(KeyCode::Home),
444            key(KeyCode::Enter),
445        ]);
446        run_events(&mut app, "", ev).unwrap();
447        assert!(app.command.is_none());
448        assert!(app.status.as_deref().unwrap().starts_with("export-csv:"));
449        run_events(
450            &mut app,
451            "",
452            vec![key(KeyCode::Char('I')), key(KeyCode::Esc)],
453        )
454        .unwrap();
455        assert!(app.command.is_none());
456        // A click while the prompt is open is ignored (it would hit row 0,
457        // column 1: the prompt line pushes the grid down one row).
458        view(&mut app, MeasureId(101));
459        run_events(&mut app, "", vec![]).unwrap();
460        let g = app.grid_geom.unwrap();
461        let (x, y) = (g.x0 + g.col_w + g.spacing + 1, g.y0 + 1);
462        let ev = vec![
463            key(KeyCode::Char('I')),
464            click(MouseEventKind::Down(MouseButton::Left), x, y),
465            key(KeyCode::Esc),
466        ];
467        run_events(&mut app, "", ev).unwrap();
468        assert_eq!((app.cursor_row, app.cursor_col), (0, 0));
469    }
470
471    #[test]
472    fn tab_completes_in_a_formula_only() {
473        let mut app = App::new(revenue_model()).unwrap();
474        view(&mut app, MeasureId(102));
475        let mut ev = vec![key(KeyCode::Char('e'))];
476        ev.extend((0..30).map(|_| key(KeyCode::Backspace)));
477        ev.extend(chars("Pri"));
478        ev.push(key(KeyCode::Tab));
479        ev.extend(chars(" * quan"));
480        ev.push(key(KeyCode::Tab));
481        run_events(&mut app, "", ev).unwrap();
482        assert_eq!(app.edit.as_deref(), Some("Price * Quantity"));
483        // Several matches: the status lists them; Tab cycles; a key resets.
484        let mut ev = chars(" + sumx");
485        ev.extend([key(KeyCode::Tab), key(KeyCode::Tab)]);
486        run_events(&mut app, "", ev).unwrap();
487        assert_eq!(app.edit.as_deref(), Some("Price * Quantity + SUMXMY2("));
488        assert!(app.status.as_deref().unwrap().starts_with("Tab cycles:"));
489        run_events(&mut app, "", vec![key(KeyCode::Backspace)]).unwrap();
490        assert!(app.tab_cycle.is_none());
491        assert!(app.status.is_none());
492        run_events(&mut app, "", vec![key(KeyCode::Esc)]).unwrap();
493        // In a value edit Tab does nothing.
494        view(&mut app, MeasureId(101));
495        let mut ev = vec![key(KeyCode::Char('e'))];
496        ev.extend(chars("pm"));
497        ev.push(key(KeyCode::Tab));
498        run_events(&mut app, "", ev).unwrap();
499        assert_eq!(app.edit.as_deref(), Some("100pm"));
500    }
501
502    #[test]
503    fn help_overlay_keys() {
504        let mut app = App::new(revenue_model()).unwrap();
505        view(&mut app, MeasureId(101));
506        let mut ev = vec![key(KeyCode::Char('?'))];
507        ev.extend(chars("pmtx"));
508        ev.extend([
509            key(KeyCode::Backspace),
510            key(KeyCode::Down),
511            key(KeyCode::Up),
512            key(KeyCode::PageDown),
513            key(KeyCode::PageUp),
514            key(KeyCode::Right),                                    // ignored
515            click(MouseEventKind::Down(MouseButton::Left), 40, 20), // ignored
516        ]);
517        run_events(&mut app, "", ev).unwrap();
518        let h = app.help.as_ref().expect("? opens the overlay");
519        assert_eq!(h.query, "pmt");
520        assert_eq!(h.current().unwrap().name, "PMT");
521        // Enter with no edit in progress just closes; Esc closes too.
522        run_events(&mut app, "", vec![key(KeyCode::Enter)]).unwrap();
523        assert!(app.help.is_none());
524        assert!(app.edit.is_none());
525        run_events(&mut app, "", vec![key(KeyCode::F(1)), key(KeyCode::Esc)]).unwrap();
526        assert!(app.help.is_none());
527        // F1 mid-edit: Enter inserts the selected function's `NAME(`.
528        let mut ev = vec![key(KeyCode::Char('e')), key(KeyCode::F(1))];
529        ev.extend(chars("pmt"));
530        ev.push(key(KeyCode::Enter));
531        run_events(&mut app, "", ev).unwrap();
532        assert!(app.help.is_none());
533        assert_eq!(app.edit.as_deref(), Some("100PMT("));
534        // Help keys with the overlay closed do nothing.
535        help_key(&mut app, KeyCode::Enter);
536        assert!(app.help.is_none());
537        assert_eq!(app.edit.as_deref(), Some("100PMT("));
538    }
539
540    /// Inserting from the help overlay changes the text, so the error
541    /// located in the old text goes: its byte span would now point into
542    /// shifted text, mid-'€' here, and drawing the marker would panic.
543    #[test]
544    fn help_insert_drops_the_stale_diagnostic() {
545        let mut app = App::new(revenue_model()).unwrap();
546        view(&mut app, MeasureId(102));
547        let mut ev = vec![key(KeyCode::Char('e'))];
548        ev.extend((0..30).map(|_| key(KeyCode::Backspace)));
549        ev.extend(chars("\"€\"+Quantty"));
550        ev.extend([key(KeyCode::Enter), key(KeyCode::Home)]);
551        run_events(&mut app, "", ev).unwrap();
552        assert_eq!(app.diagnostic.as_ref().unwrap().span, Some(6..13));
553        let mut ev = vec![key(KeyCode::F(1))];
554        ev.extend(chars("pmt"));
555        ev.push(key(KeyCode::Enter));
556        run_events(&mut app, "", ev).unwrap();
557        assert_eq!(app.edit.as_deref(), Some("PMT(\"€\"+Quantty"));
558        assert!(app.diagnostic.is_none());
559    }
560
561    #[test]
562    fn event_errors_stop_the_loop() {
563        let mut app = App::new(revenue_model()).unwrap();
564        let err = run_events(&mut app, "", vec![Err(io::Error::other("boom"))]);
565        assert_eq!(err.unwrap_err().to_string(), "boom");
566    }
567
568    #[test]
569    fn load_save_round_trip_and_save_view_key() {
570        let path = tmp_path("tui-main.db");
571        let p = path.to_str().unwrap();
572        assert!(load_err(p).contains("no measures"), "fresh store is empty");
573        let mut app = App::new(revenue_model()).unwrap();
574        save(&app, p).unwrap();
575        run_events(&mut app, p, chars("S")).unwrap();
576        assert_eq!(app.status.as_deref(), Some("saved view 'view 1'"));
577        let loaded = load(p).unwrap();
578        assert_eq!(loaded.model.views.len(), 1);
579        assert!(load_err("/nonexistent/dir/x.db").starts_with("cannot open model"));
580        assert!(save(&app, "/nonexistent/dir/x.db")
581            .unwrap_err()
582            .starts_with("autosave failed"));
583    }
584
585    /// Two sessions on one store: the second's autosave conflicts after the
586    /// first saved; 'R' reverts it to the saved model and its next save works.
587    #[test]
588    fn revert_key_recovers_from_a_save_conflict() {
589        let path = tmp_path("tui-revert.db");
590        let p = path.to_str().unwrap();
591        save(&App::new(revenue_model()).unwrap(), p).unwrap();
592        let mut a = load(p).unwrap();
593        let mut b = load(p).unwrap();
594        run_events(&mut a, p, chars("S")).unwrap();
595        b.model.views.clear();
596        b.model
597            .add_view(b.build_view(improv_core_model::ViewId(77), "mine"));
598        let e = save(&b, p).unwrap_err();
599        assert!(e.contains("conflict"), "{e}");
600        run_events(&mut b, p, chars("R")).unwrap();
601        assert_eq!(b.status.as_deref(), Some("reverted to the saved model"));
602        assert_eq!(b.model.views.len(), 1, "picked up the other session's view");
603        save(&b, p).expect("saves after the revert");
604        run_events(&mut b, "/nonexistent/dir/x.db", chars("R")).unwrap();
605        assert!(b.status.as_deref().unwrap().starts_with("revert failed"));
606        assert_eq!(b.model.views.len(), 1, "a failed revert keeps the session");
607    }
608}