Files
gridcraft/crates/ui-egui/tests/it/interaction.rs
T
PranavandFredApps 3379f12a56 Data: Solver Sensitivity report (#501)
* Data: Solver Sensitivity report

Builds on the Answer and Limits reports. reports: [sensitivity] (on
data.solver and data.solverReport) writes a Sensitivity report for
models solved by Simplex LP or GRG Nonlinear without integer
constraints and with a Max or Min objective.

Simplex LP: the model is linearised at the solution and solved again,
and the report is read from the final tableau: each variable's reduced
cost and the range of its objective coefficient over which the basis
stays optimal, and each constraint's shadow price and the range of its
right side over which that price holds (1E+30 for no limit).

GRG Nonlinear: difference gradients at the solution give each binding
constraint's Lagrange multiplier (a least-squares fit on the variables
inside their bounds, dropping constraints whose multiplier has the
wrong sign) and each variable's reduced gradient.

The Solver Results dialog offers the report.

* Data: Solver Results offers the Sensitivity report only where it applies

Integer and binary models, the Evolutionary method and Value Of goals
have no Sensitivity report, and choosing it with other reports made the
whole report request fail. Solver Results now disables the Sensitivity
choice for such models (with the reason on hover) and leaves it out of
the request, so the other chosen reports are still written.

sensitivity_applies (in cmd::solver) reads the model from data.solver
params, as the dialog holds them; a test checks that it agrees with the
report for each kind of model.

* Data: Solver bounds the nonlinear Sensitivity report's work

The Lagrange multipliers of a GRG Nonlinear model are found by least
squares over the binding constraints; a constraint whose multiplier has
the wrong sign is dropped and the rest are solved again. Each pass costs
about 2·k²·f operations for k binding constraints and f free variables,
and each pass drops at most one constraint, so a model at the
1,000-constraint limit could take minutes and hold the window.

The passes now share a limit of about 10⁹ operations (about a second).
Past it the report is refused with a message, before anything changes:
the workbook and the Undo history stay as they were, as for the other
report refusals. The limit is lowered in tests so the refusal can be
reached with a small model.

* Data: Solver refuses a Simplex Sensitivity report for a point that is not optimal

The Simplex LP Sensitivity report solves the linearised model again to
read its final tableau, but shows the final values it was given.
data.solverReport accepts any values, so a report could pair values that
are not optimal (or not feasible) with the shadow prices, reduced costs
and ranges of the true optimum. The report now checks that the given
point satisfies every constraint and bound and reaches the optimal
objective (both within the constraint precision; another optimal point
with the same objective is accepted) and is refused otherwise, before
anything changes.

* Data: Solver compares the Sensitivity report's objective in both directions

The check that a point is optimal compared its objective with the
optimum in one direction only, as for an inequality. A point just
outside a bound (within the constraint precision) can still have an
objective far below the minimum when its costs are large, and that
point was accepted. The objective is now compared by the size of the
difference, so such a point is refused.

* Data: Solver refuses a nonlinear Sensitivity report for a point that is not optimal

data.solverReport accepts any finite variable values. The Simplex LP
report already refuses values that are not optimal, but the GRG
Nonlinear report described whatever point it was given: for example, a
minimisation of A1^2 given A1 = 1 found no binding rows and reported a
reduced gradient of 2 instead of requiring the optimum at A1 = 0.

The nonlinear report now checks, from the gradients it already
computes, that the values are a Karush-Kuhn-Tucker point before
anything changes: every constraint holds within the tolerance the
nonlinear method accepts a solution within, every bound holds within
the constraint precision, each variable inside its bounds has a reduced
gradient near zero, and a variable at a bound has a reduced gradient
that points only out of that bound for the objective's direction. A
binding row whose multiplier has the wrong sign is still dropped, and
the gradient it would have explained now causes the refusal. The
stationarity tolerance is 1E-3 of the largest objective-gradient
component (at least 1), well above the reduced gradients left at optima
the method solves.

* Data: Solver writes the other reports when the Sensitivity report does not apply to the solution

Since the nonlinear Sensitivity report refuses values that are not
optimal, data.solver with reports that include sensitivity failed as a
whole when the run stopped before an optimum (an evaluation limit) or
the values were not one, so the solution and the Answer and Limits
reports were lost. Solver Results also offered Sensitivity after any
solve, and ticking it with Answer after such a run wrote no report.

data.solver now writes the Sensitivity report only when the run reached
an optimal or converged solution that the report accepts. Otherwise the
solve still applies, the other reports are written, and the result
lists the report in skippedReports with the reason. Models that have no
Sensitivity report (integer, Evolutionary, Value Of) are refused as
before. data.solverReport still refuses values that are not optimal;
its new skipNotOptimal parameter leaves the report out instead. Solver
Results offers Sensitivity only after an optimal or converged status,
with the reason on hover, passes skipNotOptimal, and shows the reason
when the report is left out.

* Data: Solver skips a Sensitivity report in a protected workbook without refusing the call

Structure protection is now checked only when a report sheet will be written. A data.solverReport request with skipNotOptimal whose only report is skipped changes nothing, so it succeeds in a workbook whose structure is protected and returns skippedReports; a request that would write a sheet is still refused.

* Data: Solver refuses report sheets in a protected workbook before building them

A request that must write a report sheet (any Answer or Limits report, or a Sensitivity report that is not allowed to be skipped) is refused before any report is built, so a protected workbook does not run the Limits report's optimisations first. The check after the reports are built remains for a Sensitivity report that may be skipped.

* Data: Solver reports Simplex sensitivity only at the vertex it describes

The Simplex LP Sensitivity report solves the linearised model again and
takes its prices and ranges from that final tableau, but it discarded
the vertex the solve returned and showed the values it was given. When
the optimum is not unique, another optimal point passed the optimality
check, so the report paired those values with the ranges of a different
vertex. The report now also requires the given values to match the
re-solved vertex within the constraint precision, and refuses another
optimum with a message that says so. A Sensitivity report that data.solver
or Solver Results may skip is left out instead, as for other points the
report does not support. Values from a Simplex LP solve still match, so
the usual Solver Results flow is unchanged.

* Data: Solver gives a redundant constraint's right side no allowable change

The Simplex LP Sensitivity report ranges each right side over the basic
values of the real columns only. A row that the other rows make
redundant (max x with x = 2 and 2x = 4) keeps its artificial column
basic at zero after phase one, and changing either right side alone
moves that artificial, so no change has a solution. The report showed
1E+30 and 2 for the first row instead. When a basic artificial has a
nonzero entry in the column of the right side being changed, both
allowable changes are now 0. The objective-coefficient ranges are
unaffected: such a row has no cost and no entries in the real columns,
and a cost change does not affect feasibility.

* Data: Solver builds the Simplex Sensitivity report's program at the solve's start values

The Simplex LP solve linearises the model at the start values, but the
Sensitivity report linearised it again at the solution. The difference
coefficients then differ in their last bits, and when the optimum is
not unique that can break a tie between pivots the other way: the
report's solve returned the other vertex and refused the solve's own
values. With max 0.1x + 0.1y over a tied edge, more than half of the
sampled starting points were refused.

The solve and the report now build the program with one function,
simplex_program, and the report builds it from the start values it is
given (data.solver's start values, which Solver Results passes back as
original), so its solve repeats the solve's pivots and returns the same
vertex. Start values at which the model cannot be evaluated fall back
to the solution, and values that are not the solve's are still refused
by the vertex check.

* Data: Solver's Sensitivity report sets its noise levels from the coefficients, not the objective's value

The figures a Sensitivity report prints as 0 were those below 1E-9 of the objective's value (1E-6 for the nonlinear figures), so a constant in the objective hid real prices: max 1E9 + x with x <= 10 wrote the row's shadow price, exactly 1, as 0, although the constant changes nothing about the optimisation. A shadow price and a reduced cost are combinations of the objective coefficients, so Simplex LP now takes the noise level from the largest coefficient; a reduced gradient is a part of the objective's gradient and a Lagrange multiplier objective per unit of its row's gradient, so GRG Nonlinear takes it from the gradient's size, and the multipliers' sign test compares lambda times the row's gradient size with it. Allowable changes keep the tableau's pivot tolerance as before.

* Data: Solver Sensitivity report tests for variables at a bound and for both goals

Hand-derived cases pin the report's conventions. Simplex LP: a maximum's variable at its upper bound (reduced cost 1, Allowable Increase 1E+30, Allowable Decrease 1), a minimum with a >= row and a variable at its upper bound (reduced cost -1, Allowable Increase 1, Allowable Decrease 1E+30), and Winston's Dakota Furniture problem, whose variable at zero has reduced cost -5 with Allowable Increase 5 and Allowable Decrease 1E+30, with every shadow price and allowable range worked out from the final basis. GRG Nonlinear: the Lagrange multiplier is the derivative of the optimal objective with respect to the right side and the reduced gradient that with respect to the variable's bound for a minimum (-1) and the negated maximum (+1), and the mirror image of the existing minimum with a variable at its bound (multiplier +2, reduced gradient -4).

* Docs: ROADMAP.md records the Solver Sensitivity report

The detail documents (docs/gaps.md, docs/target-app-parity.md) record the report, but the roadmap summary did not: its header change note, progress log and revision history now carry it.

* Data: Solver's Sensitivity report bounds each figure's rounding noise by its own terms, not the model's largest coefficient or gradient

The noise level a figure had to exceed to print was 1E-9 of the largest objective coefficient (Simplex LP) or 1E-6 of the largest gradient component (GRG Nonlinear) for every figure, so an unrelated large coefficient hid real values: max 1E9*x + y with x <= 1 and y <= 1 as rows wrote the second row's shadow price, exactly 1, as 0, and the same model's Lagrange multiplier 1 and a third variable's reduced cost or gradient -1 went the same way. The rounding error of a sum scales with the sum of its terms' sizes, so each figure now has its own bound from the terms it is made of. Simplex LP: a shadow price y_i = sum over the basis rows of c_B(r) * Binv(r, i) is bounded by 1E-9 of those terms' sizes (the row's unit column of the final tableau holds Binv's column i); a reduced cost cf_j - sum of a_ij * y_i by 1E-9 of |cf_j| plus |a_ij| times each price's term sizes, so a price that is a small difference of large terms carries their error into the reduced costs it is used in. GRG Nonlinear: a multiplier solved from its row of the normal equations is bounded by 1E-6 of its own terms (the row's gradient times the objective's, and its coupling terms with the other active rows times their multipliers) over the row's squared norm; the multipliers' sign test uses the same bound, so a row leaves the active set on the criterion the report rounds with; a reduced gradient is bounded by 1E-6 of its own component and the multipliers' terms plus the multipliers' bounds times |g_ij|. Nearly parallel active gradients amplify every multiplier's error beyond a bound local to a row; such multipliers print as computed. A constant in the objective still changes no bound, since none depends on the objective's value. The nonlinear method stops on the 1E9 model from (0, 0) with "unbounded", so its two regression tests ask for the report at the optimum through data.solverReport.

* Data: Solver's Sensitivity report tests a nonlinear point's optimality by each variable's and row's own scale, not the model's largest

The Karush-Kuhn-Tucker check that refuses a Sensitivity report for a point that is not optimal accepted a reduced gradient within 1E-3 of the largest gradient component, and a row violation within ten times the constraint precision of the largest constant right side. Both scales were the model's, not the figure's: on max 1E9*x + y with x <= 1 and y <= 1 as rows the tolerance was 1E6, so (1, 0.5), where raising y gains 1 per unit, passed as optimal and the report was written for it; on max y - (x - 2)^2 with x <= 1E9 and y <= 1, (2, 1.0001) passed although the second row is over by 1E-4 against its own precision of 2E-5. Each reduced gradient is now tested against 1E-3 of the terms it is made of (the objective's component and the multipliers' parts, at least 1, the same floor as before), and each row against ten times the constraint precision of its own right side. The nonlinear method itself accepts a solution relative to the largest constant right side; a row over by more than its own precision is not satisfied whatever the other rows' sizes, so the report refuses it. The term sizes are shared with the reduced gradients' rounding bound. Two regression tests, each proving the workbook is left as it was and that the optimum is still reported.

* Data: Solver refuses a Sensitivity report the model has none of before any report is built or the solve runs

The refusals that apply to the model rather than the solution (integer or binary constraints, the Evolutionary method, a Value Of goal) were made inside the report loop, when it reached the Sensitivity report, so a request listing the Limits or Answer report first built those reports for an error: a Value Of model asking for limits and sensitivity ran the Limits report's two optimisations per variable and received only the refusal, and data.solver ran its whole solve before it. prepare_reports now checks the model's refusal before the loop, in both modes (a refusal of the model was already an error in both; only the solution's not-optimal refusal is skipped under skipNotOptimal), and data.solver checks it before the solve. The regression test asks for limits and sensitivity on an integer model, which now gets the Sensitivity refusal instead of the Limits report's, and on a Value Of model, which gets the Sensitivity refusal with no sheet written.

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-02 09:46:01 +01:00

1624 lines
78 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! UI interaction tests: real egui input through the whole app (no GPU needed).
use egui::{Event, Key, Modifiers, PointerButton};
use egui_kittest::kittest::{NodeT as _, Queryable as _};
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_ui_egui::i18n::Language;
use gridcraft_ui_egui::{SheetApp, grid::Geo};
use serde_json::json;
/// Builds the standard interaction harness around the caller's session with four warm-up passes.
fn harness(session: Session) -> egui_kittest::Harness<'static, SheetApp> {
crate::support::harness(session, 4)
}
fn blank() -> Session {
let mut s = Session::new();
s.new_workbook();
s
}
#[test]
fn vertical_scrollbar_pages_and_drags_without_changing_selection() {
let mut h = harness(blank());
let grid = h.state().grid.rect.unwrap();
let cells = h.state().grid.cells_rect.unwrap();
let p = egui::pos2(grid.right() + 7.0, cells.bottom() - 20.0);
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(2);
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert!(h.state().view().scroll.y > 0.0, "clicking the scrollbar track scrolls down");
assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0));
h.state_mut().view_mut().unwrap().scroll.y = 0.0;
h.run_steps(2);
let start = egui::pos2(grid.right() + 7.0, cells.top() + 8.0);
h.input_mut().events.push(Event::PointerMoved(start));
h.input_mut().events.push(Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(2);
let end = start + egui::vec2(0.0, 80.0);
h.input_mut().events.push(Event::PointerMoved(end));
h.run_steps(2);
let dragged = h.state().view().scroll.y;
assert!(dragged > 0.0, "dragging the thumb scrolls down");
h.run_steps(3);
assert_eq!(h.state().view().scroll.y, dragged, "holding the thumb still must not keep scrolling");
h.input_mut().events.push(Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0));
}
/// The first column painted right of the frozen ones.
fn first_scrolled_col(h: &egui_kittest::Harness<'static, SheetApp>) -> u32 {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = app.grid.geo.as_ref().unwrap();
geo.col_at(sheet, geo.cells.left() + geo.frozen_w + 0.5)
}
fn click(h: &mut egui_kittest::Harness<'static, SheetApp>, p: egui::Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(2);
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
}
#[test]
fn horizontal_scrollbar_pages_and_drags_across_a_wide_sheet() {
let mut wb = gridcraft_engine::model::Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
for c in 0..26 {
sh.set_value(CellRef::new(0, c), Value::text(format!("col {}", c + 1)));
}
// Column A frozen: a page is the scrolling part only.
sh.freeze = Some((0, 1));
let mut s = Session::new();
s.add_document(gridcraft_engine::DocState::new(wb, None, "Book1".into(), gridcraft_engine::Trust::Local));
let mut h = harness(s);
let track = h.state().grid.hscroll.expect("the tab bar has a horizontal scrollbar");
assert!(track.width() >= 200.0, "the scrollbar gets a usable share of the tab bar: {track:?}");
assert_eq!(first_scrolled_col(&h), 1);
let page = {
let geo = h.state().grid.geo.as_ref().unwrap();
(geo.cells.width() - geo.frozen_w) / geo.z
};
click(&mut h, egui::pos2(track.right() - 6.0, track.center().y));
let scrolled = h.state().view().scroll.x;
assert!((scrolled - page).abs() < 0.5, "clicking the track right of the thumb scrolls one page: {scrolled} vs {page}");
assert!(first_scrolled_col(&h) > 1);
let geo = h.state().grid.geo.clone().unwrap();
let sheet = h.state().session.active().unwrap().wb.active().unwrap().clone();
assert_eq!(geo.x(&sheet, 0), geo.cells.left(), "the frozen column stays put");
click(&mut h, egui::pos2(track.right() - 6.0, track.center().y));
let further = h.state().view().scroll.x;
click(&mut h, egui::pos2(track.left() + 2.0, track.center().y));
assert!(h.state().view().scroll.x < further, "clicking left of the thumb scrolls back");
h.state_mut().view_mut().unwrap().scroll.x = 0.0;
h.run_steps(2);
let start = egui::pos2(track.left() + 6.0, track.center().y);
h.input_mut().events.push(Event::PointerMoved(start));
h.input_mut().events.push(Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(2);
let end = start + egui::vec2(120.0, 0.0);
h.input_mut().events.push(Event::PointerMoved(end));
h.run_steps(2);
let dragged = h.state().view().scroll.x;
assert!(first_scrolled_col(&h) > 1, "dragging the thumb right changes the first visible column");
h.run_steps(3);
assert_eq!(h.state().view().scroll.x, dragged, "holding the thumb still must not keep scrolling");
h.input_mut().events.push(Event::PointerButton { pos: end, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0));
}
fn key(h: &mut egui_kittest::Harness<'static, SheetApp>, k: Key, m: Modifiers) {
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m });
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m });
h.run_steps(2);
}
fn text(h: &mut egui_kittest::Harness<'static, SheetApp>, t: &str) {
h.input_mut().events.push(Event::Text(t.into()));
h.run_steps(2);
}
fn value(h: &egui_kittest::Harness<'static, SheetApp>, a: &str) -> Value {
h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(CellRef::parse(a).unwrap_or_default()))).unwrap_or_default()
}
fn click_cell(h: &mut egui_kittest::Harness<'static, SheetApp>, cell: &str) {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
let pos = geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).center();
h.input_mut().events.extend([
Event::PointerMoved(pos),
Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
]);
h.run_steps(2);
}
fn click_chart(h: &mut egui_kittest::Harness<'static, SheetApp>, id: u32) {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
let anchor = sheet.charts.iter().find(|chart| chart.id == id).unwrap().anchor;
let pos = egui::pos2(geo.x(sheet, anchor.cell.col) + anchor.dx * geo.z, geo.y(sheet, anchor.cell.row) + anchor.dy * geo.z)
+ egui::vec2(anchor.width, anchor.height) * geo.z / 2.0;
h.input_mut().events.extend([
Event::PointerMoved(pos),
Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
]);
h.run_steps(2);
}
#[test]
fn keyboard_navigation_does_not_replay_the_last_pointer_click() {
let mut h = harness(blank());
click_cell(&mut h, "B2");
for (key_code, modifiers, expected) in [
(Key::Enter, Modifiers::NONE, "B3"),
(Key::Enter, Modifiers::NONE, "B4"),
(Key::Enter, Modifiers::NONE, "B5"),
(Key::Enter, Modifiers::SHIFT, "B4"),
(Key::Tab, Modifiers::NONE, "C4"),
// #911: Enter after a Tab goes back to the column the Tab started from.
(Key::Enter, Modifiers::NONE, "B5"),
] {
key(&mut h, key_code, modifiers);
let selection = &h.state().session.active().unwrap().selection;
assert_eq!(selection.active.a1(), expected);
assert_eq!(selection.anchor, selection.active);
assert!(h.state().editor.is_none(), "navigation must not start editing at the old pointer location");
}
// A subsequent real pointer click must still select the clicked cell.
click_cell(&mut h, "D6");
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "D6");
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "D7");
}
#[test]
fn space_toggles_the_active_checkbox_without_clicking_the_hovered_cell() {
let mut session = blank();
session.execute("insert.checkbox", json!({"range": "A1:B1"})).unwrap();
let mut h = harness(session);
click_cell(&mut h, "A1");
assert_eq!(value(&h, "A1"), Value::Bool(true));
key(&mut h, Key::ArrowRight, Modifiers::NONE);
key(&mut h, Key::Space, Modifiers::NONE);
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "B1");
assert_eq!(value(&h, "A1"), Value::Bool(true), "the old pointer location must remain unchanged");
assert_eq!(value(&h, "B1"), Value::Bool(true));
assert!(h.state().editor.is_none());
}
#[test]
/// Name Box Enter selects A1; the next grid Enter moves down to A2.
fn name_box_enter_goes_to_the_typed_cell_not_the_one_below() {
// #247: typing A1 in the Name Box and pressing Enter selected A2; the grid replayed the Enter.
let mut h = harness(blank());
h.state_mut().session.run("selection.set", json!({"cell": "C5"})).unwrap();
h.run_steps(2);
h.ctx.memory_mut(|m| m.request_focus(egui::Id::unique("gridcraft.name_box")));
h.run_steps(2);
h.input_mut().events.push(Event::Key { key: Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND });
h.input_mut().events.push(Event::Text("A1".into()));
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
h.run_steps(2);
assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0));
assert!(h.state().name_box.is_none());
// The grid has the focus back: a plain Enter there still moves down.
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(1, 0));
}
#[test]
fn name_box_shows_a_name_defined_on_a_sheet_with_an_apostrophe() {
// #1458: the quotes of `'Bob''s'!$A$1:$B$2` were taken off without reading `''` as `'`.
let mut h = harness(blank());
let session = &mut h.state_mut().session;
session.run("home.insertSheet", json!({"name": "Bob's"})).unwrap();
session.run("sheet.activate", json!({"sheet": "Bob's"})).unwrap();
session.run("formulas.defineName", json!({"name": "Box", "refersTo": "='Bob''s'!$A$1:$B$2"})).unwrap();
session.run("selection.set", json!({"range": "A1:B2"})).unwrap();
h.run_steps(2);
assert_eq!(h.state().name_box_text(), "Box");
}
#[test]
/// Typing and Enter commit 42 in A1; `=A1*2` and Tab commit 84 in A2 and move to B2, with accessibility checks.
fn typing_enters_values_and_moves_down() {
let mut h = harness(blank());
text(&mut h, "42");
assert!(h.state().editor.is_some(), "typing starts editing");
h.check_accessibility();
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(value(&h, "A1"), Value::Number(42.0));
text(&mut h, "=A1*2");
h.check_accessibility();
key(&mut h, Key::Tab, Modifiers::NONE);
assert_eq!(value(&h, "A2"), Value::Number(84.0));
let active = h.state().session.active().map(|d| d.selection.active.a1());
assert_eq!(active.as_deref(), Some("B2"));
}
#[test]
fn enter_after_tabs_returns_to_the_column_where_the_tabs_started() {
// #911: Excel moves to the next row in the column the run of Tabs started from.
let mut h = harness(blank());
for t in ["a", "b"] {
text(&mut h, t);
key(&mut h, Key::Tab, Modifiers::NONE);
}
text(&mut h, "c");
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(value(&h, "C1"), Value::from("c"));
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A2");
// The next Enter is an ordinary one again.
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A3");
}
#[test]
fn moving_with_an_arrow_ends_the_run_of_tabs() {
let mut h = harness(blank());
key(&mut h, Key::Tab, Modifiers::NONE);
key(&mut h, Key::ArrowRight, Modifiers::NONE);
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "C2");
}
#[test]
fn an_arrow_that_commits_typing_ends_the_run_of_tabs() {
// Excel 365 from B2: the arrow is an ordinary move, neither one more Tab (Right) nor an Enter
// back to the starting column (Down).
for (arrow, after_enter) in [(Key::ArrowRight, "D3"), (Key::ArrowDown, "C4")] {
let mut s = blank();
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
let mut h = harness(s);
text(&mut h, "a");
key(&mut h, Key::Tab, Modifiers::NONE);
text(&mut h, "b");
key(&mut h, arrow, Modifiers::NONE);
assert_eq!(value(&h, "C2"), Value::from("b"), "{arrow:?}");
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), after_enter, "{arrow:?}");
}
}
#[test]
fn arrows_escape_and_undo() {
let mut h = harness(blank());
key(&mut h, Key::ArrowDown, Modifiers::NONE);
key(&mut h, Key::ArrowRight, Modifiers::NONE);
assert_eq!(h.state().session.active().map(|d| d.selection.active.a1()).as_deref(), Some("B2"));
text(&mut h, "hello");
key(&mut h, Key::Escape, Modifiers::NONE);
assert_eq!(value(&h, "B2"), Value::Empty);
text(&mut h, "x");
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(value(&h, "B2"), Value::from("x"));
key(&mut h, Key::Z, Modifiers::COMMAND);
assert_eq!(value(&h, "B2"), Value::Empty);
}
#[test]
fn shift_arrows_in_point_mode_extend_from_the_pointed_cell() {
let mut s = blank();
s.execute("selection.set", json!({"cell": "A11"})).unwrap();
let mut h = harness(s);
text(&mut h, "=SUM(");
key(&mut h, Key::ArrowUp, Modifiers::NONE);
let edited = |h: &egui_kittest::Harness<'static, SheetApp>| h.state().editor.as_ref().map(|e| e.text.clone());
assert_eq!(edited(&h).as_deref(), Some("=SUM(A10"));
// Shift is held down, as on a real keyboard: the backend reports it before the arrow keys.
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::SHIFT));
for expected in ["=SUM(A9:A10", "=SUM(A8:A10", "=SUM(A7:A10"] {
key(&mut h, Key::ArrowUp, Modifiers::SHIFT);
assert_eq!(edited(&h).as_deref(), Some(expected));
}
key(&mut h, Key::ArrowDown, Modifiers::SHIFT);
assert_eq!(edited(&h).as_deref(), Some("=SUM(A8:A10"), "Shift+Down shrinks back towards the anchor");
}
#[test]
fn ctrl_arrows_in_point_mode_jump_to_the_edge_of_the_data() {
// Typing `=COUNTIF(`, clicking the top of a column and pressing Ctrl+Shift+Down (⌘⇧↓) points
// at the whole column of data, as the same keys select it outside an edit.
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1:B5", "values": [[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]]})).unwrap();
s.execute("selection.set", json!({"cell": "D1"})).unwrap();
let mut h = harness(s);
text(&mut h, "=COUNTIF(");
click_cell(&mut h, "A1");
let edited = |h: &egui_kittest::Harness<'static, SheetApp>| h.state().editor.as_ref().map(|e| e.text.clone());
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A1"));
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND | Modifiers::SHIFT));
key(&mut h, Key::ArrowDown, Modifiers::COMMAND | Modifiers::SHIFT);
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A1:A5"), "Ctrl+Shift+Down extends to the last filled cell");
key(&mut h, Key::ArrowRight, Modifiers::COMMAND | Modifiers::SHIFT);
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A1:B5"), "Ctrl+Shift+Right extends the moving end across the data");
key(&mut h, Key::ArrowDown, Modifiers::COMMAND | Modifiers::SHIFT);
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A:B"), "past the data, Ctrl+Shift+Down reaches the sheet edge (written as whole columns)");
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND));
key(&mut h, Key::ArrowUp, Modifiers::COMMAND);
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(B5"), "Ctrl+Up without Shift points at the next filled cell");
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
key(&mut h, Key::ArrowUp, Modifiers::NONE);
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(B4"), "a plain arrow still moves one cell");
// An automation tool (osascript `key code`) presses and releases within one frame, so by the
// time the frame runs `i.modifiers` is already empty: the modifiers that count are the ones on
// the press event.
h.input_mut().events.extend([
Event::Key { key: Key::ArrowUp, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND },
Event::ModifiersChanged(Modifiers::NONE),
Event::Key { key: Key::ArrowUp, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE },
]);
h.run_steps(2);
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(B1"), "Command released in the same frame as the press still jumps");
}
#[test]
fn shortcuts_format_and_extend() {
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
let mut h = harness(s);
key(&mut h, Key::ArrowRight, Modifiers::SHIFT);
key(&mut h, Key::ArrowDown, Modifiers::SHIFT);
assert_eq!(h.state().session.active().map(|d| d.selection.a1()).as_deref(), Some("A1:B2"));
key(&mut h, Key::B, Modifiers::COMMAND);
let bold = h.state_mut().session.run("cell.get", json!({"cell": "B2"})).unwrap()["style"]["font"]["bold"].clone();
assert_eq!(bold, json!(true));
key(&mut h, Key::Delete, Modifiers::NONE);
assert_eq!(value(&h, "A1"), Value::Empty);
}
#[test]
fn delete_key_removes_the_selected_chart() {
let mut session = blank();
session.execute("range.setValues", json!({"range": "A1:B2", "values": [[1, 2], [3, 4]]})).unwrap();
let id = session.execute("insert.chart", json!({"range": "A1:B2", "at": "D2"})).unwrap()["chart"].as_u64().unwrap() as u32;
let mut h = harness(session);
click_cell(&mut h, "C10");
click_chart(&mut h, id);
assert_eq!(h.state().selected_chart(), Some(id));
key(&mut h, Key::Delete, Modifiers::NONE);
assert!(h.state().session.active().unwrap().wb.active().unwrap().charts.is_empty());
assert_eq!(h.state().selected_chart(), None);
assert_eq!(value(&h, "A1"), Value::Number(1.0));
}
#[test]
fn inserted_chart_is_selected_so_delete_removes_it() {
// #668, #256: after Insert › Chart the selection stayed on the source cells, so Delete
// cleared the data and left the chart behind.
let mut session = blank();
session.execute("range.setValues", json!({"range": "A1:B2", "values": [[1, 2], [3, 4]]})).unwrap();
session.execute("selection.set", json!({"range": "A1:B2"})).unwrap();
let mut h = harness(session);
let id = h.state_mut().run("insert.chart", json!({"type": "column"})).unwrap()["chart"].as_u64().unwrap() as u32;
h.run_steps(2);
assert_eq!(h.state().selected_chart(), Some(id), "the new chart is selected");
key(&mut h, Key::Delete, Modifiers::NONE);
assert!(h.state().session.active().unwrap().wb.active().unwrap().charts.is_empty());
assert_eq!(value(&h, "A1"), Value::Number(1.0));
}
#[test]
fn insert_column_or_bar_menu_inserts_a_hundred_percent_stacked_bar() {
// Insert › Column or Bar (keytip N, C1) lists the column and bar types of the All Charts list.
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", 10, 30], ["Q2", 30, 10]]})).unwrap();
s.execute("selection.set", json!({"range": "A1:C3"})).unwrap();
let mut h = harness(s);
key(&mut h, Key::F10, Modifiers::NONE);
for (k, t) in [(Key::N, "n"), (Key::C, "c"), (Key::Num1, "1")] {
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.input_mut().events.push(Event::Text(t.into()));
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
}
h.run_steps(2);
for label in ["Clustered Column", "Stacked Column", "100% Stacked Column", "Clustered Bar", "Stacked Bar", "100% Stacked Bar"] {
assert!(h.query_by_role_and_label(egui::accesskit::Role::Button, label).is_some(), "the menu lists {label}");
}
h.get_by_role_and_label(egui::accesskit::Role::Button, "100% Stacked Bar").click();
h.run_steps(2);
let kinds: Vec<_> = h.state().session.active().unwrap().wb.active().unwrap().charts.iter().map(|c| c.kind).collect();
assert_eq!(kinds, [gridcraft_engine::model::ChartKind::BarStacked100]);
}
#[test]
/// Every main ribbon tab and dark mode render with accessibility checks.
fn renders_every_ribbon_tab_without_panicking() {
let mut s = Session::new();
s.execute("file.new", json!({"sample": "sales"})).unwrap();
let mut h = harness(s);
for tab in ["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"] {
h.state_mut().ui.ribbon_tab = tab.into();
h.run_steps(2);
h.check_accessibility();
}
h.state_mut().ui.dark = true;
h.run_steps(3);
h.check_accessibility();
}
#[test]
/// Language commands are listed and every available locale renders and persists; CJK locales are omitted when craft fonts are absent.
fn language_switch_renders_the_ribbon_and_persists() {
let mut h = harness(blank());
// English by default whatever the host's locale: only the desktop app reads the system language.
assert_eq!(h.state().ui.language, Language::En);
// The language commands are listed with the other UI commands.
let ctx = h.ctx.clone();
let gridcraft_ui_egui::control::Outcome::Done(listed) = gridcraft_ui_egui::control::handle(h.state_mut(), &ctx, "engine.commands", &json!({}))
else {
panic!("engine.commands returns a response");
};
let ids: Vec<&str> = listed["result"].as_array().into_iter().flatten().filter_map(|c| c["id"].as_str()).collect();
for id in ["app.language.set", "app.language.english", "app.language.japanese"] {
assert!(ids.contains(&id), "engine.commands lists {id}");
}
// A minimal CI image has egui's bundled faces but no Japanese/Korean/Chinese face. Those
// catalogs are exercised when the pinned craft-fonts input is present; skipping them here is
// the documented no-font gap, not a missing-glyph or accessibility opt-out.
let languages: Vec<Language> = Language::ALL
.into_iter()
.filter(|l| gridcraft_ui_egui::theme::craft_fonts_loaded() || !matches!(l, Language::Ja | Language::Ko | Language::Zh))
.collect();
for language in languages {
h.state_mut().run("app.language.set", json!({"language": language.code()})).unwrap();
h.run_steps(2); // every available locale renders the ribbon without panicking
h.check_accessibility();
assert_eq!(h.state().ui.language, language);
let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap();
assert_eq!(restored.language, language);
}
h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap();
assert_eq!(h.state().ui.language, Language::En);
}
#[test]
/// The PivotTable Fields pane and its field menus switch to Spanish.
fn pivot_fields_pane_follows_the_interface_language() {
// #858: the PivotTable Fields pane and its field menus stayed in English after switching to Spanish.
let mut h = harness(blank());
h.state_mut().run("range.setValues", json!({"range": "A1:B3", "values": [["Region", "Sales"], ["North", 10], ["South", 20]]})).unwrap();
h.state_mut().run("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$3", "rows": ["Region"]})).unwrap();
h.state_mut().run("app.language.set", json!({"language": "es"})).unwrap();
h.run_steps(4);
h.check_accessibility();
let es = |english| Language::Es.tr(english);
for english in ["PivotTable Fields", "Rows", "Values", "Refresh", "Calculated Field…"] {
assert!(h.query_by_label(es(english)).is_some(), "the pane shows `{}`", es(english));
}
if es("PivotTable Fields") != "PivotTable Fields" {
assert!(h.query_by_label("PivotTable Fields").is_none());
}
h.get_by_label("Region ▾").click();
h.run_steps(2);
h.check_accessibility();
for english in ["Move to Column Labels", "Group by Years", "Remove Field"] {
assert!(h.query_by_label(es(english)).is_some(), "the field menu shows `{}`", es(english));
}
if es("Remove Field") != "Remove Field" {
assert!(h.query_by_label("Remove Field").is_none());
}
}
#[test]
fn pivot_fields_pane_reuses_its_field_list_until_the_workbook_changes() {
// #2113: the pane ran `pivot.fields`, a scan of the whole source list, on every frame.
let mut h = harness(blank());
h.state_mut().run("range.setValues", json!({"range": "A1:B3", "values": [["Region", "Sales"], ["North", 10], ["South", 20]]})).unwrap();
h.state_mut().run("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$3", "rows": ["Region"], "destination": "Sheet1!D1"})).unwrap();
h.run_steps(2);
assert!(h.query_by_label("Sales").is_some(), "the pane lists the source fields");
let (key, mut info) = h.state().grid.pivot_fields.clone().expect("the pane keeps its field list");
info["fields"][1]["name"] = json!("Cached");
h.state_mut().grid.pivot_fields = Some((key, info));
h.run_steps(2);
assert!(h.query_by_label("Cached").is_some(), "repaints reuse the kept list instead of scanning the source again");
h.state_mut().run("cell.set", json!({"cell": "B1", "input": "Revenue"})).unwrap();
h.run_steps(2);
assert!(h.query_by_label("Cached").is_none(), "an edit drops the kept list");
assert!(h.query_by_label("Revenue").is_some(), "the pane shows the edited source");
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert!(h.query_by_label("Sales").is_some(), "undo brings the previous field list back");
}
#[test]
fn format_cells_draws_its_labels_in_the_interface_language() {
let mut h = harness(blank());
h.state_mut().run("app.language.set", json!({"language": "de"})).unwrap();
h.state_mut().open_dialog("formatCells", json!({"tab": "Number"}));
h.run_steps(2);
let scientific = Language::De.tr("Scientific");
assert!(h.query_by_label(scientific).is_some(), "the Number tab's categories are translated");
if scientific != "Scientific" {
assert!(h.query_by_label("Scientific").is_none(), "no English category is left");
}
h.get_by_label(scientific).click();
h.run_steps(2);
let style = &h.state().dialog.as_ref().unwrap().values["style"];
assert_eq!(style["num_fmt"], json!("0.00E+00"), "a translated category still applies its code");
h.state_mut().dialog = None;
h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap();
h.state_mut().open_dialog("formatCells", json!({"tab": "Number"}));
h.run_steps(2);
assert!(h.query_by_label("Scientific").is_some(), "English is unchanged");
}
#[test]
fn name_manager_new_and_edit_open_the_name_dialog_and_come_back() {
// #238: New… in the Name Manager did nothing (the manager put itself back over the dialog it
// had just opened).
let mut h = harness(blank());
h.state_mut().open_dialog("nameManager", json!({}));
h.run_steps(4);
h.get_by_label("New…").click();
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().map(|d| d.title.as_str()), Some("New Name"));
if let Some(d) = h.state_mut().dialog.as_mut() {
d.values.insert("name".into(), json!("Total"));
d.values.insert("refersTo".into(), json!("=Sheet1!$A$1"));
}
h.run_steps(1);
key(&mut h, Key::Enter, Modifiers::NONE);
h.run_steps(2);
let names = |h: &egui_kittest::Harness<'static, SheetApp>| {
h.state().session.active().unwrap().wb.names.iter().map(|n| n.name.clone()).collect::<Vec<_>>()
};
assert_eq!(names(&h), ["Total"]);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("nameManager"), "OK returns to the Name Manager");
h.get_by_label("Edit…").click();
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().map(|d| d.title.as_str()), Some("Edit Name"));
if let Some(d) = h.state_mut().dialog.as_mut() {
d.values.insert("name".into(), json!("GrandTotal"));
}
h.run_steps(1);
key(&mut h, Key::Enter, Modifiers::NONE);
h.run_steps(2);
assert_eq!(names(&h), ["GrandTotal"], "Edit renames instead of adding a second name");
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("nameManager"));
}
#[test]
fn recommended_charts_dialog_inserts_the_chosen_chart() {
// #215: Recommended Charts opens a dialog of chart types for the data instead of inserting a
// column chart straight away; OK inserts the one picked.
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [["Fruit", "Sales"], ["Apples", 5], ["Pears", 3], ["Plums", 2]]})).unwrap();
s.execute("selection.set", json!({"range": "A1:B4"})).unwrap();
let mut h = harness(s);
h.state_mut().open_dialog("recommendedCharts", json!({}));
h.run_steps(4);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("recommendedCharts"));
h.get_by_label("Pie").click();
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
h.run_steps(2);
assert!(h.state().dialog.is_none());
let kinds: Vec<_> = h.state().session.active().unwrap().wb.active().unwrap().charts.iter().map(|c| c.kind).collect();
assert_eq!(kinds, [gridcraft_engine::model::ChartKind::Pie]);
assert!(h.state().selected_chart().is_some(), "the new chart is selected");
// Without numbers there is nothing to recommend: a message, no dialog.
h.state_mut().session.run("selection.set", json!({"range": "A1:A4"})).unwrap();
h.state_mut().open_dialog("recommendedCharts", json!({}));
assert!(h.state().dialog.is_none());
assert!(h.state().message.is_some());
}
#[test]
/// Selecting 100% Stacked Area in the All Charts list inserts that chart type.
fn all_charts_inserts_a_hundred_percent_stacked_area() {
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", 10, 30], ["Q2", 30, 10]]})).unwrap();
s.execute("selection.set", json!({"range": "A1:C3"})).unwrap();
let mut h = harness(s);
h.state_mut().open_dialog("recommendedCharts", json!({"tab": "All Charts"}));
h.run_steps(4);
// The entry sits below the list's visible height: scroll the list with the wheel first.
let first = h.get_by_label("Pie").rect().center();
h.input_mut().events.push(Event::PointerMoved(first));
h.input_mut().events.push(Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
source: egui::MouseWheelSource::Unknown,
delta: egui::vec2(0.0, -10.0),
phase: egui::TouchPhase::Move,
modifiers: Modifiers::NONE,
});
h.run_steps(20);
h.get_by_label("100% Stacked Area").click();
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
h.run_steps(2);
assert!(h.state().dialog.is_none());
let kinds: Vec<_> = h.state().session.active().unwrap().wb.active().unwrap().charts.iter().map(|c| c.kind).collect();
assert_eq!(kinds, [gridcraft_engine::model::ChartKind::AreaStacked100]);
}
#[test]
fn recommended_charts_command_without_a_type_opens_the_dialog() {
// #1399: the ribbon button and the NR keytip ran the command directly and inserted the first
// recommendation (a scatter plot) instead of showing the chart picker.
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [["Fruit", "Sales"], ["Apples", 5], ["Pears", 3], ["Plums", 2]]})).unwrap();
s.execute("selection.set", json!({"range": "A1:B4"})).unwrap();
let mut h = harness(s);
h.state_mut().run_or_alert("insert.recommendedCharts", json!({}));
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("recommendedCharts"));
assert!(h.state().session.active().unwrap().wb.active().unwrap().charts.is_empty(), "no chart is inserted before one is picked");
}
#[test]
fn select_data_dialog_sets_the_category_axis_labels() {
// #287: Chart Design › Select Data edits the Horizontal (Category) Axis Labels and removes the
// series the X values were plotted as.
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [["Year", "Sales"], [2020, 5], [2021, 7], [2022, 9]]})).unwrap();
let id = s.execute("insert.chart", json!({"range": "A1:B4", "type": "line"})).unwrap()["chart"].as_u64().unwrap();
let mut h = harness(s);
h.state_mut().select_object_id(Some(id as u32));
h.state_mut().open_dialog("selectData", json!({"chart": id}));
h.run_steps(2);
let d = h.state_mut().dialog.as_mut().unwrap();
assert_eq!(d.title, "Select Data Source");
assert_eq!(d.values["names"], json!(["Year", "Sales"]));
d.values.insert("categories".into(), json!("=A2:A4"));
d.values.insert("remove".into(), json!([0]));
h.run_steps(1);
key(&mut h, Key::Enter, Modifiers::NONE);
h.run_steps(2);
assert!(h.state().dialog.is_none());
let chart = h.state().session.active().unwrap().wb.active().unwrap().charts[0].clone();
assert_eq!(chart.series.len(), 1);
assert_eq!(chart.series[0].categories.as_deref(), Some("Sheet1!$A$2:$A$4"));
assert_eq!(chart.series[0].values, "Sheet1!$B$2:$B$4");
}
#[test]
/// Each listed dialog opens, passes accessibility and closes.
fn dialogs_open_and_close() {
let mut h = harness(blank());
for name in [
"formatCells",
"insertFunction",
"find",
"sort",
"nameManager",
"dataValidation",
"pasteSpecial",
"commandSearch",
"spelling",
"goTo",
"pivotCalculatedField",
"about",
"macros",
"recordMacro",
"macroEditor",
"analysisToolPak",
"solver",
] {
h.state_mut().open_dialog(name, json!({}));
h.run_steps(2);
h.check_accessibility();
key(&mut h, Key::Escape, Modifiers::NONE);
h.state_mut().dialog = None;
h.state_mut().message = None;
}
}
#[test]
fn data_analysis_dialog_runs_a_tool_and_asks_before_overwriting() {
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5]]})).unwrap();
let mut h = harness(s);
h.state_mut().open_dialog("analysisToolPak", json!({}));
h.state_mut().dialog.as_mut().unwrap().values.insert("pick".into(), json!("descriptive"));
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(h.state().dialog.as_ref().map(|d| d.title.clone()).as_deref(), Some("Descriptive Statistics"));
let fill = |h: &mut egui_kittest::Harness<'static, SheetApp>| {
let d = h.state_mut().dialog.as_mut().unwrap();
d.values.insert("range".into(), json!("A1:A5"));
d.values.insert("outputTo".into(), json!("range"));
d.values.insert("output".into(), json!("C1"));
};
fill(&mut h);
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
assert!(h.state().dialog.is_none(), "OK runs the tool and closes the dialog");
assert_eq!(value(&h, "C3"), Value::from("Mean"));
assert_eq!(value(&h, "D3"), Value::Number(3.0));
// A second report into the same cells asks before replacing them (opened with a tool, the
// dialog starts at that tool's form).
h.state_mut().open_dialog("analysisToolPak", json!({"tool": "descriptive"}));
fill(&mut h);
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
let d = h.state().dialog.as_ref().expect("the dialog stays open to ask");
assert_eq!(d.values.get("askOverwrite"), Some(&json!(true)));
assert!(h.state().message.is_none());
}
#[test]
fn data_analysis_dialog_opens_every_tool_form() {
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [2, 4], [3, 6]]})).unwrap();
let mut h = harness(s);
for (id, label) in gridcraft_engine::cmd::analysis::TOOLS {
h.state_mut().open_dialog("analysisToolPak", json!({"tool": id}));
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().map(|d| d.title.clone()).as_deref(), Some(*label), "{id}");
h.state_mut().dialog = None;
}
// The z-Test form runs with the known variances typed in.
h.state_mut().open_dialog("analysisToolPak", json!({"tool": "zTest"}));
{
let d = h.state_mut().dialog.as_mut().unwrap();
for (k, v) in [("range1", "A1:A3"), ("range2", "B1:B3"), ("variance1", "1"), ("variance2", "4"), ("outputTo", "range"), ("output", "D1")] {
d.values.insert(k.into(), json!(v));
}
}
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
assert!(h.state().dialog.is_none(), "OK runs the tool and closes the dialog");
assert_eq!(value(&h, "D1"), Value::from("z-Test: Two Sample for Means"));
// Random Number Generation draws from the chosen distribution with the typed seed.
h.state_mut().open_dialog("analysisToolPak", json!({"tool": "randomNumberGeneration"}));
{
let d = h.state_mut().dialog.as_mut().unwrap();
for (k, v) in [("count", "3"), ("distribution", "bernoulli"), ("seed", "1"), ("outputTo", "range"), ("output", "H1")] {
d.values.insert(k.into(), json!(v));
}
}
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
assert!(h.state().dialog.is_none());
assert!(matches!(value(&h, "H3"), Value::Number(x) if x == 0.0 || x == 1.0));
}
#[test]
fn two_factor_anova_without_replication_labels_caption_names_the_first_column() {
// Ticked, Labels makes the tool take the first cell of every data row as the row's name, so
// the caption must not say the labels are only in the first row.
let mut h = harness(blank());
h.state_mut().open_dialog("analysisToolPak", json!({"tool": "anovaTwoFactorWithout"}));
h.run_steps(2);
assert!(h.query_by_label("Labels in first row and first column").is_some());
assert!(h.query_by_label("Labels in first row").is_none());
}
#[test]
fn random_number_generation_form_hides_the_count_for_a_patterned_sequence() {
// A patterned sequence ignores the count (its from, to, step and repeats set the length), so
// the form offers the count only for the random distributions.
let mut h = harness(blank());
h.state_mut().open_dialog("analysisToolPak", json!({"tool": "randomNumberGeneration"}));
h.run_steps(2);
assert!(h.query_by_label("Number of Random Numbers:").is_some());
h.state_mut().dialog.as_mut().unwrap().values.insert("distribution".into(), json!("patterned"));
h.run_steps(2);
assert!(h.query_by_label("Number of Random Numbers:").is_none());
assert!(h.query_by_label("Repeating each sequence (times):").is_some());
}
#[test]
fn random_seed_caption_says_blank_draws_a_new_sequence() {
// A blank seed doesn't mean "no seed": the engine picks a new one, so the numbers differ on
// each run. The caption says so for both tools that draw random numbers.
let mut h = harness(blank());
for tool in ["randomNumberGeneration", "sampling"] {
h.state_mut().open_dialog("analysisToolPak", json!({"tool": tool}));
h.state_mut().dialog.as_mut().unwrap().values.insert("method".into(), json!("random"));
h.run_steps(2);
assert!(h.query_by_label("Random Seed (blank for a new sequence):").is_some(), "{tool}");
assert!(h.query_by_label("Random Seed (blank for none):").is_none(), "{tool}");
h.state_mut().dialog = None;
}
}
#[test]
fn data_analysis_form_keeps_ok_on_screen_in_a_short_window() {
// The patterned Random Number Generation form is the tallest; in a 600-point-high window the
// tool's options scroll and the output options and OK stay reachable.
let mut h = crate::common::harness_with(crate::common::builder().with_size(egui::vec2(1280.0, 600.0)), crate::common::app(blank()), 4);
h.state_mut().open_dialog("analysisToolPak", json!({"tool": "randomNumberGeneration"}));
h.state_mut().dialog.as_mut().unwrap().values.insert("distribution".into(), json!("patterned"));
h.run_steps(4);
// OK's label is padded; a button clipped off the window has no node at all.
let ok = h.query_by_label(" OK ").map(|n| n.rect());
assert!(ok.is_some_and(|r| r.bottom() <= 600.0), "OK at {ok:?} in a 600-point window");
let output = h.query_by_label("New Worksheet Ply:").map(|n| n.rect());
assert!(output.is_some_and(|r| r.bottom() <= 600.0), "the output options at {output:?}");
}
#[test]
fn random_seed_shows_only_where_numbers_are_drawn() {
// Patterned generation and periodic sampling draw nothing, so their forms have no seed.
let seed = "Random Seed (blank for a new sequence):";
let mut h = harness(blank());
for (tool, key, value, shown) in [
("randomNumberGeneration", "distribution", "uniform", true),
("randomNumberGeneration", "distribution", "patterned", false),
("sampling", "method", "random", true),
("sampling", "method", "periodic", false),
] {
h.state_mut().open_dialog("analysisToolPak", json!({"tool": tool}));
h.state_mut().dialog.as_mut().unwrap().values.insert(key.into(), json!(value));
h.run_steps(2);
assert_eq!(h.query_by_label(seed).is_some(), shown, "{tool} {value}");
h.state_mut().dialog = None;
}
}
#[test]
fn solver_dialog_loads_the_saved_model_and_results_can_restore() {
let mut s = blank();
for (c, v) in [("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2")] {
s.execute("cell.set", json!({"cell": c, "input": v})).unwrap();
}
let r = s
.execute(
"data.solver",
json!({"objective": "B1", "variables": "A1:A2", "constraints": ["A1 <= 4", "B2 <= 12", "B3 <= 18"], "method": "simplex"}),
)
.unwrap();
assert_eq!(s.active().unwrap().wb.active().unwrap().value(CellRef::parse("B1").unwrap()), Value::Number(36.0));
let mut h = harness(s);
h.state_mut().open_dialog("solver", json!({}));
h.run_steps(2);
let d = h.state().dialog.as_ref().unwrap();
assert_eq!(d.values.get("objective"), Some(&json!("B1")));
assert_eq!(d.values.get("method"), Some(&json!("simplex")));
assert_eq!(d.values.get("constraints").and_then(|c| c.as_array()).map(Vec::len), Some(3));
h.state_mut().dialog = None;
// Solver Results › Restore Original Values undoes the solution.
h.state_mut().open_dialog("solverResults", r);
h.state_mut().dialog.as_mut().unwrap().values.insert("restore".into(), json!(true));
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
assert!(h.state().dialog.is_none());
assert_eq!(value(&h, "A1"), Value::Number(0.0));
assert_eq!(value(&h, "B1"), Value::Number(0.0));
}
#[test]
fn solver_results_skip_the_sensitivity_report_for_integer_models() {
let mut s = blank();
for (c, v) in [("A1", "0"), ("B1", "=3*A1")] {
s.execute("cell.set", json!({"cell": c, "input": v})).unwrap();
}
let r =
s.execute("data.solver", json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4.5", "A1 int"], "method": "simplex"})).unwrap();
let mut h = harness(s);
// Sensitivity stays off for an integer model, so the Answer report is still written.
h.state_mut().open_dialog("solverResults", r);
for key in ["answerReport", "sensitivityReport"] {
h.state_mut().dialog.as_mut().unwrap().values.insert(key.into(), json!(true));
}
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
assert!(h.state().dialog.is_none());
assert!(h.state().message.is_none(), "{:?}", h.state().message);
let names: Vec<String> = h.state().session.active().unwrap().wb.sheets.iter().map(|s| s.name.clone()).collect();
assert_eq!(names, ["Answer Report 1", "Sheet1"]);
}
#[test]
fn solver_results_offer_sensitivity_only_after_an_optimum() {
// Rosenbrock's function with 60 evaluations stops at the limit with a solution that is not
// an optimum: Sensitivity is disabled, and Answer with Sensitivity ticked writes the Answer
// report.
let mut s = blank();
for (c, v) in [("A1", "-1.2"), ("A2", "1"), ("B1", "=(1-A1)^2+100*(A2-A1^2)^2")] {
s.execute("cell.set", json!({"cell": c, "input": v})).unwrap();
}
let r = s
.execute("data.solver", json!({"objective": "B1", "goal": "min", "variables": "A1:A2", "nonNegative": false, "maxEvaluations": 60}))
.unwrap();
assert_eq!(r["status"], "iterationLimit", "{r}");
let mut h = harness(s);
h.state_mut().open_dialog("solverResults", r.clone());
h.run_steps(2);
assert!(h.get_by_label("Sensitivity").accesskit_node().is_disabled());
assert!(!h.get_by_label("Answer").accesskit_node().is_disabled());
for key in ["answerReport", "sensitivityReport"] {
h.state_mut().dialog.as_mut().unwrap().values.insert(key.into(), json!(true));
}
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
assert!(h.state().dialog.is_none());
assert!(h.state().message.is_none(), "{:?}", h.state().message);
let names: Vec<String> = h.state().session.active().unwrap().wb.sheets.iter().map(|s| s.name.clone()).collect();
assert_eq!(names, ["Answer Report 1", "Sheet1"]);
// The same model after an optimum offers it.
let mut ok = r;
ok["status"] = json!("converged");
h.state_mut().open_dialog("solverResults", ok);
h.run_steps(2);
assert!(!h.get_by_label("Sensitivity").accesskit_node().is_disabled());
}
#[test]
fn solver_dialog_saves_loads_and_resets_the_model() {
let mut s = blank();
for (c, v) in [("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2")] {
s.execute("cell.set", json!({"cell": c, "input": v})).unwrap();
}
let mut h = harness(s);
h.state_mut().open_dialog("solver", json!({}));
{
let d = h.state_mut().dialog.as_mut().unwrap();
for (k, v) in [
("objective", json!("B1")),
("variables", json!("A1:A2")),
("constraints", json!(["B2 <= 12", "A1 <= 4"])),
("modelArea", json!(true)),
("modelRange", json!("H1")),
] {
d.values.insert(k.into(), v);
}
}
h.run_steps(2);
h.get_by_label("Save").click();
h.run_steps(2);
let note = |h: &egui_kittest::Harness<'_, SheetApp>| h.state().dialog.as_ref().unwrap().values.get("modelNote").cloned();
assert_eq!(note(&h), Some(json!("The model was saved to H1:H5.")));
// Reset All clears the form; Load fills it again from the saved cells.
h.get_by_label("Reset All").click();
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().unwrap().values.get("objective"), Some(&json!("")));
h.get_by_label("Load").click();
h.run_steps(2);
let d = h.state().dialog.as_ref().unwrap();
assert_eq!(d.values.get("objective"), Some(&json!("B1")), "{:?}", d.values.get("modelNote"));
assert_eq!(d.values.get("constraints"), Some(&json!(["B2 <= 12", "A1 <= 4"])));
let saved = gridcraft_engine::cmd::solver::saved_model(&h.state().session.active().unwrap().wb, 0);
assert_eq!(saved.map(|m| m["variables"].clone()), Some(json!("A1:A2")));
}
#[test]
fn solver_dialog_asks_before_save_model_replaces_data() {
let mut s = blank();
for (c, v) in [("A1", "0"), ("B1", "=3*A1"), ("H2", "kept")] {
s.execute("cell.set", json!({"cell": c, "input": v})).unwrap();
}
let mut h = harness(s);
let form = json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4"], "modelArea": true, "modelRange": "H1"});
h.state_mut().open_dialog("solver", form);
h.run_steps(2);
let values = |h: &egui_kittest::Harness<'_, SheetApp>| h.state().dialog.as_ref().unwrap().values.clone();
// Save over data asks first and writes nothing; Don't Replace keeps the data.
h.get_by_label("Save").click();
h.run_steps(2);
assert_eq!(values(&h).get("askModelOverwrite"), Some(&json!("H1")));
assert_eq!(value(&h, "H2"), Value::text("kept"));
h.get_by_label("Don't Replace").click();
h.run_steps(2);
assert_eq!((values(&h).get("askModelOverwrite"), value(&h, "H2")), (None, Value::text("kept")));
// Editing the range drops the question, so Replace never writes somewhere else.
h.get_by_label("Save").click();
h.run_steps(2);
h.state_mut().dialog.as_mut().unwrap().values.insert("modelRange".into(), json!("J1"));
h.run_steps(2);
assert_eq!(values(&h).get("askModelOverwrite"), None);
assert!(h.query_by_label("Replace").is_none());
// Replace writes the model in one undo step.
h.state_mut().dialog.as_mut().unwrap().values.insert("modelRange".into(), json!("H1"));
h.run_steps(2);
h.get_by_label("Save").click();
h.run_steps(2);
h.get_by_label("Replace").click();
h.run_steps(2);
assert_eq!(values(&h).get("modelNote"), Some(&json!("The model was saved to H1:H4.")));
assert_eq!(values(&h).get("askModelOverwrite"), None);
assert_eq!(value(&h, "H2"), Value::Number(1.0));
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(value(&h, "H2"), Value::text("kept"));
}
#[test]
fn solver_reset_all_and_load_model_drop_the_old_models_options() {
let mut s = blank();
for (c, v) in [("A1", "1"), ("B1", "=ABS(A1-4)")] {
s.execute("cell.set", json!({"cell": c, "input": v})).unwrap();
}
// A GRG Nonlinear model on cells, which has no Evolutionary options.
s.execute("data.solverSaveModel", json!({"to": "H1", "model": {"objective": "B1", "goal": "min", "variables": "A1"}})).unwrap();
let mut h = harness(s);
let old = json!({"objective": "B1", "goal": "min", "variables": "A1", "method": "evolutionary", "maxEvaluations": "7", "integerOptimality": "5", "requireBounds": false, "convergence": "0.5", "randomSeed": "11", "populationSize": "40", "mutationRate": "0.5", "generationsWithoutImprovement": "5", "modelArea": true, "modelRange": "H1"});
h.state_mut().open_dialog("solver", old.clone());
h.run_steps(2);
let keys = [
"maxEvaluations",
"integerOptimality",
"requireBounds",
"convergence",
"randomSeed",
"populationSize",
"mutationRate",
"generationsWithoutImprovement",
];
let left = |h: &egui_kittest::Harness<'_, SheetApp>| -> Vec<&str> {
let d = h.state().dialog.as_ref().unwrap();
keys.into_iter().filter(|k| d.values.contains_key(*k)).collect()
};
let kept = |h: &egui_kittest::Harness<'_, SheetApp>| -> Vec<&str> {
let d = h.state().dialog.as_ref().unwrap();
keys.into_iter().filter(|k| d.values.get(*k) == old.get(*k)).collect()
};
// Load Model keeps none of the old options: the loaded model's own, or none.
h.get_by_label("Load").click();
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().unwrap().values.get("method"), Some(&json!("grg")));
assert_eq!(kept(&h), Vec::<&str>::new());
// Reset All drops them.
{
let d = h.state_mut().dialog.as_mut().unwrap();
for (k, v) in old.as_object().unwrap() {
d.values.insert(k.clone(), v.clone());
}
}
h.run_steps(2);
h.get_by_label("Reset All").click();
h.run_steps(2);
assert_eq!(left(&h), Vec::<&str>::new());
// A new Evolutionary model then solves with the default options.
{
let d = h.state_mut().dialog.as_mut().unwrap();
for (k, v) in [
("objective", json!("B1")),
("goal", json!("min")),
("variables", json!("A1")),
("constraints", json!(["A1 <= 10"])),
("method", json!("evolutionary")),
] {
d.values.insert(k.into(), v);
}
}
h.run_steps(2);
h.get_by_label(" Solve ").click();
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("solverResults"), "{:?}", h.state().message);
let saved = gridcraft_engine::cmd::solver::saved_model(&h.state().session.active().unwrap().wb, 0).unwrap();
assert_eq!(
(saved["requireBounds"].as_bool(), saved["randomSeed"].as_f64(), saved["convergence"].as_f64()),
(Some(true), Some(0.0), Some(0.0001)),
"{saved}"
);
assert_eq!((saved["populationSize"].as_f64(), saved["generationsWithoutImprovement"].as_f64()), (Some(100.0), Some(50.0)), "{saved}");
assert_eq!((saved["maxEvaluations"].as_f64(), saved["integerOptimality"].as_f64()), (Some(20000.0), Some(0.0)), "{saved}");
}
#[test]
fn solver_options_go_with_the_solve() {
let mut s = blank();
for (c, v) in [("A1", "1"), ("B1", "=ABS(A1-4)")] {
s.execute("cell.set", json!({"cell": c, "input": v})).unwrap();
}
let mut h = harness(s);
h.state_mut().open_dialog("solver", json!({"objective": "B1", "goal": "min", "variables": "A1", "method": "evolutionary"}));
h.run_steps(2);
// Switching between the parameters and the options changes the window's height, and it
// re-centres over the next frames, so each switch is followed by a few frames.
h.get_by_label("Options").click();
h.run_steps(4);
assert_eq!(h.state().dialog.as_ref().unwrap().title, "Solver Options");
// Cancel keeps the options as they were; OK keeps the edits.
let put = |h: &mut egui_kittest::Harness<'_, SheetApp>, k: &str, v: serde_json::Value| {
h.state_mut().dialog.as_mut().unwrap().values.insert(k.into(), v);
};
put(&mut h, "maxEvaluations", json!("7"));
h.get_by_label(" Cancel ").click();
h.run_steps(4);
let d = h.state().dialog.as_ref().unwrap();
assert_eq!((d.title.as_str(), d.values.get("maxEvaluations")), ("Solver Parameters", Some(&json!("20000"))));
h.get_by_label("Options").click();
h.run_steps(4);
put(&mut h, "requireBounds", json!(false));
put(&mut h, "randomSeed", json!("11"));
h.get_by_label(" OK ").click();
h.run_steps(4);
h.get_by_label(" Solve ").click();
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("solverResults"), "{:?}", h.state().message);
assert!(matches!(value(&h, "A1"), Value::Number(v) if (v - 4.0).abs() < 1e-6));
let saved = gridcraft_engine::cmd::solver::saved_model(&h.state().session.active().unwrap().wb, 0).unwrap();
assert_eq!((saved["randomSeed"].as_f64(), saved["requireBounds"].as_bool()), (Some(11.0), Some(false)));
}
#[test]
fn solver_reset_all_resets_the_options() {
let mut s = blank();
for (c, v) in [("A1", "1"), ("B1", "=ABS(A1-4)")] {
s.execute("cell.set", json!({"cell": c, "input": v})).unwrap();
}
let mut h = harness(s);
let options = json!({"objective": "B1", "goal": "min", "variables": "A1", "method": "evolutionary", "maxEvaluations": "7", "integerOptimality": "5", "randomSeed": "11", "requireBounds": false});
h.state_mut().open_dialog("solver", options);
h.run_steps(2);
h.get_by_label("Reset All").click();
h.run_steps(2);
let d = h.state().dialog.as_ref().unwrap();
for key in ["maxEvaluations", "integerOptimality", "randomSeed", "requireBounds"] {
assert_eq!(d.values.get(key), None, "{key}");
}
// Options shows the defaults again.
h.get_by_label("Options").click();
h.run_steps(2);
let d = h.state().dialog.as_ref().unwrap();
assert_eq!((d.values.get("maxEvaluations"), d.values.get("randomSeed")), (Some(&json!("20000")), Some(&json!("0"))));
}
#[test]
fn escape_in_solver_options_cancels_the_options_only() {
let mut s = blank();
for (c, v) in [("A1", "1"), ("B1", "=ABS(A1-4)")] {
s.execute("cell.set", json!({"cell": c, "input": v})).unwrap();
}
let mut h = harness(s);
h.state_mut().open_dialog("solver", json!({"objective": "B1", "goal": "min", "variables": "A1", "method": "evolutionary"}));
h.run_steps(2);
// A parameter edited before opening Options stays; the option edited there is undone.
h.state_mut().dialog.as_mut().unwrap().values.insert("constraints".into(), json!(["A1 <= 9"]));
h.get_by_label("Options").click();
h.run_steps(2);
h.state_mut().dialog.as_mut().unwrap().values.insert("maxEvaluations".into(), json!("7"));
key(&mut h, Key::Escape, Modifiers::NONE);
let d = h.state().dialog.as_ref().expect("Escape leaves the Solver dialog open");
assert_eq!((d.title.as_str(), d.values.get("maxEvaluations")), ("Solver Parameters", Some(&json!("20000"))));
assert_eq!(d.values.get("constraints"), Some(&json!(["A1 <= 9"])));
// In the parameters, Escape closes the dialog as before.
key(&mut h, Key::Escape, Modifiers::NONE);
assert!(h.state().dialog.is_none());
}
#[test]
fn solver_dialog_opened_in_its_options_shows_the_defaults_and_cancel_restores_them() {
let mut s = blank();
for (c, v) in [("A1", "1"), ("B1", "=ABS(A1-4)")] {
s.execute("cell.set", json!({"cell": c, "input": v})).unwrap();
}
let mut h = harness(s);
let params =
json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["A1 <= 10"], "method": "evolutionary", "optionsView": true});
let put = |h: &mut egui_kittest::Harness<'_, SheetApp>, k: &str, v: serde_json::Value| {
h.state_mut().dialog.as_mut().unwrap().values.insert(k.into(), v);
};
let get = |h: &egui_kittest::Harness<'_, SheetApp>, k: &str| h.state().dialog.as_ref().unwrap().values.get(k).cloned();
// With no saved model, the fields show the defaults, as after the Options button.
h.state_mut().open_dialog("solver", params.clone());
h.run_steps(4);
assert_eq!(h.state().dialog.as_ref().unwrap().title, "Solver Options");
assert!(h.query_all_by_label("Max Evaluations:").next().is_some());
assert_eq!(
(get(&h, "maxEvaluations"), get(&h, "randomSeed"), get(&h, "integerOptimality")),
(Some(json!("20000")), Some(json!("0")), Some(json!("0")))
);
// Cancel restores them.
put(&mut h, "randomSeed", json!("11"));
put(&mut h, "requireBounds", json!(false));
h.get_by_label(" Cancel ").click();
h.run_steps(4);
assert_eq!(h.state().dialog.as_ref().unwrap().title, "Solver Parameters");
assert_eq!((get(&h, "randomSeed"), get(&h, "requireBounds")), (Some(json!("0")), Some(json!(true))));
// So does Escape.
h.state_mut().dialog = None;
h.state_mut().open_dialog("solver", params);
h.run_steps(4);
put(&mut h, "maxEvaluations", json!("7"));
key(&mut h, Key::Escape, Modifiers::NONE);
h.run_steps(2);
assert_eq!((h.state().dialog.as_ref().unwrap().title.as_str(), get(&h, "maxEvaluations")), ("Solver Parameters", Some(json!("20000"))));
// The next Solve uses the defaults.
h.get_by_label(" Solve ").click();
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("solverResults"), "{:?}", h.state().message);
let saved = gridcraft_engine::cmd::solver::saved_model(&h.state().session.active().unwrap().wb, 0).unwrap();
assert_eq!(
(saved["maxEvaluations"].as_f64(), saved["randomSeed"].as_f64(), saved["requireBounds"].as_bool()),
(Some(20000.0), Some(0.0), Some(true)),
"{saved}"
);
}
#[test]
fn solver_results_write_the_chosen_reports() {
let mut s = blank();
for (c, v) in [("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2")] {
s.execute("cell.set", json!({"cell": c, "input": v})).unwrap();
}
let r = s
.execute(
"data.solver",
json!({"objective": "B1", "variables": "A1:A2", "constraints": ["A1 <= 4", "B2 <= 12", "B3 <= 18"], "method": "simplex"}),
)
.unwrap();
let mut h = harness(s);
// Restore Original Values with the three reports: the cells go back, the reports stay.
h.state_mut().open_dialog("solverResults", r);
for key in ["restore", "answerReport", "sensitivityReport", "limitsReport"] {
h.state_mut().dialog.as_mut().unwrap().values.insert(key.into(), json!(true));
}
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
assert!(h.state().dialog.is_none());
assert!(h.state().message.is_none(), "{:?}", h.state().message);
let names: Vec<String> = h.state().session.active().unwrap().wb.sheets.iter().map(|s| s.name.clone()).collect();
assert_eq!(names, ["Answer Report 1", "Sensitivity Report 1", "Limits Report 1", "Sheet1"]);
assert_eq!(value(&h, "B1"), Value::Number(0.0));
}
#[test]
fn about_renders_every_tab() {
let mut h = harness(blank());
for tab in ["About", "Contributors", "Models"] {
h.state_mut().open_dialog("about", json!({"tab": tab}));
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().map(|d| d.tab.clone()).as_deref(), Some(tab));
h.state_mut().dialog = None;
}
}
#[test]
fn column_autocomplete_completes_on_enter() {
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [["North"], ["East"], ["Eastern"]]})).unwrap();
s.execute("selection.set", json!({"cell": "A4"})).unwrap();
let mut h = harness(s);
text(&mut h, "No");
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(value(&h, "A4"), Value::from("North"));
text(&mut h, "Ea"); // ambiguous: East / Eastern
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(value(&h, "A5"), Value::from("Ea"));
}
fn completion(h: &egui_kittest::Harness<'static, SheetApp>) -> (String, Option<String>) {
let ed = h.state().editor.as_ref().expect("still editing");
(ed.text.clone(), ed.completion.clone())
}
#[test]
fn column_autocomplete_backspace_and_escape_drop_only_the_proposal() {
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [["Apple"], ["Banana"]]})).unwrap();
s.execute("selection.set", json!({"cell": "A3"})).unwrap();
let mut h = harness(s);
text(&mut h, "ap");
assert_eq!(completion(&h), ("ap".into(), Some("Apple".into())), "\"ple\" is proposed after the caret");
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(value(&h, "A3"), Value::from("Apple"), "Enter accepts it, with the entry's own case");
text(&mut h, "ba");
key(&mut h, Key::Tab, Modifiers::NONE);
assert_eq!(value(&h, "A4"), Value::from("Banana"), "Tab accepts it too");
// Backspace removes the proposal, not the typed "a".
h.state_mut().run("selection.set", json!({"cell": "A5"})).unwrap();
h.run_steps(2);
text(&mut h, "ba");
key(&mut h, Key::Backspace, Modifiers::NONE);
assert_eq!(completion(&h), ("ba".into(), None));
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(value(&h, "A5"), Value::from("ba"));
// Escape dismisses the proposal and keeps the edit; a second Escape cancels it.
text(&mut h, "ap");
key(&mut h, Key::Escape, Modifiers::NONE);
assert_eq!(completion(&h), ("ap".into(), None));
key(&mut h, Key::Escape, Modifiers::NONE);
assert!(h.state().editor.is_none());
assert_eq!(value(&h, "A6"), Value::Empty);
}
#[test]
fn column_autocomplete_skips_ambiguous_prefixes_and_numbers() {
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [["Apricot"], ["Apple"], ["12 Main St"]]})).unwrap();
s.execute("selection.set", json!({"cell": "A4"})).unwrap();
let mut h = harness(s);
text(&mut h, "ap");
assert_eq!(completion(&h), ("ap".into(), None), "Apricot and Apple both match");
text(&mut h, "p");
assert_eq!(completion(&h), ("app".into(), Some("Apple".into())), "one more letter settles it");
h.state_mut().cancel_edit();
text(&mut h, "12");
assert_eq!(completion(&h), ("12".into(), None), "a number is not completed");
}
#[test]
fn column_autocomplete_folds_case_beyond_ascii() {
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [["Ébène"], ["Ébano"], ["ébano"], ["STRAẞENBAHN"]]})).unwrap();
s.execute("selection.set", json!({"cell": "A5"})).unwrap();
let mut h = harness(s);
text(&mut h, "éba");
assert_eq!(completion(&h), ("éba".into(), Some("ébano".into())), "Ébano and ébano are one entry, Ébène doesn't match");
h.state_mut().cancel_edit();
text(&mut h, "éb");
assert_eq!(completion(&h).1, None, "Ébène and Ébano differ past the prefix");
h.state_mut().cancel_edit();
// "ß" is two bytes, "ẞ" three: the proposed rest starts after the fifth character, not the sixth byte.
text(&mut h, "straß");
assert_eq!(completion(&h), ("straß".into(), Some("STRAẞENBAHN".into())));
let painted = h.output().shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.job.text == "ENBAHN"));
assert!(painted, "the rest \"ENBAHN\" is drawn after the caret");
}
#[test]
fn column_autocomplete_can_be_turned_off() {
assert!(gridcraft_ui_egui::UiState::default().autocomplete);
let old: gridcraft_ui_egui::UiState = serde_json::from_str(r#"{"dark": true}"#).unwrap();
assert!(old.autocomplete, "a ui.json written before the setting existed keeps it on");
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [["North"]]})).unwrap();
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
let mut h = harness(s);
assert_eq!(h.state_mut().run("options.autoComplete", json!({"on": false})), Ok(json!({"on": false})));
assert!(h.state().dialog.is_none());
text(&mut h, "No");
assert_eq!(completion(&h), ("No".into(), None));
text(&mut h, "r");
assert_eq!(completion(&h), ("Nor".into(), None), "typing on doesn't bring it back");
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(value(&h, "A2"), Value::from("Nor"));
assert_eq!(h.state_mut().run("options.autoComplete", json!({})), Ok(json!({"on": true})), "no parameters: it toggles");
}
/// An .xlsm carrying a VBA project, saved to bytes.
fn macro_workbook_bytes() -> Vec<u8> {
let mut wb = gridcraft_engine::model::Workbook::new();
wb.vba_project = Some(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1 fake OLE2 vbaProject".to_vec().into());
gridcraft_engine::io::save_bytes(&wb, "macros.xlsm").unwrap()
}
#[test]
fn opening_a_macro_workbook_shows_a_notice() {
// The reader's "macros kept but not executed" warning must reach a dismissible info bar,
// not be silently dropped (issue #6 follow-up: Excel-style message bar).
let mut h = harness(blank());
let b64 = gridcraft_engine::io::base64_encode(&macro_workbook_bytes());
let res = h.state_mut().session.run("file.open", json!({"name": "macros.xlsm", "base64": b64})).unwrap();
assert!(
res.get("warnings").and_then(|w| w.as_array()).is_some_and(|a| a.iter().any(|m| m.as_str().is_some_and(|s| s.contains("macros")))),
"engine reports the macro warning: {res}"
);
// The UI-side handler turns that open result into the notice (open_path does this in the app).
h.state_mut().show_open_warnings(&res);
let n = h.state().notice.clone().expect("a notice was shown");
assert!(n.text.contains("VBA"), "{}", n.text);
assert_eq!(n.kind, gridcraft_ui_egui::NoticeKind::Warning);
assert_eq!(n.action.as_ref().map(|a| a.1.as_str()), Some("macros"), "action opens the Macros dialog");
// The notice bar renders without panic; `ui.notice` surfaces it; dismissing clears it.
h.run_steps(2);
let got = h.state_mut().run("ui.notice", json!({})).unwrap();
assert_eq!(got.get("kind").and_then(|k| k.as_str()), Some("warning"));
h.state_mut().run("ui.notice.dismiss", json!({})).unwrap();
h.run_steps(2);
assert!(h.state().notice.is_none());
}
#[test]
fn dropped_macro_warning_never_promises_to_keep_macros() {
let mut h = harness(blank());
for w in ["macros (VBA project) reference a missing part and were dropped", "macros (VBA project) could not be read and were dropped: bad zip"] {
h.state_mut().show_open_warnings(&json!({"warnings": [w]}));
let n = h.state().notice.clone().unwrap();
assert_eq!(n.kind, gridcraft_ui_egui::NoticeKind::Warning);
assert!(n.text.contains("dropped") && !n.text.contains("keeps them"), "{}", n.text);
}
h.state_mut().show_open_warnings(&json!({"warnings": ["macros (VBA project) were kept but not executed"]}));
assert!(h.state().notice.clone().unwrap().text.contains("keeps them when you save as .xlsm"));
}
#[test]
fn plain_open_warning_becomes_an_info_notice() {
let mut h = harness(blank());
h.state_mut().show_open_warnings(&json!({"warnings": ["links to external workbooks are not supported"]}));
let n = h.state().notice.clone().unwrap();
assert_eq!(n.kind, gridcraft_ui_egui::NoticeKind::Info);
assert!(n.action.is_none());
assert!(n.text.contains("external"));
// No warnings → no notice, no panic.
h.state_mut().show_open_warnings(&json!({"warnings": []}));
assert!(h.state().notice.is_none());
}
#[test]
fn macros_dialog_lists_vba_and_scripts() {
let mut s = blank();
let mut wb = gridcraft_engine::model::Workbook::new();
wb.vba_project = Some(vec![0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1].into());
s.active_mut().unwrap().wb = std::sync::Arc::new(wb);
let mut h = harness(s);
h.state_mut().open_dialog("macros", json!({}));
h.run_steps(3);
let d = h.state().dialog.as_ref().expect("macros dialog open");
assert_eq!(d.name(), "macros");
let items = d.result.as_ref().and_then(|r| r.as_array()).cloned().unwrap_or_default();
assert!(items.iter().any(|i| i.get("kind").and_then(|k| k.as_str()) == Some("vba")), "{items:?}");
h.state_mut().dialog = None;
}
#[test]
fn status_bar_shows_macro_badge() {
let mut s = blank();
let mut wb = gridcraft_engine::model::Workbook::new();
wb.vba_project = Some(vec![0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1].into());
s.active_mut().unwrap().wb = std::sync::Arc::new(wb);
// Rendering the status bar must not panic with a VBA project present.
let mut h = harness(s);
h.run_steps(3);
h.state_mut().ui.dark = true;
h.run_steps(2);
}
#[test]
fn status_bar_names_a_circular_reference_by_its_address() {
let mut s = blank();
s.execute("home.insertSheet", json!({"name": "Bob's Q1"})).unwrap();
s.execute("cell.set", json!({"cell": "B2", "input": "=B2+1"})).unwrap();
let h = harness(s);
assert!(h.query_by_label("Circular References: B2").is_some(), "the status bar names the cell on the active sheet without its sheet");
}
#[test]
fn status_bar_names_no_cell_when_every_circular_reference_is_on_another_sheet() {
let mut s = blank();
s.execute("home.insertSheet", json!({"name": "Data"})).unwrap();
s.execute("cell.set", json!({"cell": "B2", "input": "=B2+1"})).unwrap();
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
let mut h = harness(s);
assert!(h.query_by_label("Circular References").is_some(), "the status bar still reports circular references");
assert!(h.query_by_label("Circular References: B2").is_none(), "no cell address from another sheet");
h.state_mut().session.execute("sheet.activate", json!({"sheet": "Data"})).unwrap();
h.run_steps(2);
assert!(h.query_by_label("Circular References: B2").is_some(), "the active sheet's loop is named");
}
#[test]
fn status_bar_names_a_circular_reference_on_the_active_sheet() {
let mut s = blank();
s.execute("cell.set", json!({"cell": "A1", "input": "=A1+1"})).unwrap();
s.execute("home.insertSheet", json!({"name": "Data"})).unwrap();
s.execute("cell.set", json!({"cell": "B2", "input": "=B2+1"})).unwrap();
s.execute("formulas.calculateNow", json!({})).unwrap();
let circular = s.active().unwrap().calc.circular.clone();
assert_eq!(circular, vec![(0, CellRef::new(0, 0)), (1, CellRef::new(1, 1))], "Sheet1's loop is listed first");
let h = harness(s);
assert!(h.query_by_label("Circular References: B2").is_some(), "the status bar names the loop on the active sheet");
assert!(h.query_by_label("Circular References: A1").is_none(), "not the first loop listed");
}
#[test]
fn alt_f8_opens_the_macros_dialog() {
let mut h = harness(blank());
key(&mut h, Key::F8, Modifiers::ALT);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name().to_string()).as_deref(), Some("macros"));
h.state_mut().dialog = None;
}
#[test]
fn general_numbers_fit_by_the_cells_own_font() {
// 9pt marks in narrow columns (a stored width of 4, i.e. 28px; common in mark sheets): "100" fits.
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [[100, 123456789]]})).unwrap();
s.execute("home.fontSize", json!({"range": "A1:B1", "size": 9})).unwrap();
s.execute("home.columnWidth", json!({"cols": "A:B", "width": 28})).unwrap();
s.execute("selection.set", json!({"cell": "C3"})).unwrap();
let h = harness(s);
let texts: Vec<String> = h
.output()
.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Text(t) => Some(t.galley.job.text.clone()),
_ => None,
})
.collect();
assert!(texts.iter().any(|t| t == "100"), "100 drawn in full: {texts:?}");
// A number that really doesn't fit still shows #### (once: only B1).
assert_eq!(texts.iter().filter(|t| t.starts_with('#')).count(), 1, "{texts:?}");
}
fn text_shapes(shape: &egui::Shape, clip: egui::Rect, out: &mut Vec<(egui::epaint::TextShape, egui::Rect)>) {
match shape {
egui::Shape::Text(t) => out.push((t.clone(), clip)),
egui::Shape::Vec(v) => v.iter().for_each(|s| text_shapes(s, clip, out)),
_ => {}
}
}
#[test]
fn rotated_wrapped_text_is_drawn_rotated_inside_its_cell() {
// Narrow columns with tall, upright headers (mark sheets): rotation and Wrap Text together.
let header = "Term 1 : Drama : Continuous Assessment";
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [[header, header]]})).unwrap();
s.execute("home.wrapText", json!({"range": "A1:B1", "on": true})).unwrap();
s.execute("home.orientation", json!({"range": "A1", "angle": "up"})).unwrap();
s.execute("home.orientation", json!({"range": "B1", "angle": "down"})).unwrap();
s.execute("home.columnWidth", json!({"cols": "A:B", "chars": 6})).unwrap();
s.execute("home.rowHeight", json!({"rows": "1:1", "height": 267})).unwrap();
s.execute("selection.set", json!({"cell": "C3"})).unwrap();
let h = harness(s);
let mut found = Vec::new();
for c in &h.output().shapes {
text_shapes(&c.shape, c.clip_rect, &mut found);
}
let rotated: Vec<_> = found.iter().filter(|(t, _)| t.galley.job.text == header).collect();
assert_eq!(rotated.len(), 2, "both headers drawn as one text block each");
for (t, clip) in rotated {
assert!((t.angle.abs() - std::f32::consts::FRAC_PI_2).abs() < 1e-3, "drawn at ±90°, got {}", t.angle);
// Wrapped along the row height into a couple of long lines, not across the narrow column.
assert!(t.galley.rows.len() <= 3, "{} lines", t.galley.rows.len());
let bb = t.visual_bounding_rect();
assert!(bb.height() > bb.width(), "upright, not flat: {bb:?}");
assert!(clip.expand(1.0).contains_rect(bb), "inside its cell: {bb:?} not in {clip:?}");
// Upright text never spills into the neighbouring columns: the clip is the cell itself.
assert!(clip.width() < 70.0, "clip spans one 6-character column, got {clip:?}");
}
}
#[test]
fn undo_after_backspace_restores_the_cleared_cell() {
// #1855: Backspace clears the cell and opens an empty editor; committing it must not add a
// second, empty undo step, so one Ctrl+Z brings the value back and leaves other edits alone.
let mut h = harness(blank());
text(&mut h, "3200");
key(&mut h, Key::Enter, Modifiers::NONE);
click_cell(&mut h, "C5");
text(&mut h, "11750");
key(&mut h, Key::Enter, Modifiers::NONE);
click_cell(&mut h, "A1");
key(&mut h, Key::Backspace, Modifiers::NONE);
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(value(&h, "A1"), Value::Empty);
key(&mut h, Key::Z, Modifiers::COMMAND);
assert_eq!(value(&h, "A1"), Value::Number(3200.0));
assert_eq!(value(&h, "C5"), Value::Number(11750.0));
}
#[test]
fn header_footer_dialog_opens_on_the_saved_header_and_footer() {
// #523: the dialog started from a fixed empty header and `&CPage &P of &N`, so OK replaced the sheet's text.
let mut s = blank();
s.execute("pageLayout.headerFooter", json!({"header": "&LLeft", "footer": "&CConfidential"})).unwrap();
let mut h = harness(s);
h.state_mut().open_dialog("headerFooter", json!({}));
h.run_steps(2);
let d = h.state_mut().dialog.as_mut().unwrap();
assert_eq!(d.values["header"], json!("&LLeft"));
assert_eq!(d.values["footer"], json!("&CConfidential"));
d.values.insert("header".into(), json!("&CNew"));
let vals = json!(d.values.clone());
h.state_mut().run("pageLayout.headerFooter", vals).unwrap();
let pr = &h.state().session.active().unwrap().wb.active().unwrap().print;
assert_eq!((pr.header.as_str(), pr.footer.as_str()), ("&CNew", "&CConfidential"));
}