diff --git a/crates/engine/src/cmd/solver/reports.rs b/crates/engine/src/cmd/solver/reports.rs index 19500cab0..c2a5c6d84 100644 --- a/crates/engine/src/cmd/solver/reports.rs +++ b/crates/engine/src/cmd/solver/reports.rs @@ -52,14 +52,19 @@ pub(super) fn abs_ref(c: CellRef) -> String { format!("${}${}", gridcraft_core::col_to_letters(c.col), u64::from(c.row) + 1) } +/// How many cells to the left and above a cell its name is searched for. +const NAME_REACH: u32 = 1_000; + /// A cell's name in the reports: the nearest text to its left and the nearest text above it. pub(super) fn cell_name(sh: &Sheet, c: CellRef) -> String { let text = |c: CellRef| match sh.value(c) { Value::Text(t) if !t.trim().is_empty() => Some(t.to_string()), _ => None, }; - let left = (0..c.col).rev().find_map(|col| text(CellRef::new(c.row, col))); - let top = (0..c.row).rev().find_map(|row| text(CellRef::new(row, c.col))); + // Labels are next to their cells: looking further than this on a sparse sheet only costs time + // (a thousand cells near the last row would each probe a million empty coordinates). + let left = (c.col.saturating_sub(NAME_REACH)..c.col).rev().find_map(|col| text(CellRef::new(c.row, col))); + let top = (c.row.saturating_sub(NAME_REACH)..c.row).rev().find_map(|row| text(CellRef::new(row, c.col))); [left, top].into_iter().flatten().collect::>().join(" ") } @@ -438,3 +443,17 @@ pub(super) fn report(s: &mut Session, p: &Json) -> Result { } Ok(out) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_name_is_searched_only_a_bounded_distance() { + let mut sh = Sheet::new("S".to_string()); + sh.set_cell(CellRef::new(0, 0), gridcraft_model::Cell::value(Value::text("Far"))); + sh.set_cell(CellRef::new(5, 4), gridcraft_model::Cell::value(Value::text("Near"))); + assert_eq!(cell_name(&sh, CellRef::new(5, 5)), "Near"); + assert_eq!(cell_name(&sh, CellRef::new(0, NAME_REACH + 1)), ""); + } +} diff --git a/crates/engine/src/cmd/whatif/scenarios.rs b/crates/engine/src/cmd/whatif/scenarios.rs index 75bf147a0..cd779b2fb 100644 --- a/crates/engine/src/cmd/whatif/scenarios.rs +++ b/crates/engine/src/cmd/whatif/scenarios.rs @@ -189,6 +189,9 @@ pub(super) fn scenario_delete(s: &mut Session, p: &Json) -> Result { Ok(json!({"deleted": removed.name})) } +/// Most changing-cell-by-scenario cells one Scenario Summary writes. +const MAX_SUMMARY_CELLS: usize = 1_000_000; + pub(super) fn scenario_summary(s: &mut Session, p: &Json) -> Result { const CMD: &str = "data.scenarioSummary"; let d = s.doc()?; @@ -211,17 +214,23 @@ pub(super) fn scenario_summary(s: &mut Session, p: &Json) -> Result { // Changing cells of every scenario, first-seen order; per scenario its values. let mut changing: Vec = Vec::new(); let mut per: Vec> = Vec::new(); + let mut seen: std::collections::HashSet = Default::default(); for sc in &scenarios { let (_, cells) = cell_list(&d.wb, &sc.changing, CMD, MAX_SCENARIO_CELLS)?; let mut m = HashMap::new(); for (c, v) in cells.iter().zip(sc.values.iter()) { - if !changing.contains(c) { + if seen.insert(*c) { changing.push(*c); } m.insert(*c, json_value(v, &d.wb)); } per.push(m); } + // The summary has a cell for every changing cell in every scenario, filled from the current + // value where a scenario doesn't set it, so small disjoint scenarios multiply. + if changing.len().saturating_mul(per.len()) > MAX_SUMMARY_CELLS { + return Err(bad(CMD, "the scenarios together change too many cells for a summary")); + } let current: Vec = changing.iter().map(|c| sh.value(*c)).collect(); let current_results: Vec = results.iter().map(|c| sh.value(*c)).collect(); let mut trial = Trial::new(d); diff --git a/crates/engine/src/cmd/whatif/tests.rs b/crates/engine/src/cmd/whatif/tests.rs index c1476e401..1c7f8c6f5 100644 --- a/crates/engine/src/cmd/whatif/tests.rs +++ b/crates/engine/src/cmd/whatif/tests.rs @@ -297,3 +297,14 @@ fn consolidate_by_position_and_label() { assert!(s.execute("data.consolidate", json!({"sources": []})).is_err()); assert!(s.execute("data.consolidate", json!({"sources": ["A1"], "function": "median"})).is_err()); } + +#[test] +fn scenario_summary_refuses_a_matrix_over_its_cell_limit() { + let mut s = s(); + // 200 scenarios of 32 disjoint cells: 6,400 changing cells x 200 scenarios = 1.28 million cells. + for i in 0..200 { + let (a, b) = (i * 32 + 1, i * 32 + 32); + s.execute("data.scenarioAdd", json!({"name": format!("S{i}"), "changing": format!("A{a}:A{b}")})).unwrap(); + } + assert!(s.execute("data.scenarioSummary", json!({})).is_err()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6f61b239e..8c79c9b44 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -237,6 +237,19 @@ const JOURNAL_MAX_ENTRIES: usize = 10_000; pub(crate) const JOURNAL_MAX_BYTES: usize = 64 * 1024 * 1024; const UNDO_BYTES_LIMIT: usize = 128 * 1024 * 1024; +/// Comment and hyperlink text of a sheet: a changed sheet's snapshot keeps its own copy of these +/// strings (an edit deep-copies them), so they count against the history budget with the cells. +fn sheet_text_bytes(sheet: &gridcraft_model::Sheet) -> usize { + let comments = sheet.comments.values().fold(0usize, |sum, c| { + let replies = c.replies.iter().fold(0usize, |n, (a, t)| n.saturating_add(a.len()).saturating_add(t.len())); + let dates = c.dates.iter().fold(0usize, |n, d| n.saturating_add(d.len())); + sum.saturating_add(std::mem::size_of::() + c.author.len() + c.text.len() + replies + dates) + }); + sheet.hyperlinks.values().fold(comments, |sum, h| { + sum.saturating_add(std::mem::size_of::() + h.target.len() + h.tooltip.as_ref().map_or(0, String::len)) + }) +} + fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize { let mut bytes = std::mem::size_of::(); for (index, sheet) in older.sheets.iter().enumerate() { @@ -248,6 +261,7 @@ fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize { let empty = gridcraft_model::CellStore::new(); let next = newer.sheets.get(index).map_or(&empty, |next| &next.cells); bytes = bytes.saturating_add(sheet.cells.retained_bytes_against(next)); + bytes = bytes.saturating_add(sheet_text_bytes(sheet)); } // Snapshots share the macro list until a change to it; a snapshot with its own list keeps it. if !older.macros.shares(&newer.macros) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fe42b6977..794134538 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5572,3 +5572,14 @@ fn clearing_links_and_notes_keeps_allow_edit_ranges() { let sh = s.doc().unwrap().wb.active().unwrap().clone(); assert!(sh.hyperlinks.is_empty() && sh.comments.is_empty()); } + +#[test] +fn history_charges_a_changed_sheets_comment_text() { + let mut s = s(); + s.execute("review.newNote", json!({"cell": "A1", "text": "n".repeat(30_000), "author": "Example"})).unwrap(); + let older = (*s.doc().unwrap().wb).clone(); + s.execute("cell.set", json!({"cell": "B2", "input": "x"})).unwrap(); + let newer = (*s.doc().unwrap().wb).clone(); + // The older snapshot owns the comment text now that the sheet changed under it. + assert!(crate::history_retained_bytes(&older, &newer) >= 30_000); +}