Merge g3 into g4

# Conflicts:
#	ROADMAP.md
#	crates/engine/src/cmd/edit.rs
#	crates/engine/src/cmd/formulas.rs
#	crates/engine/src/cmd/inspect.rs
#	crates/engine/src/cmd/pivot.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/tabs.rs
#	docs/gaps.md
#	docs/ui-parity.md
This commit is contained in:
aiongg
2026-10-26 02:45:41 +00:00
353 changed files with 24668 additions and 3425 deletions
@@ -0,0 +1,35 @@
//! The 100% stacked area chart: `insert.chart` and `chart.set` take type `area` with subtype
//! `stacked100`.
use gridcraft_engine::Session;
use gridcraft_model::ChartKind;
use serde_json::json;
fn session() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", "10", "30"], ["Q2", "30", "10"]]})).unwrap();
s
}
fn kinds(s: &Session) -> Vec<ChartKind> {
s.doc().unwrap().wb.sheet(0).unwrap().charts.iter().map(|c| c.kind).collect()
}
#[test]
fn insert_chart_makes_a_hundred_percent_stacked_area() {
let mut s = session();
s.execute("insert.chart", json!({"range": "A1:C3", "type": "area", "subtype": "stacked100"})).unwrap();
assert_eq!(kinds(&s), [ChartKind::AreaStacked100]);
}
#[test]
fn chart_set_changes_a_chart_to_a_hundred_percent_stacked_area_and_back() {
let mut s = session();
s.execute("insert.chart", json!({"range": "A1:C3", "type": "area", "subtype": "stacked"})).unwrap();
assert_eq!(kinds(&s), [ChartKind::AreaStacked]);
s.execute("chart.set", json!({"type": "area", "subtype": "stacked100"})).unwrap();
assert_eq!(kinds(&s), [ChartKind::AreaStacked100]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(kinds(&s), [ChartKind::AreaStacked]);
}
@@ -23,7 +23,7 @@
//!
//! Knobs (environment variables):
//! - `GRIDCRAFT_FUZZ_SEEDS` (default 6) and `GRIDCRAFT_FUZZ_STEPS` (default 40): the budget. Soak with
//! `GRIDCRAFT_FUZZ_SEEDS=200 GRIDCRAFT_FUZZ_STEPS=200 cargo test --release -p gridcraft-engine --test command_sequence_fuzz`.
//! `GRIDCRAFT_FUZZ_SEEDS=200 GRIDCRAFT_FUZZ_STEPS=200 cargo test --release -p gridcraft-engine --test it command_sequence_fuzz::`.
//! - `GRIDCRAFT_FUZZ_SEED=17`: replay that one seed (the failure message prints the command line).
//! - `GRIDCRAFT_FUZZ_SHRINK=1`: on failure, shrink the sequence to a minimal list of commands that
//! still fails the same way, printed as `execute` calls ready for a regression test.
@@ -139,7 +139,7 @@ fn report(seed: u64, steps: usize, sample: &str, sequence: &[Step], f: &Failure)
None => format!("end-of-run checks after {} steps", sequence.len()),
};
let mut out = format!(
"command-sequence fuzz failure: seed {seed} (sample `{sample}`), {at}\n invariant [{}]: {}\n replay: GRIDCRAFT_FUZZ_SEED={seed} GRIDCRAFT_FUZZ_STEPS={steps} cargo test -p gridcraft-engine --test command_sequence_fuzz\n",
"command-sequence fuzz failure: seed {seed} (sample `{sample}`), {at}\n invariant [{}]: {}\n replay: GRIDCRAFT_FUZZ_SEED={seed} GRIDCRAFT_FUZZ_STEPS={steps} cargo test -p gridcraft-engine --test it command_sequence_fuzz::\n",
f.kind, f.what
);
let end = (f.step + 1).min(sequence.len());
@@ -233,7 +233,7 @@ fn guarded<T>(step: usize, kind: &'static str, f: impl FnOnce() -> Result<T, Str
Ok(Ok(v)) => Ok(v),
Ok(Err(e)) => Err(fail(step, kind, e)),
Err(p) => {
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
Err(fail(step, kind, format!("panicked: {msg}")))
}
}
@@ -26,7 +26,7 @@ fn opened() -> Session {
let bytes = io::save_bytes(&wb, "dynamic.xlsx").unwrap();
let (back, _) = io::open_bytes("dynamic.xlsx", &bytes).unwrap();
let mut s = Session::new();
s.add_document(DocState::new(back, None, "dynamic".into()));
s.add_document(DocState::new(back, None, "dynamic".into(), gridcraft_engine::Trust::FromFile));
s
}
@@ -0,0 +1,29 @@
//! A font a formatting command derives from the theme's body font keeps following it (#2517).
use std::io::Read;
use gridcraft_engine::Session;
use serde_json::json;
fn styles_xml(session: &Session) -> String {
let bytes = gridcraft_xlsx::write_xlsx(&session.doc().unwrap().wb).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
zip.by_name("xl/styles.xml").unwrap().read_to_string(&mut xml).unwrap();
xml
}
#[test]
fn derived_body_font_keeps_minor_scheme() {
let mut session = Session::new();
session.new_workbook();
session.execute("home.bold", json!({"range": "A1"})).unwrap();
session.execute("home.fontSize", json!({"range": "B1", "size": 14})).unwrap();
let xml = styles_xml(&session);
let fonts = xml.split("<fonts").nth(1).unwrap().split("</fonts>").next().unwrap();
let fonts: Vec<&str> = fonts.split("<font>").skip(1).collect();
assert!(fonts.len() >= 3, "{xml}");
for f in &fonts {
assert!(f.contains("<scheme val=\"minor\"/>"), "every body-font font follows the theme: {f}");
}
}
@@ -0,0 +1,61 @@
use gridcraft_engine::Session;
use serde_json::json;
fn table() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range":"A1", "values":[["Item", "Qty"], ["x", "7"], ["y", "8"]]})).unwrap();
s.execute("insert.table", json!({"range":"A1:B3", "header":true, "name":"T"})).unwrap();
s
}
fn styles(s: &Session) -> Vec<String> {
s.doc().unwrap().wb.active().unwrap().tables.iter().map(|t| t.style.clone()).collect()
}
#[test]
fn format_as_table_inside_a_table_restyles_it() {
let mut s = table();
s.execute("selection.set", json!({"range":"B2"})).unwrap();
s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap();
assert_eq!(styles(&s), ["TableStyleLight9"]);
}
#[test]
fn format_as_table_on_a_range_inside_a_table_restyles_it() {
let mut s = table();
s.execute("home.formatAsTable", json!({"range":"A2:B3", "style":"TableStyleDark3"})).unwrap();
assert_eq!(styles(&s), ["TableStyleDark3"]);
}
#[test]
fn format_as_table_across_a_table_edge_is_rejected() {
let mut s = table();
let e = s.execute("home.formatAsTable", json!({"range":"A1:C3", "style":"TableStyleLight9"})).unwrap_err();
assert!(e.to_string().contains("overlap"), "{e}");
assert_eq!(styles(&s), ["TableStyleMedium2"]);
}
#[test]
fn format_as_table_inside_a_table_returns_that_table() {
let mut s = table();
let r = s.execute("home.formatAsTable", json!({"range":"B2", "style":"TableStyleLight9"})).unwrap();
assert_eq!(r, json!({"table":"T", "range":"A1:B3"}));
}
#[test]
fn format_as_table_uses_the_active_area_of_a_multi_area_selection() {
let mut s = table();
s.execute("selection.set", json!({"range":"D10:E11,B2"})).unwrap();
s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap();
assert_eq!(styles(&s), ["TableStyleLight9"]);
}
#[test]
fn format_as_table_inside_a_table_with_data_beside_it_restyles_it() {
let mut s = table();
s.execute("range.setValues", json!({"range":"C1", "values":[["Note"], ["a"], ["b"]]})).unwrap();
s.execute("selection.set", json!({"range":"B2"})).unwrap();
s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap();
assert_eq!(styles(&s), ["TableStyleLight9"]);
}
@@ -78,7 +78,7 @@ fn longest_flat_chains_survive_editing_saving_and_reopening() {
let bytes = io::save_bytes(wb, "chains.xlsx").expect("save");
let (reopened, _) = io::open_bytes("chains.xlsx", &bytes).expect("open");
let mut session = Session::new();
session.add_document(DocState::new(reopened, None, "chains".into()));
session.add_document(DocState::new(reopened, None, "chains".into(), gridcraft_engine::Trust::FromFile));
session.execute("formulas.calculateNow", json!({})).expect("recalc");
assert_eq!(value(&session, "C2"), Value::Number(4096.0));
assert_eq!(value(&session, "C3"), Value::Number(-4094.0));
+31
View File
@@ -0,0 +1,31 @@
//! The crate's integration tests, one module per file, built as one test binary.
mod autofit_filter_buttons;
mod cf_formula_validate;
mod chart_area_stacked100;
mod chart_workbook_names;
mod command_sequence_fuzz;
mod cross_sheet_cut;
mod csv_export_budget;
mod custom_filter_blanks;
mod custom_view_freeze;
mod dynamicmetadata;
mod extreme_selection_moves;
mod font_scheme_minor;
mod format_as_table_restyle;
mod hidden_sheet_navigation;
mod ink_roundtrip;
mod line_count_overflow;
mod long_operator_chains;
mod offset_overflow;
mod percent_entry;
mod pivot_unknown_func;
mod pivotchart_link;
mod range_fill_formats;
mod row_autofit;
mod sheet_name_quoting;
mod sparse_clear_invalidation;
mod stored_list_shape;
mod table_resize_overlap;
mod table_total_row_last_row;
mod wrap_row_height;
@@ -6,7 +6,8 @@ const NOT_OBJECTS: [&str; 4] = ["\"[1,2]\"", "\"7\"", "\"true\"", "\"\"\"hi\"\"\
fn with_name(name: &str, refers_to: &str) -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers_to})).unwrap();
// `force`: the name commands refuse GridCraft's own data names otherwise.
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers_to, "force": true})).unwrap();
s
}