Ely Office: six offline-first desktop apps in Rust and GPUI

ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 21:20:08 -04:00
commit b7fca0146d
817 files changed
+143101

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
use ely_lok::sheet::{self, Layout};
use super::data::{cells, open, quiet, run};
use super::*;
use crate::analysis::Asked;
/// Reads through the active tab's worker.
fn read<T>(cx: &mut TestAppContext, book: &Entity<Book>, what: &str, parse: fn(&str) -> T) -> T {
quiet(cx, book);
cx.update(|app| {
let book = book.read(app);
let tab = book.active_tab().expect("a tab");
let mut worker = book.core.worker.lock().expect("the worker lock");
parse(&worker.values(&tab.doc, what).expect("the read"))
})
}
fn ask(cx: &mut TestAppContext, book: &Entity<Book>, asked: Asked, is: fn(&Dialog) -> bool) {
update(cx, book, |book, cx| {
book.dialog = None;
book.ask(asked, cx)
});
settle(cx, |app| book.read(app).dialog.as_ref().is_some_and(is));
}
#[test]
fn a_pivot_table_is_made_then_changed_in_place() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
open(&mut cx, &book);
run(&mut cx, &book, "cell.goto", &["B3"]);
ask(&mut cx, &book, Asked::Pivot, |dialog| {
matches!(dialog, Dialog::Pivot { editing: false, .. })
});
update(&mut cx, &book, |book, cx| {
let Some(Dialog::Pivot {
source,
fields,
layout,
..
}) = &mut book.dialog
else {
unreachable!("a pivot dialog")
};
assert!(source.ends_with("$A$1:$C$5"), "the data area: {source}");
assert_eq!(fields, &["Name", "Qty", "City"], "the header row");
layout.rows.push("City".into());
layout.data.push(("Qty".into(), "sum".into()));
book.pivot_asked(cx)
});
settle(&cx, |app| sheet_names(app, &book).len() == 2);
let made = read(&mut cx, &book, sheet::PIVOT, |json| {
sheet::pivot(json).expect("parses")
})
.expect("the cursor is on the new table");
assert_eq!(made.layout.rows, ["City"]);
let shown = cells(&mut cx, &book, "A1:B4");
assert!(
shown.contains("x\t6") && shown.contains("y\t3"),
"{shown:?}"
);
run(&mut cx, &book, "cell.goto", &["A2"]);
ask(&mut cx, &book, Asked::Pivot, |dialog| {
matches!(dialog, Dialog::Pivot { editing: true, .. })
});
let changed = Layout {
rows: vec!["Name".into()],
data: vec![("Qty".into(), "max".into())],
..Layout::default()
};
update(&mut cx, &book, |book, cx| {
if let Some(Dialog::Pivot { layout, .. }) = &mut book.dialog {
*layout = changed.clone();
}
book.pivot_asked(cx)
});
run(&mut cx, &book, "cell.goto", &["A2"]);
let now = read(&mut cx, &book, sheet::PIVOT, |json| {
sheet::pivot(json).expect("parses")
});
let placed = Layout {
columns: vec![String::new()],
..changed
};
assert_eq!(now.expect("still there").layout, placed);
let sheets = cx.update(|app| sheet_names(app, &book));
assert_eq!(sheets.len(), 2, "the change made no sheet");
}
#[test]
fn a_chart_is_inserted_then_edited() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
open(&mut cx, &book);
run(&mut cx, &book, "cell.goto", &["A1:B5"]);
ask(&mut cx, &book, Asked::Chart, |dialog| {
matches!(dialog, Dialog::Chart { chart: None, .. })
});
let range = cx.update(|app| {
let Some(Dialog::Chart { fields, .. }) = &book.read(app).dialog else {
unreachable!("a chart dialog")
};
fields.range.read(app).text().to_string()
});
assert!(range.ends_with("$A$1:$B$5"), "the picked range: {range}");
update(&mut cx, &book, |book, cx| {
let Some(Dialog::Chart { look, fields, .. }) = &mut book.dialog else {
unreachable!("a chart dialog")
};
look.kind = "line".into();
fields
.look
.title
.update(cx, |input, cx| input.set_text("Qty by name", cx));
book.chart_asked(cx)
});
let chart = |cx: &mut TestAppContext| {
read(cx, &book, sheet::CHART, |json| {
sheet::chart(json).expect("parses")
})
};
let made = chart(&mut cx).expect("the new chart is selected");
assert_eq!(
(made.look.kind.as_str(), made.look.title.as_str()),
("line", "Qty by name")
);
ask(&mut cx, &book, Asked::Chart, |dialog| {
matches!(dialog, Dialog::Chart { chart: Some(_), .. })
});
update(&mut cx, &book, |book, cx| {
if let Some(Dialog::Chart { look, .. }) = &mut book.dialog {
look.kind = "pie".into();
look.legend = false;
}
book.chart_asked(cx)
});
let edited = chart(&mut cx).expect("still selected");
assert_eq!(
(edited.look.kind.as_str(), edited.look.legend),
("pie", false)
);
assert_eq!(edited.look.title, "Qty by name", "the title stays");
}
#[test]
fn asking_on_an_empty_cell_leaves_enter_moving_down() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &book);
update(&mut cx, &book, |book, cx| book.ask(Asked::Chart, cx));
for _ in 0..200 {
cx.executor().tick();
}
quiet(&cx, &book);
let address = cx.update(|app| {
let book = book.read(app);
let tab = book.active_tab().expect("a tab");
let mut worker = book.core.worker.lock().expect("the worker lock");
worker.post_key(&tab.doc, '7' as u32, 0).expect("a key");
worker.post_key(&tab.doc, 0, 1280).expect("Enter");
let json = worker
.values(&tab.doc, sheet::SELECTED)
.expect("the selection");
sheet::selected_range(&json)
.expect("parses")
.expect("one")
.range
});
assert!(address.ends_with("$A$2"), "Enter moved down: {address}");
}
+302
View File
@@ -0,0 +1,302 @@
use ely_gpui_component::tables::{FilterRule, SortKey, Test};
use ely_lok::{Request, sheet};
use super::*;
/// Name, Qty, City; one repeated row.
const DATA: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
<office:body><office:spreadsheet><table:table table:name="S">
<table:table-row><table:table-cell><text:p>Name</text:p></table:table-cell><table:table-cell><text:p>Qty</text:p></table:table-cell><table:table-cell><text:p>City</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell><text:p>a</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="3"><text:p>3</text:p></table:table-cell><table:table-cell><text:p>x</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell><text:p>b</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="1"><text:p>1</text:p></table:table-cell><table:table-cell><text:p>y</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell><text:p>a</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="3"><text:p>3</text:p></table:table-cell><table:table-cell><text:p>x</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell><text:p>c</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="2"><text:p>2</text:p></table:table-cell><table:table-cell><text:p>y</text:p></table:table-cell></table:table-row>
</table:table></office:spreadsheet></office:body>
</office:document>"#;
pub(super) fn open(cx: &mut TestAppContext, book: &Entity<Book>) {
update(cx, book, |book, cx| {
book.new_from_text("data.fods", DATA, cx)
});
settle(cx, |app| sheet_names(app, book) == ["S"]);
}
/// Waits for the active tab's queued input.
pub(super) fn quiet(cx: &TestAppContext, book: &Entity<Book>) {
settle(cx, |app| {
book.read(app)
.active_tab()
.is_some_and(|tab| tab.view.read(app).settled())
});
}
/// The cells of `range`, read through the worker.
pub(super) fn cells(cx: &mut TestAppContext, book: &Entity<Book>, range: &str) -> String {
quiet(cx, book);
cx.update(|app| {
let book = book.read(app);
let tab = book.active_tab().expect("a tab");
let mut worker = book.core.worker.lock().expect("the worker lock");
worker
.run(&tab.doc, "cell.goto", &[range])
.expect("the range");
tab.doc
.selection_text(&mut worker)
.expect("the text")
.trim()
.to_string()
})
}
pub(super) fn run(cx: &mut TestAppContext, book: &Entity<Book>, id: &str, values: &[&str]) {
update(cx, book, |book, cx| book.run_active(id, values, cx));
quiet(cx, book);
}
/// The column's entries the filter drops.
fn dropped(cx: &mut TestAppContext, book: &Entity<Book>, column: u32) -> Vec<String> {
quiet(cx, book);
cx.update(|app| {
let book = book.read(app);
let tab = book.active_tab().expect("a tab");
let mut worker = book.core.worker.lock().expect("the worker lock");
let json = worker
.values(&tab.doc, &sheet::cell_query(sheet::FILTER, column, 0))
.expect("the entries");
sheet::filter_values(&json)
.expect("parses")
.entries
.into_iter()
.filter(|entry| !entry.selected)
.map(|entry| entry.text)
.collect()
})
}
pub(super) fn asked(cx: &TestAppContext, book: &Entity<Book>, is: fn(&Dialog) -> bool) {
settle(cx, |app| book.read(app).dialog.as_ref().is_some_and(is));
}
#[test]
fn sort_filter_duplicates_and_columns_run_from_their_dialogs() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
open(&mut cx, &book);
run(&mut cx, &book, "cell.goto", &["B3"]);
update(&mut cx, &book, |book, cx| {
book.ask_data(crate::data::Asked::Sort, cx)
});
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Sort { .. }));
update(&mut cx, &book, |book, cx| {
let Some(Dialog::Sort {
picked,
keys,
header,
..
}) = &mut book.dialog
else {
unreachable!("checked above");
};
assert_eq!(picked.top, ["Name", "Qty", "City"]);
assert!(*header, "the kernel sees headers");
assert_eq!(crate::data::title(picked, 1, *header), "Qty");
*keys = vec![SortKey {
column: "1".into(),
rising: true,
}];
book.data_asked(cx);
});
assert_eq!(cells(&mut cx, &book, "A2:A5"), "b\nc\na\na");
run(&mut cx, &book, "cell.goto", &["A1"]);
update(&mut cx, &book, |book, cx| {
book.ask_data(crate::data::Asked::Filter, cx)
});
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Filter { .. }));
update(&mut cx, &book, |book, cx| {
if let Some(Dialog::Filter { rules, .. }) = &mut book.dialog {
*rules = vec![FilterRule {
column: "2".into(),
test: Test::Equals,
value: "x".into(),
}];
}
book.data_asked(cx);
});
assert_eq!(dropped(&mut cx, &book, 2), ["y"]);
run(&mut cx, &book, "data.unfilter", &[]);
run(&mut cx, &book, "cell.goto", &["A2"]);
update(&mut cx, &book, |book, cx| {
book.ask_data(crate::data::Asked::Dedupe, cx)
});
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Dedupe { .. }));
update(&mut cx, &book, |book, cx| book.data_asked(cx));
assert_eq!(cells(&mut cx, &book, "A1:A5"), "Name\nb\nc\na");
run(&mut cx, &book, "cell.goto", &["A7"]);
run(&mut cx, &book, "cell.enter", &["p;q;r"]);
run(&mut cx, &book, "cell.goto", &["A7"]);
update(&mut cx, &book, |book, cx| book.ask_columns(cx));
asked(&cx, &book, |dialog| {
matches!(dialog, Dialog::Columns { .. })
});
update(&mut cx, &book, |book, cx| {
if let Some(Dialog::Columns { separators, .. }) = &mut book.dialog {
*separators = vec!["59".into()];
}
book.columns_asked(cx);
});
assert_eq!(cells(&mut cx, &book, "A7:C7"), "p\tq\tr");
}
#[test]
fn a_column_button_lists_validation_offers_and_formats_list() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
open(&mut cx, &book);
run(&mut cx, &book, "cell.goto", &["A1"]);
run(&mut cx, &book, "data.autofilter", &[]);
let id = cx.update(|app| book.read(app).core.active.expect("a tab"));
update(&mut cx, &book, |book, cx| {
book.ask_column(id, &Request::Filter { column: 0, row: 0 }, cx)
});
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Column { .. }));
update(&mut cx, &book, |book, cx| {
let Some(Dialog::Column {
entries, picked, ..
}) = &mut book.dialog
else {
unreachable!("checked above");
};
assert_eq!(picked.len(), entries.len(), "nothing filtered yet");
*picked = vec!["c".into()];
book.column_asked(None, cx);
});
assert_eq!(dropped(&mut cx, &book, 0), ["a", "b"]);
run(&mut cx, &book, "data.unfilter", &[]);
run(&mut cx, &book, "cell.goto", &["E2"]);
update(&mut cx, &book, |book, cx| book.ask_rule(cx));
asked(&cx, &book, |dialog| {
matches!(dialog, Dialog::Validation { .. })
});
update(&mut cx, &book, |book, cx| {
let Some(Dialog::Validation { rule, fields, .. }) = &mut book.dialog else {
unreachable!("checked above");
};
rule.kind = "list".into();
rule.show_input = true;
fields
.list
.update(cx, |list, cx| list.set_text("Red\nGreen", cx));
fields
.input_message
.update(cx, |note, cx| note.set_text("Pick a color", cx));
book.rule_asked(cx);
});
run(&mut cx, &book, "cell.goto", &["E3"]);
run(&mut cx, &book, "cell.goto", &["E2"]);
settle(&cx, |app| {
book.read(app)
.active_tab()
.and_then(|tab| tab.extra.rule.as_ref())
.is_some_and(|(at, rule)| at.1 == "E2" && rule.input_message == "Pick a color")
});
update(&mut cx, &book, |book, cx| {
book.offer_choices(id, (4, 1), cx)
});
settle(&cx, |app| book.read(app).choices.is_some());
let values = cx.update(|app| {
let choices = book.read(app).choices.as_ref().expect("the menu");
choices.values.clone()
});
assert_eq!(values, ["Red", "Green"]);
run(&mut cx, &book, "cell.goto", &["B2:B5"]);
cx.update_window(window.into(), |_, window, app| {
book.update(app, |book, cx| book.ask_conditional(window, cx))
})
.expect("the window lives");
update(&mut cx, &book, |book, cx| {
if let Some(Dialog::Conditional { value1, .. }) = &book.dialog {
value1.update(cx, |value, cx| value.set_text("2", cx));
}
book.conditional_asked(cx);
});
quiet(&cx, &book);
update(&mut cx, &book, |book, cx| book.ask_formats(cx));
asked(&cx, &book, |dialog| {
matches!(dialog, Dialog::Formats { .. })
});
let style = cx.update(|app| match &book.read(app).dialog {
Some(Dialog::Formats { formats, .. }) => formats[0].style.clone(),
_ => unreachable!("checked above"),
});
assert_eq!(style, "Good");
update(&mut cx, &book, |book, cx| book.format_removed(cx));
quiet(&cx, &book);
update(&mut cx, &book, |book, cx| book.ask_formats(cx));
assert!(
cx.update(|app| book.read(app).dialog.is_none()),
"none left"
);
}
#[test]
fn the_panel_reads_number_formats_and_find_counts_cells() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
open(&mut cx, &book);
run(&mut cx, &book, "cell.goto", &["B2"]);
run(&mut cx, &book, "number.percent", &[]);
update(&mut cx, &book, |book, cx| {
let tab = book.active_tab().expect("a tab");
let shown = (tab.view.clone(), tab.doc.clone());
book.panel
.update(cx, |panel, cx| panel.show(Some((&shown.0, &shown.1)), cx));
});
let kind = |app: &gpui::App| book.read(app).panel.read(app).number.clone();
settle(&cx, |app| {
kind(app).is_some_and(|(_, kind)| kind == "percent")
});
let panel = cx.update(|app| book.read(app).panel.clone());
cx.update(|app| panel.update(app, |panel, cx| panel.apply_code("[ZZZ]0", cx)));
quiet(&cx, &book);
cx.update(|app| panel.update(app, |panel, cx| panel.apply_code("0.0", cx)));
settle(&cx, |app| kind(app).is_some_and(|(code, _)| code == "0.0"));
cx.update_window(window.into(), |_, window, app| {
book.update(app, |book, cx| {
book.on_find(&ely_office::FindReplace, window, cx)
})
})
.expect("the window lives");
let query = cx.update(|app| {
let finder = book.read(app).core.finder.as_ref().expect("the finder");
finder.query.clone()
});
cx.update(|app| query.update(app, |query, cx| query.set_text("a", cx)));
settle(&cx, |app| {
book.read(app)
.core
.finder
.as_ref()
.is_some_and(|finder| finder.all.len() == 3)
});
}
+364
View File
@@ -0,0 +1,364 @@
use super::*;
/// The active workbook's names, from the kernel.
fn names(app: &gpui::App, book: &Entity<Book>) -> Vec<ely_lok::sheet::Name> {
let book = book.read(app);
let tab = book.active_tab().expect("a tab");
let mut worker = book.core.worker.lock().expect("the worker lock");
let json = worker
.values(&tab.doc, ely_lok::sheet::NAMES)
.expect("names");
ely_lok::sheet::names(&json).expect("the names parse")
}
/// Opens the define dialog in the test window.
fn ask_define(cx: &mut TestAppContext, book: &Entity<Book>) {
cx.update(|app| book.update(app, |book, cx| book.ask_define(cx)));
}
/// The define dialog's range text, once it is open.
fn asked_range(cx: &TestAppContext, book: &Entity<Book>) -> String {
settle(cx, |app| {
matches!(book.read(app).dialog, Some(Dialog::Define { .. }))
});
cx.update(|app| match &book.read(app).dialog {
Some(Dialog::Define { range, .. }) => range.read(app).text().to_string(),
_ => unreachable!("checked above"),
})
}
/// Waits until every queued input of every tab landed.
fn quiet(cx: &TestAppContext, book: &Entity<Book>) {
settle(cx, |app| {
book.read(app)
.core
.tabs
.iter()
.all(|tab| tab.view.read(app).settled())
});
}
#[test]
fn a_name_is_checked_then_defined_on_the_selection() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &book);
let sheet = cx.update(|app| sheet_names(app, &book)[0].clone());
update(&mut cx, &book, |book, cx| {
book.run_active("cell.goto", &["B2:C3"], cx)
});
settle(&cx, |app| {
book.read(app)
.active_tab()
.is_some_and(|tab| tab.view.read(app).cell().address == "B2:C3")
});
update(&mut cx, &book, |book, cx| book.ask_define(cx));
settle(&cx, |app| {
matches!(book.read(app).dialog, Some(Dialog::Define { .. }))
});
let define = |cx: &mut TestAppContext, name: &str| {
cx.update(|app| {
book.update(app, |book, cx| {
if let Some(Dialog::Define { name: field, .. }) = &book.dialog {
let name = name.to_string();
field.update(cx, |field, cx| field.set_text(name, cx));
}
book.define(cx);
})
});
};
// Checks answer in order: the cell name first.
define(&mut cx, "C4");
define(&mut cx, "Area");
settle(&cx, |app| book.read(app).dialog.is_none());
settle(&cx, |app| {
book.read(app)
.active_tab()
.is_some_and(|tab| tab.view.read(app).settled())
});
let defined = cx.update(|app| names(app, &book));
assert_eq!(defined.len(), 1);
assert_eq!(
(defined[0].name.as_str(), defined[0].formula.as_str()),
("Area", format!("{sheet}!$B$2:$C$3").as_str()),
"the selection, sheet included"
);
}
#[test]
fn define_waits_for_queued_input_and_reads_named_selections() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &book);
let sheet = cx.update(|app| sheet_names(app, &book)[0].clone());
update(&mut cx, &book, |book, cx| {
book.run_active("cell.goto", &["B2:C3"], cx)
});
ask_define(&mut cx, &book);
assert_eq!(
asked_range(&cx, &book),
format!("{sheet}!$B$2:$C$3"),
"the selection after the queued move"
);
update(&mut cx, &book, |book, cx| {
book.dialog = None;
let range = format!("'{sheet}'!$D$4:$E$5");
book.run_active("names.define", &["Area", &range, ""], cx);
book.run_active("cell.goto", &["A1"], cx);
book.run_active("cell.goto", &["Area"], cx);
});
quiet(&cx, &book);
let range = cx.update(|app| {
let names = names(app, &book);
names[0].range.clone()
});
assert!(range.ends_with("!$D$4:$E$5"), "{range}");
ask_define(&mut cx, &book);
assert_eq!(
asked_range(&cx, &book),
range,
"a named selection keeps its range"
);
}
#[test]
fn the_names_list_stays_with_its_workbook_and_scope() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
for _ in 0..2 {
update(&mut cx, &book, |book, cx| book.new_blank(cx));
}
settle(&cx, |app| {
let tabs = &book.read(app).core.tabs;
tabs.len() == 2 && tabs.iter().all(|tab| !tab.extra.sheets.list.is_empty())
});
let first = cx.update(|app| book.read(app).core.tabs[0].id);
let sheet = cx.update(|app| {
book.read(app).core.tabs[0].extra.sheets.list[0]
.name
.clone()
});
update(&mut cx, &book, |book, cx| {
book.core.active = Some(first);
book.run_active("sheet.insert", &["Other", "2"], cx);
book.run_active(
"names.define",
&["Spot", &format!("'{sheet}'!$A$5"), ""],
cx,
);
book.run_active("names.define", &["Spot", "'Other'!$C$7", "Other"], cx);
book.run_active("names.define", &["Local", "'Other'!$B$2", "Other"], cx);
book.show_sheet(0, cx);
});
quiet(&cx, &book);
cx.update(|app| {
book.update(app, |book, cx| {
book.run_active("cell.enter", &["7"], cx);
book.names_in(first, cx);
})
});
settle(&cx, |app| {
matches!(book.read(app).dialog, Some(Dialog::Names { .. }))
});
update(&mut cx, &book, |book, _| {
book.core.active = Some(book.core.tabs[1].id)
});
let pick = |cx: &mut TestAppContext, name: &str, scope: &str| {
update(cx, &book, |book, cx| {
let Some(Dialog::Names { tab, names, .. }) = &book.dialog else {
panic!("the names list is open");
};
assert_eq!(*tab, first, "the list stays with the asking workbook");
let at = names
.iter()
.position(|known| known.name == name && known.scope == scope)
.expect("the name is listed");
book.choose_name(at, cx);
book.act_on_name(crate::formulas::NameAct::Go, cx);
});
};
let shown = |cx: &mut TestAppContext| {
quiet(cx, &book);
let active = cx.update(|app| book.read(app).core.active);
assert_eq!(active, Some(first), "the jump shows its workbook");
update(cx, &book, |book, cx| book.refill(cx));
let named = cx.update(|app| book.read(app).bars.name.read(app).text().to_string());
let address = cx.update(|app| {
book.read(app).core.tabs[0]
.view
.read(app)
.cell()
.address
.clone()
});
assert_eq!(named, address, "the name box shows the target");
cx.update(|app| {
let tab = &book.read(app).core.tabs[0];
(
tab.extra.sheets.current,
tab.view.read(app).cell().address.clone(),
)
})
};
pick(&mut cx, "Local", "Other");
settle(&cx, |app| {
book.read(app).core.tabs[0].extra.sheets.current == 1
});
let (part, address) = shown(&mut cx);
assert!(
part == 1 && (address == "B2" || address.starts_with("Local")),
"{part} {address}"
);
cx.update(|app| {
book.update(app, |book, cx| {
book.names_in(first, cx);
})
});
settle(&cx, |app| {
matches!(book.read(app).dialog, Some(Dialog::Names { .. }))
});
update(&mut cx, &book, |book, _| {
book.core.active = Some(book.core.tabs[1].id)
});
pick(&mut cx, "Spot", "");
settle(&cx, |app| {
book.read(app).core.tabs[0].extra.sheets.current == 0
});
let (part, address) = shown(&mut cx);
assert!(
part == 0 && (address == "A5" || address == "Spot"),
"{part} {address}"
);
}
#[test]
fn a_waiting_definition_keeps_its_workbook_sheets() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
for _ in 0..2 {
update(&mut cx, &book, |book, cx| book.new_blank(cx));
}
settle(&cx, |app| {
let tabs = &book.read(app).core.tabs;
tabs.len() == 2 && tabs.iter().all(|tab| !tab.extra.sheets.list.is_empty())
});
let (first, second) = cx.update(|app| {
let tabs = &book.read(app).core.tabs;
(tabs[0].id, tabs[1].id)
});
for (id, name) in [(first, "Origin"), (second, "Destination")] {
update(&mut cx, &book, |book, cx| {
book.core.active = Some(id);
book.run_active("sheet.rename", &[name, "1"], cx);
});
}
settle(&cx, |app| {
book.read(app)
.core
.tabs
.iter()
.zip(["Origin", "Destination"])
.all(|(tab, name)| {
tab.extra
.sheets
.list
.first()
.is_some_and(|sheet| sheet.name == name)
})
});
cx.update(|app| {
book.update(app, |book, cx| {
book.core.active = Some(first);
book.run_active("cell.goto", &["B2:C3"], cx);
book.ask_define(cx);
})
});
settle(&cx, |app| {
matches!(book.read(app).dialog, Some(Dialog::Define { .. }))
});
update(&mut cx, &book, |book, _| book.core.active = Some(second));
let scopes = cx.update(|app| book.read(app).define_scopes());
assert_eq!(
scopes,
Some(vec!["Origin".to_string()]),
"the asking workbook's sheets"
);
update(&mut cx, &book, |book, cx| {
if let Some(Dialog::Define { name, scope, .. }) = &mut book.dialog {
*scope = "Origin".into();
name.update(cx, |name, cx| name.set_text("Area", cx));
}
book.define(cx);
book.core.active = Some(first);
});
settle(&cx, |app| book.read(app).dialog.is_none());
quiet(&cx, &book);
let defined = cx.update(|app| names(app, &book));
assert!(
defined
.iter()
.any(|name| name.name == "Area" && name.scope == "Origin"),
"{defined:?}"
);
}
#[test]
fn a_rename_stays_with_its_workbook() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
for _ in 0..2 {
update(&mut cx, &book, |book, cx| book.new_blank(cx));
}
settle(&cx, |app| {
let tabs = &book.read(app).core.tabs;
tabs.len() == 2 && tabs.iter().all(|tab| !tab.extra.sheets.list.is_empty())
});
let (first, second) = cx.update(|app| {
let tabs = &book.read(app).core.tabs;
(tabs[0].id, tabs[1].id)
});
let before = cx.update(|app| {
book.read(app).core.tabs[1].extra.sheets.list[0]
.name
.clone()
});
cx.update_window(window.into(), |_, window, app| {
book.update(app, |book, cx| {
book.core.active = Some(first);
book.ask_rename(window, cx);
book.core.active = Some(second);
book.rename_asked("Kept", cx);
})
})
.expect("the window lives");
settle(&cx, |app| {
book.read(app).core.tabs[0].extra.sheets.list[0].name == "Kept"
});
assert_eq!(
cx.update(|app| book.read(app).core.tabs[1].extra.sheets.list[0]
.name
.clone()),
before,
"the other workbook keeps its sheet name"
);
}
+46
View File
@@ -0,0 +1,46 @@
//! Sheet numbers hold only in the state they were read.
use super::*;
#[test]
fn a_rename_asked_before_a_delete_renames_nothing() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &book);
for (name, at) in [("B", "2"), ("C", "3")] {
fresh(&cx, &book);
update(&mut cx, &book, |book, cx| book.run_active("sheet.insert", &[name, at], cx));
}
settle(&cx, |app| sheet_names(app, &book).len() == 3);
fresh(&cx, &book);
update(&mut cx, &book, |book, cx| book.show_sheet(1, cx));
cx.update_window(window.into(), |_, window, app| book.update(app, |book, cx| book.ask_rename(window, cx)))
.expect("the window lives");
// The first sheet goes while the dialog is open.
update(&mut cx, &book, |book, cx| book.run_active("sheet.delete", &["1"], cx));
settle(&cx, |app| sheet_names(app, &book) == ["B", "C"]);
fresh(&cx, &book);
update(&mut cx, &book, |book, cx| book.rename_asked("Renamed B", cx));
fresh(&cx, &book);
assert_eq!(cx.update(|app| sheet_names(app, &book)), ["B", "C"], "C keeps its name");
}
#[test]
fn a_failed_command_still_moves_the_state_on() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &book);
// No merged cell to split: the kernel refuses.
update(&mut cx, &book, |book, cx| book.run_active("cells.split", &[], cx));
fresh(&cx, &book);
update(&mut cx, &book, |book, cx| book.run_active("sheet.rename", &["Plan", "1"], cx));
settle(&cx, |app| sheet_names(app, &book) == ["Plan"]);
}
+379
View File
@@ -0,0 +1,379 @@
use ely_gpui_component::forms::TextInput;
use ely_lok::sheet::{self, Scale, csv::Column};
use super::data::{asked, cells, quiet, run};
use super::*;
use crate::output::Format;
/// The active tab's kernel read, after its input.
fn read<T>(
cx: &mut TestAppContext,
book: &Entity<Book>,
parse: fn(&str) -> std::io::Result<T>,
command: &str,
) -> T {
quiet(cx, book);
cx.update(|app| {
let book = book.read(app);
let tab = book.active_tab().expect("a tab");
let mut worker = book.core.worker.lock().expect("the worker lock");
parse(&worker.values(&tab.doc, command).expect("the read")).expect("parses")
})
}
fn type_in(
cx: &mut TestAppContext,
book: &Entity<Book>,
field: fn(&Dialog) -> &Entity<TextInput>,
text: &str,
) {
cx.update(|app| {
let input = field(book.read(app).dialog.as_ref().expect("a dialog")).clone();
input.update(app, |input, cx| input.set_text(text, cx));
});
}
#[test]
fn a_csv_opens_through_its_choices_and_keeps_codes() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let path = dir.path().join("accounts.txt");
let mut text = "Account;Amount\n".to_string();
text.push_str(&"123;2\n".repeat(18_000));
text.push_str("007;1.5\n");
std::fs::write(&path, text).expect("writes");
update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx));
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading));
cx.update(|app| match book.read(app).dialog.as_ref() {
Some(Dialog::Import(pending)) => {
assert_eq!(pending.how.separator, ';', "the guessed separator");
assert_eq!(pending.how.columns, [Column::Text, Column::Standard]);
}
_ => unreachable!("an import dialog"),
});
update(&mut cx, &book, |book, cx| book.import_asked(cx));
settle(&cx, |app| {
book.read(app).active_tab().is_some_and(|tab| tab.name == "accounts.txt")
});
assert_eq!(cells(&mut cx, &book, "A18002"), "007", "the zero stays");
assert_eq!(cells(&mut cx, &book, "B18002"), "1.5");
let saved = dir.path().join("accounts.xlsx");
let id = cx.update(|app| book.read(app).core.tabs[0].id);
cx.update_window(window.into(), |_, window, app| {
book.update(app, |book, cx| book.write_as(id, saved.clone(), None, window, cx))
})
.expect("the window lives");
settle(&cx, |app| book.read(app).core.tabs[0].name == "accounts.xlsx");
update(&mut cx, &book, |book, cx| {
book.close_tab(id, cx);
book.open_file(saved.clone(), cx);
});
settle(&cx, |app| sheet_names(app, &book).len() == 1);
assert_eq!(cells(&mut cx, &book, "A18002"), "007", "XLSX keeps it");
}
#[test]
fn a_wrong_encoding_blocks_the_import() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let path = dir.path().join("latin.csv");
std::fs::write(&path, b"Name\nCaf\xe9\n").expect("writes");
update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx));
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading));
update(&mut cx, &book, |book, cx| {
if let Some(Dialog::Import(pending)) = &mut book.dialog {
pending.how.encoding = ely_lok::sheet::csv::Encoding::Utf8;
}
book.scan_import(cx);
});
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading));
update(&mut cx, &book, |book, cx| book.import_asked(cx));
assert!(cx.update(|app| book.read(app).dialog.is_some()), "broken bytes stay out");
assert!(cx.update(|app| book.read(app).core.loading.is_empty()));
}
#[test]
fn exports_write_the_shown_sheet_as_text() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &book);
run(&mut cx, &book, "cell.enter", &["=1+2"]);
run(&mut cx, &book, "cell.goto", &["B1"]);
run(&mut cx, &book, "cell.enter", &["a;b"]);
let id = cx.update(|app| book.read(app).core.tabs[0].id);
for (format, name, text) in [
(Format::Csv, "out.csv", "3,a;b\n"),
(Format::Tsv, "out.tsv", "3\ta;b\n"),
] {
let path = dir.path().join(name);
update(&mut cx, &book, |book, cx| book.export_to(id, path.clone(), format, cx));
settle(&cx, |_| path.exists());
quiet(&cx, &book);
assert_eq!(std::fs::read_to_string(&path).expect("reads"), text);
}
let pdf = dir.path().join("out.pdf");
update(&mut cx, &book, |book, cx| book.export_to(id, pdf.clone(), Format::Pdf, cx));
settle(&cx, |_| pdf.exists());
quiet(&cx, &book);
assert!(std::fs::read(&pdf).expect("reads").starts_with(b"%PDF"));
}
fn area(dialog: &Dialog) -> &Entity<TextInput> {
match dialog {
Dialog::Page { fields, .. } => &fields.area,
_ => unreachable!("a page dialog"),
}
}
fn rows(dialog: &Dialog) -> &Entity<TextInput> {
match dialog {
Dialog::Page { fields, .. } => &fields.rows,
_ => unreachable!("a page dialog"),
}
}
#[test]
fn page_setup_applies_titles_area_and_page() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &book);
update(&mut cx, &book, |book, cx| book.ask_page(cx));
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Page { .. }));
type_in(&mut cx, &book, area, "A1:");
update(&mut cx, &book, |book, cx| book.page_asked(cx));
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Page { .. }));
type_in(&mut cx, &book, area, "a1:c4");
type_in(&mut cx, &book, rows, "1");
update(&mut cx, &book, |book, cx| {
if let Some(Dialog::Page {
landscape, scale, ..
}) = &mut book.dialog
{
(*landscape, *scale) = (true, Scale::Fit(1, 0));
}
book.page_asked(cx)
});
let layout = read(&mut cx, &book, sheet::print_layout, sheet::LAYOUT);
assert_eq!(layout.areas, ["$A$1:$C$4"]);
assert_eq!(layout.rows, Some((1, 1)));
assert_eq!((layout.landscape, layout.scale), (true, Scale::Fit(1, 0)));
}
fn password(dialog: &Dialog) -> &Entity<TextInput> {
match dialog {
Dialog::Protect { protecting, .. } => &protecting.password,
_ => unreachable!("a protect dialog"),
}
}
fn confirm(dialog: &Dialog) -> &Entity<TextInput> {
match dialog {
Dialog::Protect { protecting, .. } => &protecting.confirm,
_ => unreachable!("a protect dialog"),
}
}
fn protected(cx: &mut TestAppContext, book: &Entity<Book>) -> bool {
read(cx, book, sheet::protection, sheet::PROTECTION).protected
}
#[test]
fn a_wrong_password_leaves_the_sheet_protected() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &book);
let ask = |cx: &mut TestAppContext| {
update(cx, &book, |book, cx| book.ask_protect(cx));
asked(cx, &book, |dialog| matches!(dialog, Dialog::Protect { .. }));
};
ask(&mut cx);
type_in(&mut cx, &book, password, "pw");
type_in(&mut cx, &book, confirm, "px");
update(&mut cx, &book, |book, cx| book.protect_asked(cx));
assert!(cx.update(|app| book.read(app).dialog.is_some()), "a mismatch stays");
type_in(&mut cx, &book, confirm, "pw");
update(&mut cx, &book, |book, cx| book.protect_asked(cx));
assert!(protected(&mut cx, &book));
ask(&mut cx);
type_in(&mut cx, &book, password, "no");
update(&mut cx, &book, |book, cx| book.protect_asked(cx));
cx.run_until_parked();
assert!(protected(&mut cx, &book), "still protected");
ask(&mut cx);
type_in(&mut cx, &book, password, "pw");
update(&mut cx, &book, |book, cx| book.protect_asked(cx));
cx.run_until_parked();
assert!(!protected(&mut cx, &book));
}
#[test]
fn a_new_link_names_its_unrefreshed_source() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let source = dir.path().join("source.ods");
blank(&mut cx, &book);
run(&mut cx, &book, "cell.enter", &["7"]);
let name = cx.update(|app| sheet_names(app, &book)[0].clone());
cx.update(|app| {
let book = book.read(app);
let tab = book.active_tab().expect("a tab");
let mut worker = book.core.worker.lock().expect("the worker lock");
tab.doc
.save_as(&mut worker, &ely_lok::file_url(&source), "ods")
.expect("the source");
});
settle(&cx, |app| book.read(app).active_tab().is_some_and(|tab| tab.extra.linked.is_some()));
run(&mut cx, &book, "cell.goto", &["B1"]);
let formula = format!("='{}/[source.ods]{name}'!A1", dir.path().display());
run(&mut cx, &book, "cell.enter", &[&formula]);
let url = ely_lok::file_url(&source);
settle(&cx, |app| {
book.read(app)
.active_tab()
.is_some_and(|tab| tab.extra.links == [url.clone()])
});
}
#[test]
fn the_checked_bytes_import_even_if_the_file_changes() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let path = dir.path().join("codes.csv");
std::fs::write(&path, "Account\n00123\n").expect("writes");
update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx));
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading));
std::fs::write(&path, b"Account\n999\nA\xffB\n").expect("rewrites");
update(&mut cx, &book, |book, cx| book.import_asked(cx));
settle(&cx, |app| book.read(app).active_tab().is_some());
assert_eq!(cells(&mut cx, &book, "A2"), "00123", "what was checked");
}
#[test]
fn a_closed_dialogs_scan_never_lands_in_the_next() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let pending = |name: &str, text: &str| {
Box::new(crate::text::Pending::new(dir.path().join(name), text.into(), false))
};
// Either order: one of them lands the old scan last.
for old_first in [true, false] {
let (old, next) = (pending("old.csv", "Old\n00123\n"), pending("next.csv", "Next,Amount\n1,2\n"));
update(&mut cx, &book, |book, cx| {
let (first, second) = match old_first {
true => (Dialog::Import(old), Dialog::Import(next)),
false => (Dialog::Import(next), Dialog::Import(old)),
};
book.dialog = Some(first);
book.scan_import(cx);
let first = book.dialog.replace(second);
book.scan_import(cx);
if !old_first {
book.dialog = first;
}
});
cx.run_until_parked();
cx.update(|app| match book.read(app).dialog.as_ref() {
Some(Dialog::Import(pending)) => {
assert_eq!(pending.rows[0], ["Next", "Amount"], "old first {old_first}");
assert_eq!(pending.how.columns, [Column::Standard, Column::Standard]);
}
_ => unreachable!("an import dialog"),
});
}
}
#[test]
fn the_preview_reads_what_the_kernel_imports() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let path = dir.path().join("odd.csv");
let text = "Id,Code\n \"00123\",1\n\ny,-12345678901234567890\n";
std::fs::write(&path, text).expect("writes");
update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx));
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading));
let preview = cx.update(|app| match book.read(app).dialog.as_ref() {
Some(Dialog::Import(pending)) => pending.rows.clone(),
_ => unreachable!("an import dialog"),
});
update(&mut cx, &book, |book, cx| book.import_asked(cx));
settle(&cx, |app| book.read(app).active_tab().is_some());
// Row 4 lines up only if the empty line counted.
for (at, row) in preview.iter().enumerate().filter(|(_, row)| row.len() == 2) {
for (column, value) in ["A", "B"].iter().zip(row) {
let cell = format!("{column}{}", at + 1);
assert_eq!(&cells(&mut cx, &book, &cell), value, "{cell} as previewed");
}
}
assert_eq!(preview[3][1], "-12345678901234567890");
}
#[test]
fn text_files_wait_their_turn_for_the_import_dialog() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let paths = ["a.csv", "b.csv"].map(|name| dir.path().join(name));
for (path, text) in paths.iter().zip(["first\n", "second\n"]) {
std::fs::write(path, text).expect("writes");
}
// Another dialog is open; neither import may replace it.
update(&mut cx, &book, |book, cx| {
book.dialog = Some(Dialog::Merge { tab: 0 });
for path in &paths {
book.ask_import(path.clone(), cx);
}
});
settle(&cx, |app| book.read(app).imports.len() == 2);
cx.update(|app| assert!(matches!(book.read(app).dialog, Some(Dialog::Merge { .. }))));
update(&mut cx, &book, |book, cx| {
book.dialog = None;
cx.notify();
});
for opened in 1..=2 {
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading));
update(&mut cx, &book, |book, cx| book.import_asked(cx));
settle(&cx, |app| book.read(app).core.tabs.len() == opened);
}
let mut names = cx.update(|app| book.read(app).core.tabs.iter().map(|tab| tab.name.clone()).collect::<Vec<_>>());
names.sort();
assert_eq!(names, ["a.csv", "b.csv"], "both files open");
}
+194
View File
@@ -0,0 +1,194 @@
use ely_lok::sheet::Validation;
use super::data::{open, quiet, run};
use super::*;
/// The active workbook's cached rule for its current cell.
fn rule(app: &gpui::App, book: &Entity<Book>) -> Option<(String, Validation)> {
let tab = book.read(app).active_tab()?;
let (spot, rule) = tab.extra.rule.as_ref()?;
Some((spot.1.clone(), rule.clone()))
}
#[test]
fn a_dialog_asked_from_a_workbook_left_behind_is_dropped() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
open(&mut cx, &book);
update(&mut cx, &book, |book, cx| book.new_blank(cx));
settle(&cx, |app| {
let tabs = &book.read(app).core.tabs;
tabs.len() == 2 && tabs.iter().all(|tab| !tab.extra.sheets.list.is_empty())
});
let (first, second) = cx.update(|app| {
let tabs = &book.read(app).core.tabs;
(tabs[0].id, tabs[1].id)
});
type Ask = fn(&mut Book, &mut gpui::Window, &mut gpui::Context<Book>);
let asks: [Ask; 5] = [
|book, _, cx| book.ask_rule(cx),
|book, _, cx| book.ask_columns(cx),
|book, _, cx| book.ask_formats(cx),
|book, _, cx| book.ask(crate::analysis::Asked::Chart, cx),
|book, _, cx| book.ask(crate::analysis::Asked::Pivot, cx),
];
for ask in asks {
update(&mut cx, &book, |book, cx| {
book.core.active = Some(first);
book.run_active("cell.goto", &["B2"], cx);
});
quiet(&cx, &book);
let worker = cx.update(|app| book.read(app).core.worker.clone());
// The read lands only after the switch.
let held = worker.lock().expect("the worker lock");
cx.update_window(window.into(), |_, window, app| {
book.update(app, |book, cx| {
book.dialog = None;
ask(book, window, cx);
book.core.active = Some(second);
})
})
.expect("the window lives");
drop(held);
for _ in 0..200 {
cx.executor().tick();
}
quiet(&cx, &book);
assert!(
cx.update(|app| book.read(app).dialog.is_none()),
"no dialog for a workbook left behind"
);
}
}
#[test]
fn rules_set_in_place_reach_the_cell_and_clear() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
open(&mut cx, &book);
run(&mut cx, &book, "cell.goto", &["E2"]);
settle(&cx, |app| {
rule(app, &book).is_some_and(|(at, rule)| at == "E2" && rule.kind == "any")
});
let list = Validation {
kind: "list".into(),
operator: "equal".into(),
list: vec!["Red".into()],
dropdown: true,
show_input: true,
input_message: "A color".into(),
error_style: "stop".into(),
..Validation::default()
};
run(&mut cx, &book, "rules.validation", &[&list.to_json()]);
settle(&cx, |app| {
rule(app, &book).is_some_and(|(_, rule)| rule.kind == "list")
});
update(&mut cx, &book, |book, cx| book.clear_rule(cx));
quiet(&cx, &book);
settle(&cx, |app| {
rule(app, &book).is_some_and(|(_, rule)| rule.kind == "any" && !rule.show_input)
});
}
#[test]
fn the_panel_follows_a_sheet_switch_at_one_address() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
open(&mut cx, &book);
run(&mut cx, &book, "sheet.insert", &["T", "2"]);
settle(&cx, |app| sheet_names(app, &book) == ["S", "T"]);
update(&mut cx, &book, |book, cx| {
let tab = book.active_tab().expect("a tab");
let (view, doc) = (tab.view.clone(), tab.doc.clone());
book.panel
.update(cx, |panel, cx| panel.show(Some((&view, &doc)), cx));
});
let kind = |app: &gpui::App| {
let number = book.read(app).panel.read(app).number.clone();
number.map(|(_, kind)| kind)
};
for (sheet, command, want) in [
(0, "number.percent", "percent"),
(1, "number.currency", "currency"),
] {
update(&mut cx, &book, |book, cx| book.show_sheet(sheet, cx));
run(&mut cx, &book, "cell.goto", &["A1"]);
run(&mut cx, &book, command, &[]);
settle(&cx, |app| kind(app).as_deref() == Some(want));
}
update(&mut cx, &book, |book, cx| book.show_sheet(0, cx));
run(&mut cx, &book, "cell.goto", &["A1"]);
settle(&cx, |app| kind(app).as_deref() == Some("percent"));
}
#[test]
fn a_format_code_lands_in_the_workbook_that_asked() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
open(&mut cx, &book);
update(&mut cx, &book, |book, cx| book.new_blank(cx));
settle(&cx, |app| {
let tabs = &book.read(app).core.tabs;
tabs.len() == 2 && tabs.iter().all(|tab| !tab.extra.sheets.list.is_empty())
});
let (first, second) = cx.update(|app| {
let tabs = &book.read(app).core.tabs;
(tabs[0].id, tabs[1].id)
});
let code = |cx: &mut TestAppContext, id: u64| {
cx.update(|app| {
let book = book.read(app);
let tab = book.tab(id).expect("a tab");
let mut worker = book.core.worker.lock().expect("the worker lock");
worker.run(&tab.doc, "cell.goto", &["A1"]).expect("A1");
let json = worker
.values(&tab.doc, ely_lok::sheet::NUMBER)
.expect("the format");
ely_lok::sheet::number_format(&json).expect("parses").0
})
};
let (panel, view, doc) = cx.update(|app| {
book.update(app, |book, _| book.core.active = Some(first));
let book = book.read(app);
let tab = book.tab(first).expect("a tab");
(book.panel.clone(), tab.view.clone(), tab.doc.clone())
});
cx.update(|app| {
panel.update(app, |panel, cx| {
panel.show(Some((&view, &doc)), cx);
panel.apply_code("0.000", cx);
})
});
// The check is still on its way.
update(&mut cx, &book, |book, _| book.core.active = Some(second));
settle(&cx, |app| {
book.read(app)
.core
.tabs
.iter()
.all(|tab| tab.view.read(app).settled())
});
quiet(&cx, &book);
for _ in 0..200 {
cx.executor().tick();
}
assert_eq!(code(&mut cx, first), "0.000", "the asking workbook");
assert_ne!(code(&mut cx, second), "0.000", "not the active one");
}