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:
817 files changed
+143101
No files matched your search
@@ -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}");
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user