Files
gridcraft/crates/ui-egui/tests/it/file_menu.rs
T

718 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The File tab opens the Backstage view, which exposes the file operations without a native menu.
use std::sync::{Arc, Mutex};
use egui_kittest::kittest::{NodeT as _, Queryable as _};
use gridcraft_engine::{Session, core::CellRef, core::Value};
use gridcraft_ui_egui::i18n::Language;
use gridcraft_ui_egui::{Services, SheetApp};
fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> {
crate::support::services_harness(services, 4)
}
/// Opens File (the Backstage view) and clicks a navigation entry or page button.
fn choose(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) {
if h.state().backstage.is_none() {
h.get_by_label("File").click();
h.run_steps(3);
}
click(h, label);
}
fn click(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) {
h.get_by_role_and_label(egui::accesskit::Role::Button, label).click();
h.run_steps(3);
}
fn page(h: &egui_kittest::Harness<'static, SheetApp>) -> Option<&'static str> {
h.state().backstage.as_ref().map(|b| b.page.id())
}
#[test]
fn new_and_open_from_the_file_tab_keep_the_ribbon_as_it_was() {
let opened = Arc::new(Mutex::new(0));
let capture = opened.clone();
let mut h = harness(Services {
pick_open: Some(Box::new(move || {
*capture.lock().unwrap() += 1;
None
})),
..Default::default()
});
h.state_mut().ui.ribbon_tab = "Data".into();
h.state_mut().ui.ribbon_collapsed = true;
h.run_steps(2);
h.get_by_label("File").click();
h.run_steps(3);
assert_eq!(page(&h), Some("home"), "the File tab opens the Backstage view on Home");
choose(&mut h, "New");
assert_eq!(page(&h), Some("new"));
click(&mut h, "Blank workbook");
assert_eq!(h.state().session.documents().len(), 2);
assert!(h.state().backstage.is_none(), "creating a workbook returns to the sheet");
choose(&mut h, "Open");
click(&mut h, "Browse…");
assert_eq!(*opened.lock().unwrap(), 1, "Browse invokes the platform file picker");
assert_eq!(page(&h), Some("open"), "a cancelled pick stays on File › Open");
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(h.state().backstage.is_none(), "Esc returns to the sheet");
assert_eq!(h.state().ui.ribbon_tab, "Data");
assert!(h.state().ui.ribbon_collapsed);
}
/// Esc first cancels the focused File-page field, then closes File on the next press.
#[test]
fn escape_leaves_file_fields_before_closing_the_view() {
use egui::accesskit::Role;
for (page_id, label) in [
("info", "Title"),
("info", "Subject"),
("info", "Author"),
("info", "Company"),
("info", "Keywords"),
("info", "Description"),
("options", "Maximum Iterations:"),
("options", "Maximum Change:"),
("print", "Copies"),
] {
let services = Services {
printers: Some(Box::new(|| {
let (tx, rx) = std::sync::mpsc::channel();
tx.send(Vec::new()).unwrap();
rx
})),
..Default::default()
};
let mut session = Session::new();
session.new_workbook();
let mut h = crate::common::harness_with(
crate::common::builder().with_size(egui::vec2(1200.0, 1400.0)),
crate::common::prepare(SheetApp::new(session, services)),
4,
);
h.state_mut().run("formulas.calculationOptions", serde_json::json!({"iterative": true})).unwrap();
h.state_mut().run("ui.backstage", serde_json::json!({"page": page_id})).unwrap();
h.run_steps(3);
let role = if page_id == "print" { Role::SpinButton } else { Role::TextInput };
h.get_by_role_and_label(role, label).click();
h.run_steps(2);
assert!(h.ctx.text_edit_focused(), "{label} has the caret");
let original = h.get_by_role_and_label(role, label).value();
let revision = h.state().session.active().unwrap().revision;
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.run_steps(1);
h.input_mut().events.push(egui::Event::Text("7".into()));
h.run_steps(2);
if page_id == "print" {
assert_eq!(h.state().printing.settings.copies, 7, "Copies applies the entered value while editing");
} else {
assert_eq!(h.get_by_role_and_label(Role::TextInput, label).value().as_deref(), Some("7"));
}
let copies = h.state().printing.settings.copies;
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert_eq!(page(&h), Some(page_id), "Esc in {label} keeps File open");
assert!(!h.ctx.text_edit_focused(), "Esc leaves {label}");
assert_eq!(h.state().session.active().unwrap().revision, revision, "Esc does not commit {label}");
if page_id == "print" {
assert_eq!(h.state().printing.settings.copies, copies, "leaving Copies preserves its existing live value");
} else {
assert_eq!(h.get_by_role_and_label(Role::TextInput, label).value(), original, "Esc discards the {label} draft");
}
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert_eq!(page(&h), None, "the next Esc returns to the sheet");
}
}
#[test]
fn save_actions_include_the_cell_draft_and_close_keeps_the_unsaved_prompt() {
let downloads = Arc::new(Mutex::new(Vec::<(String, Vec<u8>)>::new()));
let capture = downloads.clone();
let mut h = harness(Services {
download: Some(Box::new(move |name, bytes| capture.lock().unwrap().push((name.to_string(), bytes.to_vec())))),
..Default::default()
});
h.input_mut().events.push(egui::Event::Text("123".into()));
h.run_steps(2);
assert!(h.state().editor.is_some());
choose(&mut h, "Save");
assert!(h.state().editor.is_none(), "opening File commits the cell draft");
assert_eq!(page(&h), Some("saveAs"), "a workbook without a file saves through Save As");
click(&mut h, "Download (.xlsx)");
assert!(h.state().backstage.is_none());
choose(&mut h, "Save As");
click(&mut h, "Download (.xlsx)");
choose(&mut h, "Export");
click(&mut h, "CSV (Comma delimited)");
let saved = downloads.lock().unwrap();
assert_eq!(saved.len(), 3);
for (_, bytes) in saved.iter().take(2) {
let (wb, _) = gridcraft_engine::io::open_bytes("saved.xlsx", bytes).unwrap();
assert_eq!(wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0));
}
assert!(saved[2].0.ends_with(".csv"));
assert!(String::from_utf8_lossy(&saved[2].1).contains("123"));
drop(saved);
choose(&mut h, "Close");
assert!(h.state().backstage.is_none());
assert_eq!(h.state().session.documents().len(), 1);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges"));
assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0));
}
#[test]
/// Options opens, named Backstage pages are reachable and accessible, an invalid page errors, and both close and Back leave Backstage.
fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() {
let mut h = harness(Services::default());
let r = h.state_mut().run("file.options", serde_json::json!({})).unwrap();
assert_eq!(r["calculation"]["mode"], "automatic");
h.run_steps(3);
h.check_accessibility();
assert_eq!(page(&h), Some("options"));
click(&mut h, "Info");
h.check_accessibility();
assert_eq!(page(&h), Some("info"));
for id in ["print", "export", "saveAs", "new", "open", "home"] {
h.state_mut().run("ui.backstage", serde_json::json!({"page": id})).unwrap();
h.run_steps(2);
h.check_accessibility();
assert_eq!(page(&h), Some(id));
}
assert!(h.state_mut().run("ui.backstage", serde_json::json!({"page": "feedback"})).is_err());
h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap();
assert!(h.state().backstage.is_none());
// The back arrow returns to the sheet too.
h.state_mut().run("ui.backstage", serde_json::json!({})).unwrap();
h.run_steps(2);
click(&mut h, "Back");
h.check_accessibility();
assert!(h.state().backstage.is_none());
}
#[test]
fn translated_options_headings_show_no_context_prefix() {
for language in [Language::Fr, Language::Sr, Language::Nl, Language::Ro] {
let mut h = harness(Services::default());
h.state_mut().run("app.language.set", serde_json::json!({"language": language.code()})).unwrap();
h.state_mut().run("file.options", serde_json::json!({})).unwrap();
h.run_steps(3);
assert_eq!(page(&h), Some("options"));
for key in ["Options|General", "Options|Save"] {
let heading = language.tr(key);
assert!(!heading.contains('|'), "{language:?}: {key} shows its context: {heading}");
assert!(h.query_all_by_label(heading).next().is_some(), "{language:?}: no {heading} heading");
}
// Page Layout › Sheet Options: the Gridlines and Headings "View" checkboxes had the same
// prefix. The View tab may share the word, so count at least the two checkboxes.
h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap();
h.state_mut().ui.ribbon_tab = "Page Layout".into();
h.run_steps(3);
let view = language.tr("Sheet Options|View");
assert!(!view.contains('|'), "{language:?}: Sheet Options|View shows its context: {view}");
assert!(h.query_all_by_label(view).count() >= 2, "{language:?}: the Sheet Options checkboxes don't read {view}");
}
}
#[test]
fn file_account_shows_the_version_and_opens_about() {
let mut h = harness(Services::default());
choose(&mut h, "Account");
assert_eq!(page(&h), Some("account"));
assert!(h.state().dialog.is_none());
// The About dialog also shows the version; it must not be open yet.
h.get_by_label(env!("CARGO_PKG_VERSION"));
h.get_by_label(env!("CARGO_PKG_LICENSE"));
click(&mut h, "About GridCraft");
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("about"));
assert_eq!(page(&h), Some("account"), "About opens over the File view, like Protect Workbook on Info");
}
#[test]
fn export_as_pdf_is_delivered_as_a_download() {
let files = Arc::new(Mutex::new(Vec::<(String, Vec<u8>)>::new()));
let capture = files.clone();
let mut h = harness(Services {
download: Some(Box::new(move |name, bytes| capture.lock().unwrap().push((name.to_string(), bytes.to_vec())))),
..Default::default()
});
h.state_mut().run("file.exportPdf", serde_json::json!({})).unwrap();
let got = files.lock().unwrap();
assert_eq!(got.len(), 1, "Export as PDF hands a file to the browser");
assert!(got[0].0.ends_with(".pdf"));
assert!(got[0].1.starts_with(b"%PDF-"));
}
#[test]
fn save_as_offers_pdf_and_leaves_the_workbook_file_alone() {
let dir = std::env::temp_dir().join(format!("gridcraft-save-as-type-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let asked = Arc::new(Mutex::new(Vec::<String>::new()));
let (capture, target) = (asked.clone(), dir.clone());
let mut h = harness(Services {
pick_save: Some(Box::new(move |suggested| {
capture.lock().unwrap().push(suggested.to_string());
Some(target.join("Report").to_string_lossy().into_owned())
})),
..Default::default()
});
h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap();
choose(&mut h, "Save As");
h.state_mut().backstage.as_mut().unwrap().save_format = "pdf";
h.run_steps(2);
click(&mut h, "Browse…");
assert!(asked.lock().unwrap().first().is_some_and(|s| s.ends_with(".pdf")), "the picker is offered a .pdf name");
assert!(std::fs::read(dir.join("Report.pdf")).unwrap().starts_with(b"%PDF-"));
let d = h.state().session.active().unwrap();
assert!(d.path.is_none() && d.is_dirty(), "a PDF is a copy, not the workbook's file");
let _ = std::fs::remove_dir_all(&dir);
}
/// The window's close button (or Alt+F4, or the OS quitting the app): steps until the request
/// has been handled and returns the viewport commands the app answered with.
fn request_window_close(h: &mut egui_kittest::Harness<'static, SheetApp>) -> Vec<egui::ViewportCommand> {
h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
step_commands(h)
}
/// Runs a few frames and gathers the commands sent to the window.
fn step_commands(h: &mut egui_kittest::Harness<'static, SheetApp>) -> Vec<egui::ViewportCommand> {
let mut out = Vec::new();
for _ in 0..3 {
h.step();
out.extend(h.output().viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default());
}
out
}
fn click_commands(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) -> Vec<egui::ViewportCommand> {
h.get_by_role_and_label(egui::accesskit::Role::Button, label).click();
step_commands(h)
}
#[test]
fn closing_the_window_asks_about_every_unsaved_workbook() {
let mut h = harness(Services::default());
h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap();
h.state_mut().run("file.new", serde_json::json!({})).unwrap();
h.state_mut().run("cell.set", serde_json::json!({"cell": "B2", "input": "7"})).unwrap();
h.run_steps(2);
let sent = request_window_close(&mut h);
assert!(sent.contains(&egui::ViewportCommand::CancelClose), "unsaved changes hold the window open: {sent:?}");
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges"));
let sent = click_commands(&mut h, "Cancel");
assert!(!sent.contains(&egui::ViewportCommand::Close), "Cancel keeps the app open");
assert!(h.state().dialog.is_none());
assert_eq!(h.state().session.documents().len(), 2, "Cancel keeps both workbooks");
request_window_close(&mut h);
let sent = click_commands(&mut h, "Don't Save");
assert!(!sent.contains(&egui::ViewportCommand::Close), "the other workbook is still unsaved");
assert_eq!(h.state().session.documents().len(), 1);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges"), "then it asks about the next one");
let sent = click_commands(&mut h, "Don't Save");
assert!(sent.contains(&egui::ViewportCommand::Close), "once every workbook is answered the window closes: {sent:?}");
let sent = request_window_close(&mut h);
assert!(!sent.contains(&egui::ViewportCommand::CancelClose), "the close that follows is let through");
}
#[test]
fn closing_the_window_can_save_first_and_a_saved_workbook_closes_at_once() {
let dir = std::env::temp_dir().join(format!("gridcraft-close-window-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Book.xlsx").to_string_lossy().into_owned();
let mut h = harness(Services::default());
h.state_mut().run("file.save", serde_json::json!({"path": &path})).unwrap();
h.run_steps(2);
let sent = request_window_close(&mut h);
assert!(!sent.contains(&egui::ViewportCommand::CancelClose), "nothing unsaved: the window just closes");
assert!(h.state().dialog.is_none());
// A cell still being typed is an unsaved change too.
h.input_mut().events.push(egui::Event::Text("123".into()));
h.run_steps(2);
assert!(h.state().editor.is_some());
let sent = request_window_close(&mut h);
assert!(sent.contains(&egui::ViewportCommand::CancelClose), "{sent:?}");
let sent = click_commands(&mut h, "Save");
assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}");
let (wb, _) = gridcraft_engine::io::open_bytes("Book.xlsx", &std::fs::read(&path).unwrap()).unwrap();
assert_eq!(wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0), "Save wrote the change before closing");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn quitting_with_one_of_two_workbooks_unsaved_asks_once() {
let mut h = harness(Services::default());
h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap();
h.state_mut().run("file.new", serde_json::json!({})).unwrap();
h.run_steps(2);
let sent = request_window_close(&mut h);
assert!(sent.contains(&egui::ViewportCommand::CancelClose), "{sent:?}");
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges"));
assert_eq!(h.state().session.active_index(), 0, "the unsaved workbook comes forward");
let sent = click_commands(&mut h, "Cancel");
assert!(!sent.contains(&egui::ViewportCommand::Close), "Cancel stops the quit");
assert_eq!(h.state().session.documents().len(), 2, "Cancel keeps both workbooks");
request_window_close(&mut h);
let sent = click_commands(&mut h, "Don't Save");
assert!(sent.contains(&egui::ViewportCommand::Close), "the saved workbook isn't asked about: {sent:?}");
assert!(h.state().dialog.is_none());
}
/// Book1 has a cell being typed when File › New runs (the macOS menu runs the command directly).
fn typing_in_book1_then_new(h: &mut egui_kittest::Harness<'static, SheetApp>) -> u64 {
h.input_mut().events.push(egui::Event::Text("hello".into()));
h.run_steps(2);
assert!(h.state().editor.is_some());
let book1 = h.state().session.doc().unwrap().uid;
h.state_mut().run_or_alert("file.new", serde_json::json!({}));
h.run_steps(3);
book1
}
fn a1(h: &egui_kittest::Harness<'static, SheetApp>, doc: usize) -> Value {
h.state().session.documents()[doc].wb.active().unwrap().value(CellRef::new(0, 0))
}
#[test]
fn new_workbook_keeps_the_typed_cell_in_the_first_and_switch_windows_brings_it_back() {
let mut h = harness(Services::default());
let book1 = typing_in_book1_then_new(&mut h);
assert!(h.state().editor.is_none(), "the draft doesn't follow into Book2");
assert_eq!(h.state().session.documents().len(), 2);
assert_eq!(h.state().session.active_index(), 1);
assert_eq!(a1(&h, 0), Value::text("hello"), "the draft is committed into Book1, as Enter would");
assert!(h.state().session.documents()[0].is_dirty(), "so quitting asks about Book1");
assert_eq!(a1(&h, 1), Value::Empty);
h.key_press(egui::Key::Enter);
h.run_steps(2);
assert_eq!(a1(&h, 1), Value::Empty, "Enter in Book2 writes nothing left over");
// The key tip or command search opens the View tab's list; Book1 shows its unsaved bullet.
h.state_mut().run_or_alert("view.switchWindows", serde_json::json!({}));
h.run_steps(3);
assert_eq!(h.state().ui.ribbon_tab, "View");
h.get_by_label_contains("Book1 •").click();
h.run_steps(3);
assert_eq!(h.state().session.doc().unwrap().uid, book1);
assert_eq!(h.state().session.documents().len(), 2);
assert!(h.state().message.is_none());
}
#[test]
fn a_draft_its_workbook_refuses_stops_the_switch_and_stays_open() {
let mut h = harness(Services::default());
let book1 = typing_in_book1_then_new(&mut h);
let book2 = h.state().session.doc().unwrap().uid;
h.state_mut().session.execute("data.validation", serde_json::json!({"range": "A1", "type": "list", "formula1": "\"Red,Green\""})).unwrap();
h.input_mut().events.push(egui::Event::Text("Purple".into()));
h.run_steps(2);
h.state_mut().run_or_alert("view.switchWindows", serde_json::json!({"doc": book1}));
h.run_steps(2);
assert_eq!(h.state().session.doc().unwrap().uid, book2, "the switch doesn't happen");
assert_eq!(h.state().editor.as_ref().map(|ed| ed.text.as_str()), Some("Purple"), "the draft stays open to fix");
assert_eq!(a1(&h, 1), Value::Empty, "Book2 is unchanged");
assert!(h.state().message.is_some(), "the refusal is reported");
// Esc drops the draft; then the switch goes through.
h.state_mut().message = None;
h.state_mut().cancel_edit();
h.state_mut().run_or_alert("view.switchWindows", serde_json::json!({"doc": book1}));
h.run_steps(2);
assert_eq!(h.state().session.doc().unwrap().uid, book1);
assert!(h.state().message.is_none());
}
#[test]
fn an_untouched_f2_draft_leaves_the_workbook_clean_on_a_file_command() {
let dir = std::env::temp_dir().join(format!("gridcraft-f2-draft-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Book.xlsx").to_string_lossy().into_owned();
let mut h = harness(Services::default());
h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "=1+2"})).unwrap();
h.state_mut().run("file.save", serde_json::json!({"path": &path})).unwrap();
h.run_steps(2);
let undo = h.state().session.doc().unwrap().undo.len();
h.key_press(egui::Key::F2);
h.run_steps(2);
assert_eq!(h.state().editor.as_ref().map(|ed| ed.text.as_str()), Some("=1+2"));
h.state_mut().run_or_alert("file.new", serde_json::json!({}));
h.run_steps(2);
assert!(h.state().editor.is_none());
let book1 = &h.state().session.documents()[0];
assert!(!book1.is_dirty(), "nothing was typed, so nothing changed");
assert_eq!(book1.undo.len(), undo, "and no undo step was added");
let _ = std::fs::remove_dir_all(&dir);
}
fn pdf_picker_harness(dir: &std::path::Path) -> (Arc<Mutex<Vec<String>>>, egui_kittest::Harness<'static, SheetApp>) {
let asked = Arc::new(Mutex::new(Vec::<String>::new()));
let (capture, target) = (asked.clone(), dir.to_path_buf());
let h = harness(Services {
pick_save: Some(Box::new(move |suggested| {
capture.lock().unwrap().push(suggested.to_string());
Some(target.join("Report.xlsx").to_string_lossy().into_owned())
})),
..Default::default()
});
(asked, h)
}
#[test]
fn the_palette_export_as_pdf_asks_where_to_save() {
let dir = std::env::temp_dir().join(format!("gridcraft-export-pdf-palette-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (asked, mut h) = pdf_picker_harness(&dir);
h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap();
// The palette and menus go through run_or_alert.
h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({}));
assert!(asked.lock().unwrap().first().is_some_and(|s| s.ends_with(".pdf")), "the picker is offered a .pdf name");
assert!(std::fs::read(dir.join("Report.pdf")).unwrap().starts_with(b"%PDF-"), "the PDF is written, whatever extension the picker returned");
assert!(h.state().message.is_none());
let d = h.state().session.active().unwrap();
assert!(d.path.is_none(), "a PDF is a copy, not the workbook's file");
// Other params survive the round trip through the picker.
h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({"range": "nope"}));
assert!(h.state().message.is_some(), "a bad range is reported, not swallowed");
// A path in the params skips the picker.
let before = asked.lock().unwrap().len();
let named = dir.join("Named.pdf");
h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({"path": named.to_string_lossy()}));
assert_eq!(asked.lock().unwrap().len(), before);
assert!(named.exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_cancelled_export_pdf_picker_writes_nothing() {
let mut h = harness(Services { pick_save: Some(Box::new(|_| None)), ..Default::default() });
h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({}));
assert!(h.state().message.is_none());
assert!(h.state_mut().session.take_ui_requests().is_empty());
}
/// Options › Formulas: Maximum Iterations and Maximum Change, with the checkbox that enables them.
#[test]
fn options_sets_maximum_iterations_and_change_only_while_iteration_is_on() {
use egui::accesskit::Role;
let mut session = Session::new();
session.new_workbook();
// Tall enough that the Formulas section is on screen without scrolling.
let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 1400.0)).build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
crate::common::prepare(SheetApp::new(session, Services::default())),
);
h.run_steps(4);
h.state_mut().run("file.options", serde_json::json!({})).unwrap();
h.run_steps(3);
let calc = |h: &egui_kittest::Harness<'static, SheetApp>| h.state().session.active().unwrap().wb.calc.clone();
// Resolve the field by its accessible name in both the disabled and enabled states.
let field = |h: &egui_kittest::Harness<'static, SheetApp>, label: &str| h.get_by_role_and_label(Role::TextInput, label).rect();
let disabled = |h: &egui_kittest::Harness<'static, SheetApp>, rect: egui::Rect| {
h.get_all_by_role(Role::TextInput).find(|n| n.rect() == rect).unwrap().accesskit_node().is_disabled()
};
let retype = |h: &mut egui_kittest::Harness<'static, SheetApp>, rect: egui::Rect, text: &str| {
h.input_mut().events.push(egui::Event::PointerMoved(rect.center()));
h.run_steps(1);
h.get_all_by_role(Role::TextInput).find(|n| n.rect() == rect).unwrap().click();
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.run_steps(1);
for c in text.chars() {
h.input_mut().events.push(egui::Event::Text(c.to_string()));
h.run_steps(2);
}
h.key_press(egui::Key::Enter);
h.run_steps(2);
};
// Iteration is off: the two fields show the defaults, greyed out.
let (iterations, change) = (field(&h, "Maximum Iterations:"), field(&h, "Maximum Change:"));
assert!(disabled(&h, iterations) && disabled(&h, change), "the fields are disabled while iteration is off");
assert_eq!(h.get_all_by_role(Role::TextInput).find(|n| n.rect() == iterations).unwrap().value().as_deref(), Some("100"));
assert_eq!(h.get_all_by_role(Role::TextInput).find(|n| n.rect() == change).unwrap().value().as_deref(), Some("0.001"));
h.get_by_role_and_label(Role::CheckBox, "Enable iterative calculation").click();
h.run_steps(3);
assert!(calc(&h).iterative);
let (iterations, change) = (field(&h, "Maximum Iterations:"), field(&h, "Maximum Change:"));
assert!(!disabled(&h, iterations) && !disabled(&h, change));
retype(&mut h, iterations, "500");
assert_eq!(calc(&h).max_iterations, 500);
retype(&mut h, change, "0.01");
assert_eq!(calc(&h).max_change, 0.01);
assert!(h.state().message.is_none());
// Out-of-range entries are refused with a message and change nothing (the command alone would clamp them).
for bad in ["0", "32768", "12.5", "lots"] {
retype(&mut h, iterations, bad);
assert_eq!(calc(&h).max_iterations, 500, "{bad}");
assert!(h.state_mut().message.take().is_some(), "{bad} is reported");
h.run_steps(2);
}
retype(&mut h, iterations, "32767");
assert_eq!(calc(&h).max_iterations, 32767);
for bad in ["-1", "x"] {
retype(&mut h, change, bad);
assert_eq!(calc(&h).max_change, 0.01, "{bad}");
assert!(h.state_mut().message.take().is_some(), "{bad} is reported");
h.run_steps(2);
}
retype(&mut h, change, "0");
assert_eq!(calc(&h).max_change, 0.0);
// Undo puts the old value back in the field.
h.state_mut().run("edit.undo", serde_json::json!({})).unwrap();
h.run_steps(3);
assert_eq!(calc(&h).max_change, 0.01);
let change = field(&h, "Maximum Change:");
assert_eq!(h.get_all_by_role(Role::TextInput).find(|n| n.rect() == change).unwrap().value().as_deref(), Some("0.01"));
// A value typed but not confirmed with Enter is dropped when the checkbox switches iteration off.
let iterations = field(&h, "Maximum Iterations:");
h.input_mut().events.push(egui::Event::PointerMoved(iterations.center()));
h.run_steps(1);
h.get_all_by_role(Role::TextInput).find(|n| n.rect() == iterations).unwrap().click();
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.input_mut().events.push(egui::Event::Text("777".into()));
h.run_steps(2);
h.get_by_role_and_label(Role::CheckBox, "Enable iterative calculation").click();
h.run_steps(3);
assert!(!calc(&h).iterative);
assert_eq!(calc(&h).max_iterations, 32767, "an unconfirmed entry is not committed once iteration is off");
assert!(h.state().message.is_none());
// Spanish accepts a decimal comma in Maximum Change, and still a dot.
h.get_by_role_and_label(Role::CheckBox, "Enable iterative calculation").click();
h.run_steps(3);
h.state_mut().run("app.language.set", serde_json::json!({"language": Language::Es.code()})).unwrap();
h.state_mut().run("file.options", serde_json::json!({})).unwrap();
h.run_steps(3);
let change = field(&h, "Cambio máximo:");
retype(&mut h, change, "0,02");
assert_eq!(calc(&h).max_change, 0.02);
retype(&mut h, change, "0.5");
assert_eq!(calc(&h).max_change, 0.5);
assert!(h.state().message.is_none());
}
/// Share › Export as Web Page (and the native File menu's Save as Web Page) asks for a name in its
/// own dialog. A bare name has no folder: written as typed it lands in the process's working
/// directory, where nobody looks for it (#1212). The save picker chooses the folder instead.
#[test]
fn save_a_copy_with_a_bare_name_asks_where_to_save_it() {
let dir = std::env::temp_dir().join(format!("gridcraft-save-copy-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let asked = Arc::new(Mutex::new(Vec::<String>::new()));
let (capture, target) = (asked.clone(), dir.clone());
let mut h = harness(Services {
pick_save: Some(Box::new(move |suggested| {
capture.lock().unwrap().push(suggested.to_string());
Some(target.join("Chosen.html").to_string_lossy().into_owned())
})),
..Default::default()
});
h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap();
let save_copy = |h: &mut egui_kittest::Harness<'static, SheetApp>, path: &str| {
h.state_mut().open_dialog("saveCopy", serde_json::json!({"format": "html"}));
h.run_steps(3);
h.state_mut().dialog.as_mut().unwrap().values.insert("path".into(), serde_json::json!(path));
h.run_steps(2);
h.get_by_label(" OK ").click();
h.run_steps(3);
};
let name = format!("gridcraft-save-copy-{}", std::process::id());
save_copy(&mut h, &name);
let stray = std::path::PathBuf::from(format!("{name}.html"));
let written_in_cwd = stray.exists();
let _ = std::fs::remove_file(&stray);
assert!(!written_in_cwd, "a bare name is not written to the working directory");
assert_eq!(*asked.lock().unwrap(), vec![format!("{name}.html")], "the picker is offered the typed name");
assert!(std::fs::read_to_string(dir.join("Chosen.html")).unwrap().contains("42"), "the page is written where the picker says");
assert!(h.state().message.is_none());
// A full path skips the picker; a write that fails is reported, not swallowed.
save_copy(&mut h, &dir.join("Named.html").to_string_lossy());
assert!(dir.join("Named.html").exists());
save_copy(&mut h, &dir.join("missing").join("Lost.html").to_string_lossy());
assert!(h.state().message.is_some(), "a failed write is reported");
assert_eq!(asked.lock().unwrap().len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn new_window_shows_the_same_workbook_and_a_draft_settles_in_its_own_window() {
let mut h = harness(Services::default());
h.state_mut().run("selection.set", serde_json::json!({"range": "B2"})).unwrap();
let first = h.state().session.active_window().unwrap();
h.state_mut().run_or_alert("view.newWindow", serde_json::json!({}));
h.run_steps(2);
let second = h.state().session.active_window().unwrap();
let book = h.state().session.doc().unwrap().uid;
assert_ne!(first, second);
assert_eq!(h.state().session.documents().len(), 1, "one workbook, two windows");
let titles: Vec<String> = h.state().open_windows().iter().map(|w| w.title.clone()).collect();
assert_eq!(titles, ["Book1:1", "Book1:2"]);
// A draft typed in window 2 is entered in window 2's cell before window 1 comes forward.
h.state_mut().run("selection.set", serde_json::json!({"range": "D4"})).unwrap();
h.input_mut().events.push(egui::Event::Text("typed".into()));
h.run_steps(2);
h.state_mut().run_or_alert("view.switchWindows", serde_json::json!({"window": first}));
h.run_steps(2);
assert!(h.state().editor.is_none(), "the draft doesn't follow into window 1");
assert_eq!(h.state().session.active_window(), Some(first));
let d = h.state().session.doc().unwrap();
assert_eq!(d.wb.active().unwrap().value(CellRef::parse("D4").unwrap()), Value::text("typed"));
assert_eq!(d.selection.active, CellRef::parse("B2").unwrap(), "window 1 keeps its own selection");
// Quitting asks once about the workbook, whichever window shows it.
request_window_close(&mut h);
let sent = click_commands(&mut h, "Don't Save");
assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}");
assert!(h.state().session.document_index(book).is_none(), "both windows closed with the workbook");
}
#[test]
fn control_requests_route_to_a_window_and_list_windows() {
use gridcraft_ui_egui::control::{Outcome, handle};
let mut h = harness(Services::default());
let first = h.state().session.active_window().unwrap();
h.state_mut().run_or_alert("file.new", serde_json::json!({}));
h.run_steps(2);
let ctx = h.ctx.clone();
let call = |h: &mut egui_kittest::Harness<'static, SheetApp>, method: &str, p: serde_json::Value| match handle(h.state_mut(), &ctx, method, &p) {
Outcome::Done(v) => v,
Outcome::Screenshot { .. } => serde_json::Value::Null,
};
let r = call(&mut h, "engine.execute", serde_json::json!({"command": "cell.set", "params": {"cell": "A1", "input": "one"}, "window": first}));
assert_eq!(r["ok"], true, "{r}");
assert_eq!(a1(&h, 0), Value::text("one"));
assert_eq!(a1(&h, 1), Value::Empty);
assert_eq!(h.state().session.active_index(), 1, "Book2 stays in front");
let r = call(&mut h, "cell.get", serde_json::json!({"cell": "A1", "window": first}));
assert!(r["result"].to_string().contains("one"), "{r}");
let r = call(&mut h, "window.list", serde_json::json!({}));
let rows = r["result"].as_array().unwrap();
assert_eq!(rows.len(), 2, "{r}");
assert_eq!((rows[0]["window"].as_u64(), rows[0]["title"].as_str(), rows[0]["dirty"].as_bool()), (Some(first), Some("Book1"), Some(true)));
assert_eq!(rows[1]["active"].as_bool(), Some(true));
let r = call(&mut h, "engine.execute", serde_json::json!({"command": "cell.get", "params": {"cell": "A1"}, "window": 999}));
assert_eq!(r["ok"], false, "{r}");
}