Files
gridcraft/crates/ui-egui/tests/it/file_menu.rs
T
aiongg a7608ada6d CI: one test binary per crate, nextest, line tables only (#2372)
* CI: dev builds keep line tables only

* CI: one integration-test binary per crate

* CI: xtask runs tests with nextest, doctests apart, --keep-going on clippy and wasm

cargo xtask test runs the workspace tests (--lib --bins --tests) with cargo-nextest when it is installed, else cargo test, then the doctests. cargo xtask ci uses the same two steps.

* CI: docs name cargo xtask test

* CI: agent guides name the moved fuzz test

* CI: tests added on main join their crate's test binary
2026-10-23 12:59:46 -04:00

380 lines
18 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::Queryable as _;
use gridcraft_engine::{Session, core::CellRef, core::Value};
use gridcraft_ui_egui::{Services, SheetApp};
fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> {
let mut session = Session::new();
session.new_workbook();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
SheetApp::new(session, services),
);
h.run_steps(4);
h
}
/// 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);
}
#[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]
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);
assert_eq!(page(&h), Some("options"));
click(&mut h, "Info");
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);
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");
assert!(h.state().backstage.is_none());
}
#[test]
fn translated_options_headings_show_no_context_prefix() {
for (code, headings, prefixed, (view, view_prefixed)) in [
("fr", ["Général", "Enregistrer"], ["Options|Général", "Options|Enregistrer"], ("Affichage", "Options de la feuille|Affichage")),
("sr", ["Opšte", "Sačuvaj"], ["Opcije|Opšte", "Opcije|Sačuvaj"], ("Prikaz", "Opcije lista|Prikaz")),
("nl", ["Algemeen", "Opslaan"], ["Opties|Algemeen", "Opties|Opslaan"], ("Weergeven", "Bladopties|Weergeven")),
("ro", ["General", "Salvare"], ["Opțiuni|General", "Opțiuni|Salvare"], ("Vizualizare", "Opțiuni foaie|Vizualizare")),
] {
let mut h = harness(Services::default());
h.state_mut().run("app.language.set", serde_json::json!({"language": code})).unwrap();
h.state_mut().run("file.options", serde_json::json!({})).unwrap();
h.run_steps(3);
assert_eq!(page(&h), Some("options"));
for heading in headings {
assert!(h.query_all_by_label(heading).next().is_some(), "{code}: no {heading} heading");
}
for label in prefixed {
assert!(h.query_by_label(label).is_none(), "{code}: {label} shows its context");
}
// 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);
assert!(h.query_by_label(view_prefixed).is_none(), "{code}: {view_prefixed} shows its context");
assert!(h.query_all_by_label(view).count() >= 2, "{code}: 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);
}
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());
}
/// 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);
}