mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-09 20:09:57 -06:00
* UI: an unsaved macro-editor draft counts as unsaved work when its workbook closes Closing a workbook (File > Close, Cmd+W, the Backstage Close, a window's close button) while the macro editor holds unsaved changes now asks about the draft first, with the editor's own Save changes to macro question, even if the workbook itself is saved. Before, the workbook closed and the draft was discarded without a question. Save or Don't Save goes on to close the workbook, which then asks about its own unsaved changes (a saved macro is one); Cancel keeps both open. A draft that cannot be saved keeps its workbook open with the reason shown. A new macro that has not been edited does not ask, and a close that leaves the workbook open (one of several windows) or a forced close goes ahead as before. * UI: a new macro's name change alone counts as an unsaved macro-editor draft The check compared the draft with an untouched new macro that took the draft's current name, so a new macro whose only change was its name compared equal, and closing its workbook discarded the name without asking. It now compares with the name the editor opened with. * UI: Quit asks about an unsaved macro-editor draft before quitting Quit (Cmd+Q, the macOS Quit menu, a quit from the Dock) went straight to the unsaved workbooks and never consulted the macro step editor, so with every workbook saved the app quit and discarded the editor's unsaved steps. It now lets the editor ask first, as the window's close button already does: Save or Don't Save goes on with the quit, Cancel stops it. With one native window per workbook, the editor goes on with the app's quit directly once it has closed: a close sent to the window it is drawn in would close only that workbook's window. * UI: a refused macro save while quitting stops the quit When the macro editor's Save changes question was answered Save during a quit (the window's close button or Quit) and the save was refused (a new macro with a name already in use, a limit), the editor stayed open with the reason shown but kept its quit intent. Closing the editor later on its own then quit the app. A refused save now stops the quit, as Cancel does. * UI: a quit takes over a workbook close waiting on the macro editor When File > Close was waiting on the macro editor's question and Quit came in (or the other way round), closing the editor went on with the quit and then replayed the close. With one native window per workbook, the quit had already moved to the next unsaved workbook's window, so the replayed close acted on that workbook and replaced the quit's Save Changes question with an ordinary close question. The quit now takes over: it asks about every unsaved workbook itself, and the waiting close is dropped. * UI: an untouched new macro closes without asking, however the editor closes Closing a workbook already left an untouched new macro alone, but the editor's own close checks still read its dirty flag, which a new macro starts with: its close button, a quit, the control channel's cancel and the close after a stopped debug run asked Save changes to macro (or refused) for a macro nobody had edited. Every close check now asks whether the draft has unsaved changes, so all of them agree. * UI: closing the window that shows the macro editor asks about its draft The editor's unsaved draft was consulted only when its workbook would close. Closing the window that shows the editor while the workbook stayed open in another window (window.close, or File > Close in that window) went ahead: the window's dialog went with it, and the editor's state was left behind with no window to show it, so the next macro opened in the editor replaced the draft without asking. That close now asks first, like a workbook close. An editor left with no window to show it (a saved one, or a forced close) is now dropped, as when its workbook closes. A window that has closed no longer counts as showing a dialog while its parked state is still kept. * UI: a close waiting on the macro editor runs in the window it was asked in A workbook or window close that waited on the editor's Save changes question was replayed with the command and its parameters alone, so file.close acted on whichever window was the session's active one when the editor closed. The editor is drawn with its own window active, but nothing tied the replay to it. The pending close now records the editor's window and enters it before running again; if that window has closed meanwhile, nothing else is closed and a message says so. * UI: a close asked while the macro editor is asking waits for the answer A second file.close or window.close for the editor's workbook or window while its Save changes question stood replaced the pending close with the new one: the answer then closed whatever the last request asked for, not what the question had been opened for. A further request for the same close now waits on the open question (no second question, and the close runs once); a request for a different close (the workbook while only the editor's window was closing, or the reverse) is refused with the reason until the question is answered. A quit keeps taking over as before. * UI: test the quit with a macro draft in one window and unsaved cells in another The quit asks about the editor's draft first, in its window, then about each unsaved workbook in its window; Cancel at either question stops it with every window and workbook open, and a draft discarded with Don't Save before the quit was cancelled stays discarded. Covered in the single-window harness, which presses the real buttons, and in the native-windows harness, which now takes a key press in the root window. --------- Co-authored-by: FredApps <FredApps@users.noreply.github.com>
1041 lines
47 KiB
Rust
1041 lines
47 KiB
Rust
//! The macro step editor: editing, Add Step, breakpoints, dragging steps, and the debugger
|
||
//! (step, continue, run to the end as one undo step, stop with keep or undo).
|
||
|
||
use egui::{Event, Key, Modifiers, PointerButton};
|
||
use egui_kittest::kittest::Queryable as _;
|
||
use gridcraft_engine::Session;
|
||
use gridcraft_engine::core::{CellRef, Value};
|
||
use gridcraft_engine::model::{Step, StepKind};
|
||
use gridcraft_ui_egui::{Services, SheetApp};
|
||
use serde_json::json;
|
||
|
||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||
|
||
fn harness() -> Harness {
|
||
harness_with(Default::default())
|
||
}
|
||
|
||
fn harness_with(services: Services) -> Harness {
|
||
let mut session = Session::new();
|
||
session.new_workbook();
|
||
let mut app = SheetApp::new(session, services);
|
||
app.ui.ribbon_tab = "Automate".into();
|
||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(
|
||
|ui, app: &mut SheetApp| {
|
||
let ctx = ui.ctx().clone();
|
||
app.logic(&ctx);
|
||
app.ui(ui);
|
||
},
|
||
app,
|
||
);
|
||
h.run_steps(4);
|
||
h
|
||
}
|
||
|
||
fn value(h: &Harness, a: &str) -> Value {
|
||
h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(CellRef::parse(a).unwrap_or_default()))).unwrap_or_default()
|
||
}
|
||
|
||
/// Three steps that write A1, A2, A3.
|
||
fn three(h: &mut Harness) {
|
||
let steps: Vec<_> = ["A1", "A2", "A3"].iter().map(|c| json!({"command": "cell.set", "params": {"cell": c, "input": c}})).collect();
|
||
h.state_mut().run("macro.save", json!({"macro": {"name": "Three", "output": {"onConflict": "overwrite"}, "steps": steps}})).unwrap();
|
||
h.state_mut().open_dialog("macroEditor", json!({"macro": "Three"}));
|
||
h.run_steps(3);
|
||
}
|
||
|
||
fn draft_cells(h: &Harness) -> Vec<String> {
|
||
let ed = h.state().macros.editor.as_ref().unwrap();
|
||
ed.draft
|
||
.steps
|
||
.iter()
|
||
.map(|s| match &s.kind {
|
||
StepKind::Command { params, .. } => params["cell"].as_str().unwrap_or("").to_string(),
|
||
_ => String::new(),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn click(h: &mut Harness, label: &str) {
|
||
h.get_by_label(label).click();
|
||
h.run_steps(3);
|
||
}
|
||
|
||
#[test]
|
||
fn new_macro_add_step_through_the_picker_and_save() {
|
||
let mut h = harness();
|
||
click(&mut h, "New Macro");
|
||
assert!(h.state().macros.editor.is_some());
|
||
click(&mut h, "Add Step");
|
||
h.input_mut().events.push(Event::Text("bold".into()));
|
||
h.run_steps(2);
|
||
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||
h.run_steps(3);
|
||
let ed = h.state().macros.editor.clone().unwrap();
|
||
assert!(matches!(&ed.draft.steps[..], [s] if matches!(&s.kind, StepKind::Command { id, .. } if id == "home.bold")));
|
||
assert_eq!(ed.selected.as_deref(), Some("1"));
|
||
click(&mut h, "Save");
|
||
let wb = &h.state().session.active().unwrap().wb;
|
||
assert_eq!(wb.macros.len(), 1);
|
||
assert_eq!(wb.macros[0].name, ed.draft.name);
|
||
assert!(!h.state().macros.editor.as_ref().unwrap().dirty);
|
||
}
|
||
|
||
#[test]
|
||
fn the_gutter_toggles_breakpoints() {
|
||
let mut h = harness();
|
||
three(&mut h);
|
||
click(&mut h, "Breakpoint 2");
|
||
assert!(h.state().macros.editor.as_ref().unwrap().draft.steps[1].breakpoint);
|
||
click(&mut h, "Breakpoint 2");
|
||
assert!(!h.state().macros.editor.as_ref().unwrap().draft.steps[1].breakpoint);
|
||
}
|
||
|
||
#[test]
|
||
fn dragging_a_step_reorders_the_list() {
|
||
let mut h = harness();
|
||
three(&mut h);
|
||
let from = h.get_by_label("Step 1: Set A1 to A1").rect().center();
|
||
let to = h.get_by_label("Step 3: Set A3 to A3").rect().center() + egui::vec2(0.0, 8.0);
|
||
h.input_mut().events.push(Event::PointerMoved(from));
|
||
h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||
h.run_steps(2);
|
||
for k in 1..=6 {
|
||
let p = from + (to - from) * (k as f32 / 6.0);
|
||
h.input_mut().events.push(Event::PointerMoved(p));
|
||
h.run_steps(1);
|
||
}
|
||
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||
h.run_steps(3);
|
||
assert_eq!(draft_cells(&h), ["A2", "A3", "A1"]);
|
||
let ed = h.state().macros.editor.as_ref().unwrap();
|
||
assert!(ed.dirty);
|
||
assert_eq!(ed.selected.as_deref(), Some("3"), "the moved step stays selected");
|
||
}
|
||
|
||
#[test]
|
||
fn step_continue_and_finish_as_one_undo_step() {
|
||
let mut h = harness();
|
||
three(&mut h);
|
||
click(&mut h, "Breakpoint 3");
|
||
let undo_before = h.state().session.active().unwrap().undo.len();
|
||
// Continue starts the run (saving the breakpoint first) and stops before step 3.
|
||
click(&mut h, "Continue");
|
||
let ed = h.state().macros.editor.clone().unwrap();
|
||
assert_eq!(ed.debug.as_ref().and_then(|d| d["current"]["step"].as_str()), Some("3"));
|
||
assert_eq!(ed.selected.as_deref(), Some("3"), "the current step is selected");
|
||
assert!(h.query_by_label_contains("Paused before step 3").is_some());
|
||
assert_eq!((value(&h, "A1"), value(&h, "A3")), (Value::Text("A1".into()), Value::Empty));
|
||
assert!(h.state().grid.macro_overlay.as_ref().is_some_and(|o| !o.target.is_empty()), "the target is outlined");
|
||
// Clearing the breakpoint while paused, then Step runs step 3 and the run finishes.
|
||
click(&mut h, "Breakpoint 3");
|
||
click(&mut h, "Step");
|
||
assert!(h.state().session.macro_debug.is_none());
|
||
assert_eq!(value(&h, "A3"), Value::Text("A3".into()));
|
||
let ed = h.state().macros.editor.clone().unwrap();
|
||
assert!(ed.outcome.as_deref().is_some_and(|o| o.starts_with("Finished")), "{:?}", ed.outcome);
|
||
let doc = h.state().session.active().unwrap();
|
||
// The breakpoint save is one entry, the run another.
|
||
assert_eq!(doc.undo.last().map(|e| e.label.as_str()), Some("Run Macro: Three"));
|
||
assert!(doc.undo.len() <= undo_before + 2);
|
||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||
assert_eq!((value(&h, "A1"), value(&h, "A3")), (Value::Empty, Value::Empty));
|
||
}
|
||
|
||
#[test]
|
||
fn run_to_here_then_stop_asks_whether_to_keep_the_changes() {
|
||
for keep in [false, true] {
|
||
let mut h = harness();
|
||
three(&mut h);
|
||
click(&mut h, "Step 3: Set A3 to A3");
|
||
click(&mut h, "Run to Here");
|
||
assert_eq!(
|
||
h.state().macros.editor.as_ref().unwrap().debug.as_ref().and_then(|d| d["current"]["step"].as_str()),
|
||
Some("3"),
|
||
"{:?} {:?}",
|
||
h.state().message,
|
||
h.state().macros.editor.as_ref().unwrap().outcome
|
||
);
|
||
click(&mut h, "Stop");
|
||
assert!(h.state().macros.editor.as_ref().unwrap().ask_stop);
|
||
click(&mut h, if keep { "Keep Changes" } else { "Undo Changes" });
|
||
assert!(h.state().session.macro_debug.is_none());
|
||
let want = if keep { Value::Text("A2".into()) } else { Value::Empty };
|
||
assert_eq!(value(&h, "A2"), want, "keep = {keep}");
|
||
assert_eq!(value(&h, "A3"), Value::Empty);
|
||
}
|
||
}
|
||
|
||
/// Step 3's Cell box is picking from the grid, with the selection it last saw (A1).
|
||
fn pick_step_3_cell(h: &mut Harness) {
|
||
let refs = &mut h.state_mut().macros.editor.as_mut().unwrap().refs;
|
||
refs.pick = Some("ref:3:cell".into());
|
||
refs.seen = "$A$1".into();
|
||
}
|
||
|
||
#[test]
|
||
fn a_pick_ends_when_its_box_leaves_the_screen() {
|
||
// Choosing another step hides the picking box; a selection made meanwhile must not land in
|
||
// it when the step is shown again.
|
||
let mut h = harness();
|
||
three(&mut h);
|
||
click(&mut h, "Step 3: Set A3 to A3");
|
||
pick_step_3_cell(&mut h);
|
||
h.run_steps(2);
|
||
click(&mut h, "Step 1: Set A1 to A1");
|
||
assert!(h.state().macros.editor.as_ref().unwrap().refs.pick.is_none(), "the pick ends with its box");
|
||
h.state_mut().run("selection.set", json!({"range": "F9"})).unwrap();
|
||
h.run_steps(2);
|
||
click(&mut h, "Step 3: Set A3 to A3");
|
||
let ed = h.state().macros.editor.as_ref().unwrap();
|
||
assert!(!ed.dirty, "the draft stays clean");
|
||
assert_eq!(draft_cells(&h), ["A1", "A2", "A3"]);
|
||
}
|
||
|
||
#[test]
|
||
fn debugging_never_writes_the_selection_into_a_reference_box() {
|
||
// A reference box that was picking from the grid stays out of it once an action starts, and
|
||
// the selections the debugger makes while it runs never land in a box or dirty the draft.
|
||
let mut h = harness();
|
||
three(&mut h);
|
||
click(&mut h, "Step 3: Set A3 to A3");
|
||
pick_step_3_cell(&mut h);
|
||
h.run_steps(2);
|
||
assert_eq!(draft_cells(&h), ["A1", "A2", "A3"]);
|
||
click(&mut h, "Run to Here");
|
||
assert!(h.state().session.macro_debug.is_some());
|
||
let ed = h.state().macros.editor.as_ref().unwrap();
|
||
assert!(ed.refs.pick.is_none(), "the action ends the pick");
|
||
assert!(!ed.dirty);
|
||
// A box focused while debugging does not take the selection either.
|
||
pick_step_3_cell(&mut h);
|
||
h.state_mut().run("selection.set", json!({"range": "F9"})).unwrap();
|
||
h.run_steps(3);
|
||
let ed = h.state().macros.editor.as_ref().unwrap();
|
||
assert!(!ed.dirty, "the draft stays clean");
|
||
assert_eq!(draft_cells(&h), ["A1", "A2", "A3"]);
|
||
h.state_mut().macros.editor.as_mut().unwrap().refs.pick = None;
|
||
click(&mut h, "Stop");
|
||
click(&mut h, "Undo Changes");
|
||
assert!(h.state().session.macro_debug.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn step_into_from_the_macros_dialog_pauses_before_the_first_step() {
|
||
let mut h = harness();
|
||
three(&mut h);
|
||
h.state_mut().dialog = None;
|
||
h.state_mut().macros.editor = None;
|
||
h.state_mut().open_dialog("macros", json!({"name": "Three"}));
|
||
h.run_steps(2);
|
||
click(&mut h, "Step Into");
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor"));
|
||
assert!(h.state().session.macro_debug.is_some());
|
||
assert!(h.query_by_label_contains("Debugging “Three”").is_some(), "the status bar says so");
|
||
}
|
||
|
||
#[test]
|
||
fn a_recorded_macro_opens_in_the_editor() {
|
||
let mut h = harness();
|
||
click(&mut h, "Record Macro");
|
||
// The Record Macro dialog comes first.
|
||
h.run_steps(2);
|
||
click(&mut h, "OK");
|
||
assert!(h.state().session.is_recording());
|
||
h.state_mut().run("cell.set", json!({"cell": "B2", "input": "5"})).unwrap();
|
||
h.state_mut().run("home.bold", json!({})).unwrap();
|
||
h.run_steps(2);
|
||
click(&mut h, "Stop");
|
||
let name = h.state().session.active().unwrap().wb.macros[0].name.clone();
|
||
h.state_mut().open_dialog("macroEditor", json!({"macro": name}));
|
||
h.run_steps(3);
|
||
let ed = h.state().macros.editor.clone().unwrap();
|
||
assert!(!ed.draft.steps.is_empty());
|
||
assert!(!ed.dirty);
|
||
}
|
||
|
||
#[test]
|
||
fn run_from_the_editor_asks_before_writing_over_data() {
|
||
let mut h = harness();
|
||
h.state_mut().run("cell.set", json!({"cell": "A2", "input": "keep me"})).unwrap();
|
||
let steps = vec![json!({"command": "cell.set", "params": {"cell": "A2", "input": "new"}})];
|
||
h.state_mut().run("macro.save", json!({"macro": {"name": "Over", "steps": steps}})).unwrap();
|
||
h.state_mut().open_dialog("macroEditor", json!({"macro": "Over", "check": true}));
|
||
h.run_steps(3);
|
||
// Check marks the cell it would write over.
|
||
let o = h.state().grid.macro_overlay.clone().unwrap();
|
||
assert_eq!(o.conflicts.iter().map(|(_, r)| r.a1()).collect::<Vec<_>>(), ["A2"]);
|
||
click(&mut h, "Run");
|
||
assert!(h.state().macros.question.as_ref().is_some_and(|q| q.choices.len() == 3));
|
||
click(&mut h, "Overwrite");
|
||
assert_eq!(value(&h, "A2"), Value::Text("new".into()));
|
||
}
|
||
|
||
#[test]
|
||
fn a_new_macro_never_replaces_one_with_its_name() {
|
||
let mut h = harness();
|
||
three(&mut h);
|
||
h.state_mut().macros.editor = None;
|
||
h.state_mut().dialog = None;
|
||
h.run_steps(2);
|
||
click(&mut h, "New Macro");
|
||
for button in ["Save", "Check", "Run"] {
|
||
{
|
||
let ed = h.state_mut().macros.editor.as_mut().unwrap();
|
||
ed.draft.name = "three".into();
|
||
ed.draft.steps = vec![Step::command("cell.set", json!({"cell": "B1", "input": "x"}))];
|
||
ed.dirty = true;
|
||
}
|
||
h.run_steps(2);
|
||
click(&mut h, button);
|
||
let wb = &h.state().session.active().unwrap().wb;
|
||
assert_eq!(wb.macros.len(), 1, "{button}");
|
||
assert_eq!(wb.macros[0].name, "Three", "{button}");
|
||
assert_eq!(wb.macros[0].steps.len(), 3, "{button}: the saved macro is intact");
|
||
assert!(value(&h, "B1").is_empty(), "{button}: nothing ran");
|
||
let msg = h.state_mut().message.take().map(|(_, m)| m).unwrap_or_default();
|
||
assert!(msg.contains("A macro called “three” already exists. Choose another name."), "{button}: {msg}");
|
||
assert!(h.state().macros.editor.as_ref().unwrap().saved_as.is_none());
|
||
h.run_steps(2);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_rename_that_lands_before_a_failed_save_is_remembered() {
|
||
let mut h = harness();
|
||
three(&mut h);
|
||
let big = "x".repeat(5 * 1024 * 1024);
|
||
{
|
||
let ed = h.state_mut().macros.editor.as_mut().unwrap();
|
||
ed.draft.name = "Renamed".into();
|
||
// Too large to keep in the workbook: the rename lands, the save after it fails.
|
||
ed.draft.steps.push(Step::command("cell.set", json!({"cell": "A4", "input": big})));
|
||
ed.dirty = true;
|
||
}
|
||
h.run_steps(2);
|
||
click(&mut h, "Save");
|
||
let msg = h.state_mut().message.take().map(|(_, m)| m).unwrap_or_default();
|
||
assert!(msg.contains("too large"), "{msg}");
|
||
assert_eq!(h.state().macros.editor.as_ref().unwrap().saved_as.as_deref(), Some("Renamed"));
|
||
// Trimmed back, the next Save works.
|
||
h.state_mut().macros.editor.as_mut().unwrap().draft.steps.pop();
|
||
h.run_steps(2);
|
||
click(&mut h, "Save");
|
||
assert!(h.state_mut().message.take().is_none());
|
||
let wb = &h.state().session.active().unwrap().wb;
|
||
assert_eq!(wb.macros.len(), 1);
|
||
assert_eq!((wb.macros[0].name.as_str(), wb.macros[0].steps.len()), ("Renamed", 3));
|
||
}
|
||
|
||
#[test]
|
||
fn another_dialog_never_replaces_the_open_editor() {
|
||
let mut h = harness();
|
||
three(&mut h);
|
||
h.state_mut().macros.editor.as_mut().unwrap().draft.name = "Unsaved".into();
|
||
h.run_steps(2);
|
||
for name in ["formatCells", "macros", "macroEditor"] {
|
||
h.state_mut().open_dialog(name, json!({}));
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("macroEditor"), "{name}");
|
||
assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.name.as_str()), Some("Unsaved"), "{name}");
|
||
}
|
||
click(&mut h, "New Macro");
|
||
assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.name.as_str()), Some("Unsaved"));
|
||
}
|
||
|
||
#[test]
|
||
fn enter_in_change_never_picks_a_control_step() {
|
||
let mut h = harness();
|
||
three(&mut h);
|
||
let enter = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
|
||
for search in ["", "stop"] {
|
||
h.state_mut().macros.editor.as_mut().unwrap().selected = Some("1".into());
|
||
h.run_steps(2);
|
||
click(&mut h, "Change…");
|
||
if !search.is_empty() {
|
||
h.input_mut().events.push(Event::Text(search.into()));
|
||
h.run_steps(2);
|
||
}
|
||
h.input_mut().events.push(enter.clone());
|
||
h.run_steps(3);
|
||
let ed = h.state().macros.editor.as_ref().unwrap();
|
||
assert!(matches!(&ed.draft.steps[0].kind, StepKind::Command { .. }), "{search:?}: {:?}", ed.draft.steps[0].kind);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_editor_works_only_on_the_workbook_it_opened_in() {
|
||
let mut h = harness();
|
||
// A new macro whose one step writes A1, not saved yet.
|
||
h.state_mut().open_dialog("macroEditor", json!({"new": "Fresh"}));
|
||
h.run_steps(3);
|
||
let step = Step::command("cell.set", json!({"cell": "A1", "input": "ran"}));
|
||
h.state_mut().macros.editor.as_mut().unwrap().draft.steps = vec![step];
|
||
let book1 = h.state().session.active().unwrap().uid;
|
||
// Another workbook becomes active while the editor is open (File › New, Switch Window, an agent).
|
||
h.state_mut().session.new_workbook();
|
||
h.run_steps(3);
|
||
let mut ed = h.state_mut().macros.editor.take().unwrap();
|
||
let app = h.state_mut();
|
||
// Save, Check, Run and Debug never act on the other workbook.
|
||
assert!(!gridcraft_ui_egui::macro_editor::save(app, &mut ed));
|
||
gridcraft_ui_egui::macro_editor::check(app, &mut ed);
|
||
gridcraft_ui_egui::macro_editor::run(app, &mut ed);
|
||
gridcraft_ui_egui::macro_editor::debug_action(app, &mut ed, "step");
|
||
assert!(app.session.macro_debug.is_none());
|
||
assert!(app.session.active().unwrap().wb.macros.is_empty(), "nothing was saved into the other workbook");
|
||
assert_eq!(value(&h, "A1"), Value::Empty, "nothing ran there");
|
||
assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("Book1")), "{:?}", h.state().message);
|
||
h.state_mut().message = None;
|
||
h.state_mut().macros.editor = Some(ed);
|
||
h.run_steps(3);
|
||
// The editor stays with its own workbook's window: it is not drawn over the other one.
|
||
assert!(h.state().dialog.as_ref().is_none_or(|d| d.name() != "macroEditor"), "the editor is not shown here");
|
||
// Back in its own workbook, the editor shows again and saves there.
|
||
let i = h.state().session.documents().iter().position(|d| d.uid == book1).unwrap();
|
||
h.state_mut().session.set_active(i);
|
||
h.run_steps(3);
|
||
assert!(h.state().dialog.as_ref().is_some_and(|d| d.name() == "macroEditor"), "the editor is back with its workbook");
|
||
let mut ed = h.state_mut().macros.editor.take().unwrap();
|
||
let app = h.state_mut();
|
||
assert!(gridcraft_ui_egui::macro_editor::save(app, &mut ed));
|
||
assert_eq!(app.session.active().unwrap().wb.macros.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(), ["Fresh"]);
|
||
app.macros.editor = Some(ed);
|
||
h.run_steps(3);
|
||
// Closing the workbook (without saving it) closes its editor.
|
||
h.state_mut().run("file.close", json!({"force": true})).unwrap();
|
||
h.run_steps(3);
|
||
assert!(h.state().macros.editor.is_none());
|
||
assert!(h.state().dialog.as_ref().is_none_or(|d| d.name() != "macroEditor"));
|
||
}
|
||
|
||
#[test]
|
||
fn a_deferred_close_acts_on_the_editors_window_whichever_window_is_active_meanwhile() {
|
||
let mut h = harness();
|
||
edited_new_macro(&mut h);
|
||
let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone();
|
||
let book1 = h.state().session.active().unwrap().uid;
|
||
let home = h.state().session.active_window().unwrap();
|
||
// Another workbook in a window of its own.
|
||
h.state_mut().run("file.new", json!({})).unwrap();
|
||
h.run_steps(2);
|
||
let book2 = h.state().session.active().unwrap().uid;
|
||
let other = h.state().session.active_window().unwrap();
|
||
assert_ne!(other, home);
|
||
// Back in the editor's window, File › Close asks about the draft and waits.
|
||
h.state_mut().enter_window(home);
|
||
h.run_steps(2);
|
||
let r = h.state_mut().run("file.close", json!({})).unwrap();
|
||
assert_eq!(r["closed"], false);
|
||
h.run_steps(3);
|
||
assert!(asks_about(&h, &name));
|
||
assert_eq!(h.state().macros.editor.as_ref().and_then(|e| e.then_close.as_ref()).map(|c| c.window), Some(home));
|
||
// The other window is the session's active one when the question is answered (a native
|
||
// window the person clicked on while the question stood), while the editor's window draws
|
||
// the question: the answer is given through the editor's own frame, as its button gives it.
|
||
let ctx = h.ctx.clone();
|
||
let app = h.state_mut();
|
||
let mut d = app.dialog.take().expect("the editor is its window's dialog");
|
||
assert_eq!(d.name(), "macroEditor");
|
||
app.session.activate_window(other);
|
||
assert_eq!(app.session.active().map(|d| d.uid), Some(book2));
|
||
// Don't Save: the question's answer closes the editor without saving.
|
||
let ed = app.macros.editor.as_mut().unwrap();
|
||
ed.ask_close = false;
|
||
ed.dirty = false;
|
||
ed.close_requested = true;
|
||
let mut open = true;
|
||
let mut out = ctx.run_pass(egui::RawInput::default(), |ctx| gridcraft_ui_egui::macro_editor::window(app, ctx, &mut d, &mut open));
|
||
out.textures_delta.clear(); // not painted
|
||
assert!(!open, "the editor closed");
|
||
// The close that was asked in the editor's window closes that workbook, not the other.
|
||
assert!(!is_open(&h, book1), "the editor's workbook closed");
|
||
assert!(is_open(&h, book2), "the workbook of the window activated meanwhile stays open");
|
||
assert!(h.state().session.window(other).is_some());
|
||
assert!(h.state().macros.editor.is_none());
|
||
assert!(h.state().message.is_none(), "{:?}", h.state().message);
|
||
h.run_steps(2);
|
||
assert!(h.state().dialog.is_none());
|
||
}
|
||
|
||
/// Asks the window to close (the close button, Alt+F4, quitting) and returns the commands the
|
||
/// app answered with over the next frames.
|
||
fn request_window_close(h: &mut Harness) -> Vec<egui::ViewportCommand> {
|
||
h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
|
||
step_commands(h)
|
||
}
|
||
|
||
fn step_commands(h: &mut Harness) -> Vec<egui::ViewportCommand> {
|
||
let mut out = Vec::new();
|
||
for _ in 0..4 {
|
||
h.step();
|
||
out.extend(h.output().viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default());
|
||
}
|
||
out
|
||
}
|
||
|
||
#[test]
|
||
fn quitting_asks_about_the_editors_unsaved_steps_first() {
|
||
let mut h = harness();
|
||
three(&mut h);
|
||
h.state_mut().macros.editor.as_mut().unwrap().draft.name = "Unsaved".into();
|
||
h.state_mut().macros.editor.as_mut().unwrap().dirty = true;
|
||
// As if the workbook had just been saved.
|
||
let d = h.state_mut().session.active_mut().unwrap();
|
||
d.saved = d.wb.clone();
|
||
h.run_steps(2);
|
||
// Every workbook is saved, but the draft is not: the window stays open and the editor asks.
|
||
let sent = request_window_close(&mut h);
|
||
assert!(sent.contains(&egui::ViewportCommand::CancelClose), "{sent:?}");
|
||
assert!(!sent.contains(&egui::ViewportCommand::Close), "{sent:?}");
|
||
assert!(h.query_by_label_contains("Save changes to macro “Unsaved”").is_some());
|
||
// Cancel keeps the app and the draft.
|
||
// The formula bar's Cancel button has the same name; the question's is the button drawn last.
|
||
h.get_all_by_role_and_label(egui::Role::Button, "Cancel").last().unwrap().click();
|
||
let sent = step_commands(&mut h);
|
||
assert!(!sent.contains(&egui::ViewportCommand::Close), "{sent:?}");
|
||
assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.name.as_str()), Some("Unsaved"));
|
||
// Asked again, Don't Save closes the editor and then the window.
|
||
request_window_close(&mut h);
|
||
h.get_by_label("Don't Save").click();
|
||
let sent = step_commands(&mut h);
|
||
assert!(h.state().macros.editor.is_none());
|
||
assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn quitting_with_the_editor_open_still_asks_about_unsaved_workbooks() {
|
||
let mut h = harness();
|
||
three(&mut h);
|
||
h.state_mut().run("cell.set", json!({"cell": "Z9", "input": "unsaved"})).unwrap();
|
||
h.run_steps(2);
|
||
let sent = request_window_close(&mut h);
|
||
assert!(sent.contains(&egui::ViewportCommand::CancelClose), "{sent:?}");
|
||
// The saved, unchanged editor closes and the window close goes on (the window then asks to
|
||
// close again, which a test harness has to do by hand); the workbook is asked about as
|
||
// without the editor.
|
||
assert!(h.state().macros.editor.is_none());
|
||
assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}");
|
||
request_window_close(&mut h);
|
||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges"));
|
||
}
|
||
|
||
#[test]
|
||
fn a_dialog_asked_for_over_the_editor_says_why_it_did_not_open() {
|
||
let mut h = harness();
|
||
three(&mut h);
|
||
// File › Close on a changed workbook asks about saving it, which can't replace the editor.
|
||
h.state_mut().run("cell.set", json!({"cell": "Z9", "input": "unsaved"})).unwrap();
|
||
h.state_mut().run("file.close", json!({})).unwrap();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor"));
|
||
assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("Close the macro step editor")), "{:?}", h.state().message);
|
||
}
|
||
|
||
#[test]
|
||
fn the_control_channels_cancel_closes_the_editor_as_its_own_close_does() {
|
||
let mut h = harness();
|
||
three(&mut h);
|
||
let ctx = h.ctx.clone();
|
||
let reply = |o: gridcraft_ui_egui::control::Outcome| match o {
|
||
gridcraft_ui_egui::control::Outcome::Done(v) => v,
|
||
_ => json!(null),
|
||
};
|
||
// Unsaved steps are only the editor's to ask about: cancel is refused and keeps them.
|
||
h.state_mut().macros.editor.as_mut().unwrap().dirty = true;
|
||
let r = reply(gridcraft_ui_egui::control::handle(h.state_mut(), &ctx, "ui.dialog.cancel", &json!({})));
|
||
assert!(r.get("error").is_some(), "{r}");
|
||
assert!(h.state().macros.editor.is_some());
|
||
// A saved editor closes, and takes its overlays with it.
|
||
h.state_mut().macros.editor.as_mut().unwrap().dirty = false;
|
||
h.get_by_label("Check").click();
|
||
h.run_steps(3);
|
||
assert!(h.state().grid.macro_overlay.is_some());
|
||
let r = reply(gridcraft_ui_egui::control::handle(h.state_mut(), &ctx, "ui.dialog.cancel", &json!({})));
|
||
assert!(r.get("error").is_none(), "{r}");
|
||
h.run_steps(2);
|
||
assert!(h.state().dialog.is_none());
|
||
assert!(h.state().macros.editor.is_none());
|
||
assert!(h.state().grid.macro_overlay.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn the_editor_fits_a_narrow_window() {
|
||
for size in [egui::vec2(600.0, 480.0), egui::vec2(480.0, 400.0)] {
|
||
let mut session = Session::new();
|
||
session.new_workbook();
|
||
let app = SheetApp::new(session, Default::default());
|
||
let mut h = egui_kittest::Harness::builder().with_size(size).build_ui_state(
|
||
|ui, app: &mut SheetApp| {
|
||
let ctx = ui.ctx().clone();
|
||
app.logic(&ctx);
|
||
app.ui(ui);
|
||
},
|
||
app,
|
||
);
|
||
h.run_steps(4);
|
||
three(&mut h);
|
||
h.run_steps(4);
|
||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
|
||
let window = h.get_by_label_contains("Macro: Three").rect();
|
||
assert!(screen.contains_rect(window), "the editor at {window:?} is outside the {size:?} window");
|
||
// The editor's own controls (Delete is left out: the ribbon has one too).
|
||
for label in ["Add Step", "Duplicate", "Check", "Run", "Step", "Continue", "Run to Here", "Stop", "Details", "Inputs", "Output", "Save"] {
|
||
let r = h.get_by_label(label).rect();
|
||
assert!(screen.contains_rect(r), "{label} at {r:?} is outside the {size:?} window");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_macro_at_the_step_limit_opens_without_freezing_the_editor() {
|
||
let mut h = harness();
|
||
let steps: Vec<_> = (0..gridcraft_engine::model::MAX_MACRO_STEPS)
|
||
.map(|i| json!({"command": "cell.set", "params": {"cell": format!("A{}", i + 1), "input": "x"}}))
|
||
.collect();
|
||
h.state_mut().run("macro.save", json!({"macro": {"name": "Big", "steps": steps}})).unwrap();
|
||
h.state_mut().open_dialog("macroEditor", json!({"macro": "Big"}));
|
||
h.run_steps(2);
|
||
// Each row used to copy the whole list: five frames took about a minute. Now well under a
|
||
// second; the bound leaves room for a slow machine.
|
||
let t = gridcraft_ui_egui::now_ms();
|
||
h.run_steps(5);
|
||
let ms = gridcraft_ui_egui::now_ms() - t;
|
||
assert!(ms < 10_000.0, "five frames took {ms} ms");
|
||
assert!(h.query_by_label_contains("Step 1: ").is_some());
|
||
}
|
||
|
||
/// Opens the editor on a new macro and gives its draft a step, as editing it does.
|
||
fn edited_new_macro(h: &mut Harness) {
|
||
h.state_mut().open_dialog("macroEditor", json!({}));
|
||
h.run_steps(3);
|
||
let ed = h.state_mut().macros.editor.as_mut().unwrap();
|
||
ed.draft.steps.push(Step::command("cell.set", json!({"cell": "A1", "input": "7"})));
|
||
ed.dirty = true;
|
||
h.run_steps(2);
|
||
}
|
||
|
||
/// Clicks a button of the editor's Save changes question: the question's buttons are drawn last
|
||
/// (the formula bar has a Cancel and the editor a Save of their own).
|
||
fn answer(h: &mut Harness, label: &str) {
|
||
h.get_all_by_role_and_label(egui::Role::Button, label).last().unwrap().click();
|
||
h.run_steps(4);
|
||
}
|
||
|
||
/// Whether the workbook `uid` is still open (the app starts a blank one when the last closes).
|
||
fn is_open(h: &Harness, uid: u64) -> bool {
|
||
h.state().session.documents().iter().any(|d| d.uid == uid)
|
||
}
|
||
|
||
fn asks_about(h: &Harness, name: &str) -> bool {
|
||
h.query_by_label_contains(&format!("Save changes to macro “{name}”")).is_some()
|
||
}
|
||
|
||
#[test]
|
||
fn closing_the_workbook_asks_about_an_unsaved_draft_first() {
|
||
let mut h = harness();
|
||
edited_new_macro(&mut h);
|
||
let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone();
|
||
let book = h.state().session.active().unwrap().uid;
|
||
assert!(!h.state().session.active().unwrap().is_dirty());
|
||
// The workbook is saved, but the draft is not: File › Close asks about the draft and waits.
|
||
let r = h.state_mut().run("file.close", json!({})).unwrap();
|
||
assert_eq!(r["closed"], false);
|
||
h.run_steps(3);
|
||
assert!(asks_about(&h, &name));
|
||
assert!(is_open(&h, book));
|
||
// Cancel keeps the workbook and the draft.
|
||
answer(&mut h, "Cancel");
|
||
assert!(is_open(&h, book));
|
||
assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.steps.len()), Some(1));
|
||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor"));
|
||
// A window's close button (`window.close`) asks the same way.
|
||
let window = h.state().session.active_window().unwrap();
|
||
let r = h.state_mut().run("window.close", json!({"window": window})).unwrap();
|
||
assert_eq!(r["closed"], false);
|
||
h.run_steps(3);
|
||
assert!(asks_about(&h, &name));
|
||
answer(&mut h, "Cancel");
|
||
assert!(is_open(&h, book));
|
||
// Don't Save closes the editor, then the workbook (which has nothing of its own to ask about).
|
||
h.state_mut().run("file.close", json!({})).unwrap();
|
||
h.run_steps(3);
|
||
answer(&mut h, "Don't Save");
|
||
assert!(h.state().macros.editor.is_none());
|
||
assert!(!is_open(&h, book));
|
||
assert!(h.state().dialog.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn save_in_the_question_saves_the_macro_then_the_workbook_asks_about_itself() {
|
||
let path = std::env::temp_dir().join(format!("gridcraft-macro-draft-{}.xlsx", std::process::id()));
|
||
let _ = std::fs::remove_file(&path);
|
||
let target = path.to_string_lossy().to_string();
|
||
let mut h = harness_with(Services { pick_save: Some(Box::new(move |_| Some(target.clone()))), ..Default::default() });
|
||
edited_new_macro(&mut h);
|
||
let book = h.state().session.active().unwrap().uid;
|
||
h.state_mut().run("file.close", json!({})).unwrap();
|
||
h.run_steps(3);
|
||
answer(&mut h, "Save");
|
||
// The macro is in the workbook now, which makes it unsaved: closing it goes on and asks.
|
||
assert!(h.state().macros.editor.is_none());
|
||
assert_eq!(h.state().session.active().unwrap().wb.macros.len(), 1);
|
||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges"));
|
||
h.get_all_by_role_and_label(egui::Role::Button, "Save").last().unwrap().click();
|
||
h.run_steps(4);
|
||
assert!(!is_open(&h, book));
|
||
let mut s = Session::new();
|
||
s.execute("file.open", json!({"path": path.to_str().unwrap()})).unwrap();
|
||
let wb = &s.active().unwrap().wb;
|
||
assert_eq!(wb.macros.len(), 1);
|
||
assert_eq!(wb.macros[0].steps.len(), 1);
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
|
||
#[test]
|
||
fn a_draft_that_cannot_be_saved_keeps_its_workbook_open() {
|
||
let mut h = harness();
|
||
h.state_mut().run("macro.save", json!({"macro": {"name": "Taken", "steps": []}})).unwrap();
|
||
let d = h.state_mut().session.active_mut().unwrap();
|
||
d.saved = d.wb.clone();
|
||
edited_new_macro(&mut h);
|
||
let book = h.state().session.active().unwrap().uid;
|
||
// A new macro never replaces one with its name, so Save is refused.
|
||
h.state_mut().macros.editor.as_mut().unwrap().draft.name = "Taken".into();
|
||
h.state_mut().run("file.close", json!({})).unwrap();
|
||
h.run_steps(3);
|
||
answer(&mut h, "Save");
|
||
assert!(h.state().message.is_some());
|
||
h.state_mut().message = None;
|
||
h.run_steps(2);
|
||
assert!(h.state().macros.editor.is_some());
|
||
// Closing the editor itself later never closes the workbook the earlier question was about.
|
||
h.state_mut().macros.editor.as_mut().unwrap().close_requested = true;
|
||
h.run_steps(3);
|
||
answer(&mut h, "Don't Save");
|
||
assert!(h.state().macros.editor.is_none());
|
||
assert!(is_open(&h, book));
|
||
}
|
||
|
||
#[test]
|
||
fn an_untouched_new_macro_does_not_make_closing_ask() {
|
||
let mut h = harness();
|
||
h.state_mut().open_dialog("macroEditor", json!({}));
|
||
h.run_steps(3);
|
||
assert!(h.state().macros.editor.as_ref().unwrap().dirty);
|
||
let book = h.state().session.active().unwrap().uid;
|
||
let r = h.state_mut().run("file.close", json!({})).unwrap();
|
||
assert_eq!(r["closed"], true);
|
||
assert!(!is_open(&h, book));
|
||
h.run_steps(3);
|
||
assert!(h.state().macros.editor.is_none());
|
||
assert!(h.state().dialog.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn a_new_macros_name_change_alone_makes_closing_ask() {
|
||
let mut h = harness();
|
||
h.state_mut().open_dialog("macroEditor", json!({}));
|
||
h.run_steps(3);
|
||
let book = h.state().session.active().unwrap().uid;
|
||
// Only the name changes, as typing in the Name box does.
|
||
let ed = h.state_mut().macros.editor.as_mut().unwrap();
|
||
ed.draft.name = "Renamed".into();
|
||
ed.dirty = true;
|
||
h.run_steps(2);
|
||
let r = h.state_mut().run("file.close", json!({})).unwrap();
|
||
assert_eq!(r["closed"], false);
|
||
h.run_steps(3);
|
||
assert!(asks_about(&h, "Renamed"));
|
||
assert!(is_open(&h, book));
|
||
}
|
||
|
||
/// Quit (Cmd+Q, the macOS Quit menu, a Dock quit) and the commands the app answered with over the
|
||
/// next frames.
|
||
fn request_quit(h: &mut Harness) -> Vec<egui::ViewportCommand> {
|
||
let ctx = h.ctx.clone();
|
||
h.state_mut().request_quit(&ctx);
|
||
step_commands(h)
|
||
}
|
||
|
||
#[test]
|
||
fn quit_asks_about_the_editors_unsaved_steps_first() {
|
||
let mut h = harness();
|
||
edited_new_macro(&mut h);
|
||
let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone();
|
||
assert!(!h.state().session.active().unwrap().is_dirty());
|
||
// Every workbook is saved, but the draft is not: the app stays and the editor asks.
|
||
let sent = request_quit(&mut h);
|
||
assert!(!sent.contains(&egui::ViewportCommand::Close), "{sent:?}");
|
||
assert!(!h.state().allow_close);
|
||
assert!(asks_about(&h, &name));
|
||
// Cancel keeps the app and the draft.
|
||
answer(&mut h, "Cancel");
|
||
let sent = step_commands(&mut h);
|
||
assert!(!sent.contains(&egui::ViewportCommand::Close), "{sent:?}");
|
||
assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.steps.len()), Some(1));
|
||
// Asked again, Don't Save closes the editor and the quit goes on.
|
||
request_quit(&mut h);
|
||
assert!(asks_about(&h, &name));
|
||
h.get_by_label("Don't Save").click();
|
||
let sent = step_commands(&mut h);
|
||
assert!(h.state().macros.editor.is_none());
|
||
assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_refused_save_while_quitting_stops_the_quit() {
|
||
let mut h = harness();
|
||
h.state_mut().run("macro.save", json!({"macro": {"name": "Taken", "steps": []}})).unwrap();
|
||
let d = h.state_mut().session.active_mut().unwrap();
|
||
d.saved = d.wb.clone();
|
||
edited_new_macro(&mut h);
|
||
// A new macro never replaces one with its name, so the question's Save is refused.
|
||
h.state_mut().macros.editor.as_mut().unwrap().draft.name = "Taken".into();
|
||
for quit in [request_quit, request_window_close] {
|
||
h.run_steps(2);
|
||
let sent = quit(&mut h);
|
||
assert!(sent.contains(&egui::ViewportCommand::CancelClose) || !h.state().allow_close, "{sent:?}");
|
||
assert!(asks_about(&h, "Taken"));
|
||
answer(&mut h, "Save");
|
||
assert!(h.state().message.is_some());
|
||
h.state_mut().message = None;
|
||
assert!(h.state().macros.editor.is_some());
|
||
// Closing the editor later, on its own, no longer quits the app.
|
||
h.state_mut().macros.editor.as_mut().unwrap().close_requested = true;
|
||
h.run_steps(3);
|
||
h.get_by_label("Don't Save").click();
|
||
let sent = step_commands(&mut h);
|
||
assert!(h.state().macros.editor.is_none());
|
||
assert!(!sent.contains(&egui::ViewportCommand::Close), "the app keeps running: {sent:?}");
|
||
assert!(!h.state().allow_close);
|
||
// The next round starts with the same draft in the editor again.
|
||
edited_new_macro(&mut h);
|
||
h.state_mut().macros.editor.as_mut().unwrap().draft.name = "Taken".into();
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn an_untouched_new_macro_closes_without_asking_however_the_editor_closes() {
|
||
let mut h = harness();
|
||
let ctx = h.ctx.clone();
|
||
let open_new = |h: &mut Harness| {
|
||
h.state_mut().open_dialog("macroEditor", json!({}));
|
||
h.run_steps(3);
|
||
assert!(h.state().macros.editor.as_ref().is_some_and(|e| e.dirty && e.saved_as.is_none()));
|
||
};
|
||
// Its own close button.
|
||
open_new(&mut h);
|
||
h.state_mut().macros.editor.as_mut().unwrap().close_requested = true;
|
||
h.run_steps(3);
|
||
assert!(h.state().macros.editor.is_none(), "no Save changes question for a macro nobody edited");
|
||
// The control channel's cancel.
|
||
open_new(&mut h);
|
||
let r = gridcraft_ui_egui::control::handle(h.state_mut(), &ctx, "ui.dialog.cancel", &json!({}));
|
||
assert!(matches!(&r, gridcraft_ui_egui::control::Outcome::Done(v) if v.get("error").is_none()));
|
||
h.run_steps(2);
|
||
assert!(h.state().macros.editor.is_none());
|
||
// Quit, with every workbook saved: the editor closes and the quit goes on.
|
||
open_new(&mut h);
|
||
let sent = request_quit(&mut h);
|
||
assert!(h.state().macros.editor.is_none());
|
||
assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn closing_the_window_that_shows_the_editor_asks_about_its_draft() {
|
||
let mut h = harness();
|
||
edited_new_macro(&mut h);
|
||
let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone();
|
||
let home = h.state().session.active_window().unwrap();
|
||
h.state_mut().run("view.newWindow", json!({})).unwrap();
|
||
h.run_steps(3);
|
||
let other = h.state().session.active_window().unwrap();
|
||
assert_ne!(other, home);
|
||
// The workbook stays open in its other window, but the editor's window would take the draft.
|
||
let r = h.state_mut().run("window.close", json!({"window": home})).unwrap();
|
||
assert_eq!(r["closed"], false);
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().session.active_window(), Some(home), "the editor's window comes forward to ask");
|
||
assert!(asks_about(&h, &name));
|
||
answer(&mut h, "Cancel");
|
||
assert!(h.state().session.window(home).is_some());
|
||
assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.steps.len()), Some(1));
|
||
// File › Close in the editor's window asks too; Don't Save closes that window and the editor.
|
||
let r = h.state_mut().run("file.close", json!({})).unwrap();
|
||
assert_eq!(r["closed"], false);
|
||
h.run_steps(3);
|
||
assert!(asks_about(&h, &name));
|
||
answer(&mut h, "Don't Save");
|
||
assert!(h.state().macros.editor.is_none());
|
||
assert!(h.state().session.window(home).is_none());
|
||
assert!(h.state().session.window(other).is_some(), "the workbook stays open in its other window");
|
||
}
|
||
|
||
#[test]
|
||
fn closing_the_window_that_shows_a_saved_editor_closes_the_editor_with_it() {
|
||
let mut h = harness();
|
||
three(&mut h);
|
||
let home = h.state().session.active_window().unwrap();
|
||
h.state_mut().run("view.newWindow", json!({})).unwrap();
|
||
h.run_steps(3);
|
||
let r = h.state_mut().run("window.close", json!({"window": home})).unwrap();
|
||
assert_eq!(r["closed"], true);
|
||
h.run_steps(3);
|
||
// Nothing is left of an editor no window shows: a new one opens, and nothing was lost.
|
||
assert!(h.state().macros.editor.is_none());
|
||
h.state_mut().open_dialog("macroEditor", json!({"macro": "Three"}));
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor"));
|
||
}
|
||
|
||
#[test]
|
||
fn a_close_request_during_the_editors_save_dialog_waits_for_it() {
|
||
let mut h = harness();
|
||
edited_new_macro(&mut h);
|
||
let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone();
|
||
let book1 = h.state().session.active().unwrap().uid;
|
||
let home = h.state().session.active_window().unwrap();
|
||
h.state_mut().run("file.new", json!({})).unwrap();
|
||
h.run_steps(2);
|
||
let book2 = h.state().session.active().unwrap().uid;
|
||
h.state_mut().enter_window(home);
|
||
h.run_steps(2);
|
||
// Cmd+W: the editor asks about the draft and the close waits.
|
||
let r = h.state_mut().run("file.close", json!({})).unwrap();
|
||
assert_eq!(r["closed"], false);
|
||
h.run_steps(3);
|
||
assert!(asks_about(&h, &name));
|
||
// Cmd+W again, then the window's close button, while the question stands: the same close,
|
||
// so both wait on the same question; it is not asked twice and nothing closes under it.
|
||
let r = h.state_mut().run("file.close", json!({})).unwrap();
|
||
assert_eq!(r["closed"], false);
|
||
let r = h.state_mut().run("window.close", json!({"window": home})).unwrap();
|
||
assert_eq!(r["closed"], false);
|
||
h.run_steps(3);
|
||
assert_eq!(h.get_all_by_label_contains("Save changes to macro").count(), 1, "one question");
|
||
assert!(is_open(&h, book1) && is_open(&h, book2));
|
||
assert!(h.state().message.is_none(), "{:?}", h.state().message);
|
||
let pending = h.state().macros.editor.as_ref().and_then(|e| e.then_close.as_ref()).expect("the close waits");
|
||
assert_eq!((pending.id.as_str(), pending.whole), ("file.close", true), "the close that opened the question is the pending one");
|
||
// Don't Save: the editor closes and the workbook closes once; the other workbook stays.
|
||
answer(&mut h, "Don't Save");
|
||
assert!(h.state().macros.editor.is_none());
|
||
assert!(!is_open(&h, book1));
|
||
assert!(is_open(&h, book2), "the close ran once");
|
||
assert!(h.state().dialog.is_none());
|
||
assert!(h.state().message.is_none(), "{:?}", h.state().message);
|
||
}
|
||
|
||
#[test]
|
||
fn a_different_close_during_the_editors_question_is_refused_until_it_is_answered() {
|
||
let mut h = harness();
|
||
edited_new_macro(&mut h);
|
||
let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone();
|
||
let book = h.state().session.active().unwrap().uid;
|
||
let home = h.state().session.active_window().unwrap();
|
||
h.state_mut().run("view.newWindow", json!({})).unwrap();
|
||
h.run_steps(3);
|
||
let other = h.state().session.active_window().unwrap();
|
||
// The editor's window closes while the workbook stays open in the other: the editor asks.
|
||
let r = h.state_mut().run("window.close", json!({"window": home})).unwrap();
|
||
assert_eq!(r["closed"], false);
|
||
h.run_steps(3);
|
||
assert!(asks_about(&h, &name));
|
||
// The Backstage Close (the whole workbook) while that question stands asks for more than
|
||
// the question was asked for: refused with the reason; the question and the workbook stay.
|
||
let e = h.state_mut().run("file.close", json!({"document": true})).unwrap_err();
|
||
assert!(e.contains("asking whether to save") && e.contains("its window closes"), "{e}");
|
||
h.run_steps(2);
|
||
assert_eq!(h.get_all_by_label_contains("Save changes to macro").count(), 1, "one question");
|
||
assert!(is_open(&h, book));
|
||
assert_eq!(h.state().session.windows().len(), 2);
|
||
// Don't Save closes the editor's window alone, as the question was asked for.
|
||
answer(&mut h, "Don't Save");
|
||
assert!(h.state().macros.editor.is_none());
|
||
assert!(h.state().session.window(home).is_none());
|
||
assert!(h.state().session.window(other).is_some());
|
||
assert!(is_open(&h, book), "the workbook stays open in its other window");
|
||
// The reverse: the workbook is closing when a close of the editor's window alone arrives.
|
||
h.state_mut().run("view.newWindow", json!({})).unwrap();
|
||
h.run_steps(3);
|
||
h.state_mut().enter_window(other);
|
||
edited_new_macro(&mut h);
|
||
let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone();
|
||
let r = h.state_mut().run("file.close", json!({"document": true})).unwrap();
|
||
assert_eq!(r["closed"], false);
|
||
h.run_steps(3);
|
||
assert!(asks_about(&h, &name));
|
||
let e = h.state_mut().run("window.close", json!({"window": other})).unwrap_err();
|
||
assert!(e.contains("the workbook closes"), "{e}");
|
||
// Cancel keeps the workbook, both windows and the draft.
|
||
answer(&mut h, "Cancel");
|
||
assert!(is_open(&h, book));
|
||
assert_eq!(h.state().session.windows().len(), 2);
|
||
assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.steps.len()), Some(1));
|
||
assert!(h.state().macros.editor.as_ref().is_some_and(|e| e.then_close.is_none()));
|
||
}
|
||
|
||
#[test]
|
||
fn quitting_with_a_draft_in_one_window_and_unsaved_cells_in_another_asks_in_order_and_cancel_stops_it() {
|
||
let mut h = harness();
|
||
// Book1, saved, with an unsaved draft in its window; Book2 with unsaved cells in its own.
|
||
edited_new_macro(&mut h);
|
||
let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone();
|
||
let book1 = h.state().session.active().unwrap().uid;
|
||
let a = h.state().session.active_window().unwrap();
|
||
assert!(!h.state().session.active().unwrap().is_dirty());
|
||
h.state_mut().run("file.new", json!({})).unwrap();
|
||
h.state_mut().run("cell.set", json!({"cell": "Z9", "input": "unsaved"})).unwrap();
|
||
h.run_steps(2);
|
||
let book2 = h.state().session.active().unwrap().uid;
|
||
let title2 = h.state().session.active().unwrap().display_title();
|
||
let b = h.state().session.active_window().unwrap();
|
||
assert_ne!(a, b);
|
||
let both_open =
|
||
|h: &Harness| is_open(h, book1) && is_open(h, book2) && h.state().session.window(a).is_some() && h.state().session.window(b).is_some();
|
||
// Quit from Book2's window: the editor's question comes first, in its own window.
|
||
let sent = request_quit(&mut h);
|
||
assert!(!sent.contains(&egui::ViewportCommand::Close) && !h.state().allow_close, "{sent:?}");
|
||
assert_eq!(h.state().session.active_window(), Some(a), "the editor's window comes forward");
|
||
assert!(asks_about(&h, &name));
|
||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor"), "no workbook is asked about yet");
|
||
// Don't Save: the draft goes, the editor closes and the quit goes on (the window asks to
|
||
// close again, which a test harness has to do by hand).
|
||
h.get_all_by_role_and_label(egui::Role::Button, "Don't Save").last().unwrap().click();
|
||
let sent = step_commands(&mut h);
|
||
assert!(h.state().macros.editor.is_none());
|
||
assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}");
|
||
request_window_close(&mut h);
|
||
// Book2 is asked about next, in its window, as the quit's question.
|
||
assert_eq!(h.state().session.active_window(), Some(b), "the unsaved workbook's window comes forward");
|
||
let d = h.state().dialog.as_ref().expect("Book2 is asked about");
|
||
assert_eq!(d.name(), "saveChanges");
|
||
assert_eq!(d.values.get("title").and_then(serde_json::Value::as_str), Some(title2.as_str()));
|
||
assert_eq!(d.values.get("quit"), Some(&json!(true)), "{:?}", d.values);
|
||
assert!(both_open(&h));
|
||
// Cancel: the quit stops with both windows and workbooks open and Book2's changes kept; the
|
||
// draft the person discarded stays discarded.
|
||
h.get_all_by_role_and_label(egui::Role::Button, "Cancel").last().unwrap().click();
|
||
let sent = step_commands(&mut h);
|
||
assert!(!sent.contains(&egui::ViewportCommand::Close) && !h.state().allow_close, "{sent:?}");
|
||
assert!(h.state().dialog.is_none());
|
||
assert!(both_open(&h));
|
||
assert!(h.state().session.documents().iter().find(|d| d.uid == book2).unwrap().is_dirty());
|
||
assert!(h.state().macros.editor.is_none());
|
||
// A quit cancelled at the editor's question stops before any workbook is asked about.
|
||
h.state_mut().enter_window(a);
|
||
edited_new_macro(&mut h);
|
||
let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone();
|
||
let sent = request_quit(&mut h);
|
||
assert!(!sent.contains(&egui::ViewportCommand::Close), "{sent:?}");
|
||
assert!(asks_about(&h, &name));
|
||
answer(&mut h, "Cancel");
|
||
let sent = step_commands(&mut h);
|
||
assert!(!sent.contains(&egui::ViewportCommand::Close) && !h.state().allow_close, "{sent:?}");
|
||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor"), "no Save Changes question followed");
|
||
assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.steps.len()), Some(1), "the draft is kept");
|
||
assert!(both_open(&h));
|
||
}
|