use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use truecalc_workbook::{
Address, Cell, CellInput, EngineFlavor, RecalcContext, Value, Workbook, Worksheet,
};
fn addr(a1: &str) -> Address {
Address::from_a1(a1).expect("valid A1")
}
fn at(row: u32, col: u32) -> Address {
Address::new(row, col).expect("in-bounds address")
}
fn ctx() -> RecalcContext {
RecalcContext::new(1_780_878_600_000, "Etc/GMT", 0).expect("Etc/GMT is a valid tz")
}
fn ctx_later() -> RecalcContext {
RecalcContext::new(1_780_878_600_000 + 86_400_000, "Etc/GMT", 0).expect("valid tz")
}
fn fresh(wb: &Workbook) -> Workbook {
let json = wb.to_json().expect("serializable");
let out = match Workbook::from_json(json.as_bytes()) {
Ok(loaded) => loaded,
Err(_) => {
let mut copy = wb.clone();
let _ = copy.sheets_mut();
copy
}
};
assert!(
!out.graph_cache_is_warm(),
"a freshly loaded workbook must hold no graph"
);
out
}
fn hash_of(wb: &Workbook) -> u64 {
let mut h = DefaultHasher::new();
wb.hash(&mut h);
h.finish()
}
fn small_workbook() -> Workbook {
let mut wb = Workbook::new(EngineFlavor::Sheets);
wb.add_sheet(Worksheet::new("S")).unwrap();
wb.add_sheet(Worksheet::new("T")).unwrap();
wb.define_name("RATE", "S!A1").unwrap();
wb.set("S", addr("A1"), CellInput::Literal(Value::Number(2.0)))
.unwrap();
wb.set("S", addr("A2"), CellInput::Literal(Value::Number(3.0)))
.unwrap();
wb.set("S", addr("B1"), CellInput::Formula("=A1+A2".into()))
.unwrap();
wb.set(
"S",
addr("B2"),
CellInput::Formula("=SUM(A1:A2)*RATE".into()),
)
.unwrap();
wb.set("T", addr("A1"), CellInput::Formula("=S!B1+S!B2".into()))
.unwrap();
wb
}
#[test]
fn a_first_recalc_builds_one_graph_and_repeats_build_none() {
let mut wb = small_workbook();
assert_eq!(wb.graph_builds(), 0);
wb.recalc(&ctx());
assert_eq!(wb.graph_builds(), 1, "the first recalc builds the graph");
for _ in 0..20 {
wb.recalc(&ctx());
}
assert_eq!(
wb.graph_builds(),
1,
"recalculating an unchanged workbook must not rebuild the graph"
);
}
#[test]
fn an_incremental_recalc_reuses_the_same_cached_graph_as_a_full_one() {
let mut wb = small_workbook();
wb.recalc(&ctx());
wb.recalc_incremental(&ctx(), &[("S".to_owned(), addr("A1"))]);
wb.recalc(&ctx());
assert_eq!(
wb.graph_builds(),
1,
"the two recalc paths share one cache, not one each"
);
}
#[test]
fn writing_a_literal_over_a_literal_keeps_the_graph() {
let mut wb = small_workbook();
wb.recalc(&ctx());
wb.set("S", addr("A1"), CellInput::Literal(Value::Number(9.0)))
.unwrap();
assert!(wb.graph_cache_is_warm(), "a literal write adds no node");
wb.recalc(&ctx());
assert_eq!(wb.graph_builds(), 1);
}
#[test]
fn writing_a_literal_into_an_empty_cell_keeps_the_graph() {
let mut wb = small_workbook();
wb.recalc(&ctx());
wb.set("S", addr("D9"), CellInput::Literal(Value::Number(1.0)))
.unwrap();
assert!(
wb.graph_cache_is_warm(),
"a new literal cell is not a graph node"
);
}
#[test]
fn clearing_a_literal_keeps_the_graph() {
let mut wb = small_workbook();
wb.recalc(&ctx());
wb.clear("S", addr("A1"));
assert!(wb.graph_cache_is_warm(), "a literal is not a graph node");
}
#[test]
fn writing_a_formula_rebuilds_the_graph() {
let mut wb = small_workbook();
wb.recalc(&ctx());
wb.set("S", addr("B1"), CellInput::Formula("=A2*10".into()))
.unwrap();
assert!(!wb.graph_cache_is_warm());
wb.recalc(&ctx());
assert_eq!(wb.graph_builds(), 2);
}
#[test]
fn writing_a_literal_over_a_formula_rebuilds_the_graph() {
let mut wb = small_workbook();
wb.recalc(&ctx());
wb.set("S", addr("B1"), CellInput::Literal(Value::Number(4.0)))
.unwrap();
assert!(
!wb.graph_cache_is_warm(),
"replacing a formula with a literal removes a node"
);
}
#[test]
fn clearing_a_formula_rebuilds_the_graph() {
let mut wb = small_workbook();
wb.recalc(&ctx());
wb.clear("S", addr("B1"));
assert!(!wb.graph_cache_is_warm());
}
#[test]
fn every_name_operation_rebuilds_the_graph() {
for op in ["define", "redefine", "remove"] {
let mut wb = small_workbook();
wb.recalc(&ctx());
match op {
"define" => {
wb.define_name("OTHER", "S!A2").unwrap();
}
"redefine" => {
wb.redefine_name("RATE", "S!A2").unwrap();
}
_ => {
wb.remove_name("RATE").unwrap();
}
}
assert!(!wb.graph_cache_is_warm(), "{op} name must invalidate");
}
}
#[test]
fn every_sheet_operation_rebuilds_the_graph() {
for op in ["add", "rename", "remove", "move"] {
let mut wb = small_workbook();
wb.recalc(&ctx());
match op {
"add" => {
wb.add_sheet(Worksheet::new("U")).unwrap();
}
"rename" => {
wb.rename_sheet("T", "T2").unwrap();
}
"remove" => {
wb.remove_sheet("T").unwrap();
}
_ => {
wb.move_sheet(0, 1).unwrap();
}
}
assert!(!wb.graph_cache_is_warm(), "{op} sheet must invalidate");
}
}
#[test]
fn every_table_operation_rebuilds_the_graph() {
for op in ["define", "redefine", "remove"] {
let mut wb = table_workbook();
wb.recalc(&ctx());
match op {
"define" => {
wb.define_table("Second", "T!A1:A2").unwrap();
}
"redefine" => {
wb.redefine_table("TBL", "S!A1:B4").unwrap();
}
_ => {
wb.remove_table("TBL").unwrap();
}
}
assert!(!wb.graph_cache_is_warm(), "{op} table must invalidate");
}
}
#[test]
fn every_mutable_accessor_invalidates_on_the_borrow() {
let mut wb = small_workbook();
wb.recalc(&ctx());
let _ = wb.sheets_mut();
assert!(!wb.graph_cache_is_warm(), "sheets_mut");
let mut wb = small_workbook();
wb.recalc(&ctx());
let _ = wb.sheet_mut("S");
assert!(!wb.graph_cache_is_warm(), "sheet_mut");
let mut wb = small_workbook();
wb.recalc(&ctx());
let _ = wb.names_mut();
assert!(!wb.graph_cache_is_warm(), "names_mut");
let mut wb = small_workbook();
wb.recalc(&ctx());
let _ = wb.tables_mut();
assert!(!wb.graph_cache_is_warm(), "tables_mut");
}
#[test]
fn a_formula_written_through_sheet_mut_is_seen_by_the_next_recalc() {
let mut wb = small_workbook();
wb.recalc(&ctx());
wb.sheet_mut("S")
.unwrap()
.set(addr("C1"), Cell::with_formula("=A1*100", Value::Empty));
wb.recalc(&ctx());
assert_eq!(
wb.get("S", addr("C1")).unwrap().value(),
&Value::Number(200.0),
"a formula authored through sheet_mut must be evaluated"
);
}
fn table_workbook() -> Workbook {
let mut wb = Workbook::new(EngineFlavor::Sheets);
wb.add_sheet(Worksheet::new("S")).unwrap();
wb.add_sheet(Worksheet::new("T")).unwrap();
wb.set(
"S",
addr("A1"),
CellInput::Literal(Value::Text("qty".into())),
)
.unwrap();
wb.set(
"S",
addr("B1"),
CellInput::Literal(Value::Text("price".into())),
)
.unwrap();
wb.set("S", addr("A2"), CellInput::Literal(Value::Number(1.0)))
.unwrap();
wb.set("S", addr("A3"), CellInput::Literal(Value::Number(2.0)))
.unwrap();
wb.set("S", addr("B2"), CellInput::Literal(Value::Number(10.0)))
.unwrap();
wb.set("S", addr("B3"), CellInput::Literal(Value::Number(20.0)))
.unwrap();
wb.define_table("TBL", "S!A1:B3").unwrap();
wb.set("T", addr("A1"), CellInput::Formula("=SUM(TBL[qty])".into()))
.unwrap();
wb
}
#[test]
fn header_text_written_as_a_literal_moves_a_structured_reference() {
let mut warm = table_workbook();
warm.recalc(&ctx());
assert_eq!(
warm.get("T", addr("A1")).unwrap().value(),
&Value::Number(3.0)
);
warm.set(
"S",
addr("A1"),
CellInput::Literal(Value::Text("other".into())),
)
.unwrap();
warm.set(
"S",
addr("B1"),
CellInput::Literal(Value::Text("qty".into())),
)
.unwrap();
assert!(
!warm.graph_cache_is_warm(),
"a literal write in a table-bearing workbook must invalidate"
);
let mut cold = fresh(&warm);
warm.recalc(&ctx());
cold.recalc(&ctx());
assert_eq!(
warm.to_json().unwrap(),
cold.to_json().unwrap(),
"the warm arm must follow the header text to column B"
);
assert_eq!(
warm.get("T", addr("A1")).unwrap().value(),
&Value::Number(30.0),
"TBL[qty] now names column B"
);
}
#[test]
fn a_recomputed_header_value_invalidates_the_graph() {
let mut wb = Workbook::new(EngineFlavor::Sheets);
wb.add_sheet(Worksheet::new("S")).unwrap();
wb.set(
"S",
addr("A1"),
CellInput::Formula("=IF(YEAR(TODAY())>2023,\"qty\",\"zzz\")".into()),
)
.unwrap();
wb.set(
"S",
addr("B1"),
CellInput::Literal(Value::Text("qty".into())),
)
.unwrap();
wb.set("S", addr("A2"), CellInput::Literal(Value::Number(1.0)))
.unwrap();
wb.set("S", addr("A3"), CellInput::Literal(Value::Number(2.0)))
.unwrap();
wb.set("S", addr("B2"), CellInput::Literal(Value::Number(10.0)))
.unwrap();
wb.set("S", addr("B3"), CellInput::Literal(Value::Number(20.0)))
.unwrap();
wb.define_table("TBL", "S!A1:B3").unwrap();
wb.set("S", addr("D1"), CellInput::Formula("=SUM(TBL[qty])".into()))
.unwrap();
let old = RecalcContext::new(1_600_000_000_000, "Etc/GMT", 0).unwrap();
let new = RecalcContext::new(1_780_878_600_000, "Etc/GMT", 0).unwrap();
wb.recalc(&old);
wb.recalc(&old);
assert_eq!(
wb.get("S", addr("D1")).unwrap().value(),
&Value::Number(30.0)
);
wb.recalc(&new);
wb.recalc(&new);
assert_eq!(
wb.get("S", addr("D1")).unwrap().value(),
&Value::Number(3.0)
);
let mut cold = fresh(&wb);
wb.recalc_incremental(&old, &[]);
cold.recalc_incremental(&old, &[]);
assert_eq!(
wb.to_json().unwrap(),
cold.to_json().unwrap(),
"a recomputed header value must not leave a stale graph behind"
);
assert_eq!(
wb.get("S", addr("D1")).unwrap().value(),
&Value::Number(30.0),
"TBL[qty] must follow the volatile header back to column B"
);
}
#[test]
fn a_warm_cache_changes_nothing_a_caller_can_observe() {
let mut warm = small_workbook();
warm.recalc(&ctx());
let cold = fresh(&warm);
assert_eq!(warm, cold, "the cache must not participate in equality");
assert_eq!(
hash_of(&warm),
hash_of(&cold),
"the cache must not participate in Hash"
);
assert_eq!(
warm.to_json().unwrap(),
cold.to_json().unwrap(),
"the cache must not be serialized"
);
assert!(warm.graph_cache_is_warm() && !cold.graph_cache_is_warm());
}
#[test]
fn a_clone_of_a_warm_workbook_stays_independent() {
let mut original = small_workbook();
original.recalc(&ctx());
let mut copy = original.clone();
copy.set("S", addr("C1"), CellInput::Formula("=A1*1000".into()))
.unwrap();
copy.recalc(&ctx());
assert_eq!(
copy.get("S", addr("C1")).unwrap().value(),
&Value::Number(2000.0)
);
assert!(original.get("S", addr("C1")).is_none());
let mut cold = fresh(&original);
original.recalc(&ctx());
cold.recalc(&ctx());
assert_eq!(original.to_json().unwrap(), cold.to_json().unwrap());
}
struct Rng(u64);
impl Rng {
fn new(seed: u64) -> Self {
Self(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0x5EED_1234_ABCD_9876)
}
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn below(&mut self, n: usize) -> usize {
(self.next_u64() % n as u64) as usize
}
fn pick_str(&mut self, xs: &[&'static str]) -> &'static str {
xs[self.below(xs.len())]
}
}
const BASE_SHEETS: [&str; 2] = ["S", "T"];
const ROWS: u32 = 6;
const COLS: u32 = 4;
const TABLE_REF: &str = "S!A1:B3";
const HEADERS: [&str; 3] = ["qty", "price", "cost"];
const NAME_REFS: [&str; 5] = ["S!C1", "S!C1:C4", "T!B2", "T!B1:C3", "S!D4"];
fn ref_text(rng: &mut Rng, own_sheet: &str) -> String {
let sheet = rng.pick_str(&["S", "T", "X"]);
let row = if sheet == "S" {
4 + rng.below(3) as u32
} else {
1 + rng.below(ROWS as usize) as u32
};
let a = Address::new(row, 1 + rng.below(COLS as usize) as u32).expect("in bounds");
if sheet == own_sheet {
a.to_a1()
} else {
format!("{sheet}!{}", a.to_a1())
}
}
fn formula(rng: &mut Rng, own_sheet: &str) -> String {
match rng.below(10) {
0 => format!("={}+{}", ref_text(rng, own_sheet), ref_text(rng, own_sheet)),
1 => format!("={}*2", ref_text(rng, own_sheet)),
2 => format!("=SUM(T!A1:C3)+{}", ref_text(rng, own_sheet)),
3 => "=SUM(TBL[qty])".to_owned(),
4 => "=COUNT(TBL[price])".to_owned(),
5 => "=SUM(TBL[cost])".to_owned(),
6 => "=NAMEA+1".to_owned(),
7 => "=SUM(NAMEA)".to_owned(),
8 => "=TODAY()".to_owned(),
_ => format!("={{1;2}}+{}", ref_text(rng, own_sheet)),
}
}
fn free_cell(rng: &mut Rng, wb: &Workbook) -> (&'static str, Address) {
let sheet = if wb.sheet("T").is_some() {
rng.pick_str(&BASE_SHEETS)
} else {
"S"
};
let row = if sheet == "S" {
4 + rng.below(3) as u32
} else {
1 + rng.below(ROWS as usize) as u32
};
(
sheet,
Address::new(row, 1 + rng.below(COLS as usize) as u32).expect("in bounds"),
)
}
fn cells_of_ref(r: &str) -> Vec<(String, Address)> {
let (sheet, a1) = r.split_once('!').expect("canonical ref carries a sheet");
let (start, end) = match a1.split_once(':') {
Some((s, e)) => (addr(s), addr(e)),
None => {
let a = addr(a1);
(a, a)
}
};
let mut out = Vec::new();
for row in start.row..=end.row {
for col in start.column..=end.column {
out.push((sheet.to_owned(), at(row, col)));
}
}
out
}
fn build_workbook(rng: &mut Rng) -> Workbook {
let mut wb = Workbook::new(EngineFlavor::Sheets);
for s in BASE_SHEETS {
wb.add_sheet(Worksheet::new(s)).unwrap();
}
for (i, h) in HEADERS.iter().take(2).enumerate() {
wb.set(
"S",
at(1, 1 + i as u32),
CellInput::Literal(Value::Text((*h).to_owned())),
)
.unwrap();
}
for row in 2..=3u32 {
for col in 1..=2u32 {
wb.set(
"S",
at(row, col),
CellInput::Literal(Value::Number(rng.below(9) as f64)),
)
.unwrap();
}
}
wb.define_table("TBL", TABLE_REF).unwrap();
wb.define_name("NAMEA", NAME_REFS[rng.below(NAME_REFS.len())])
.unwrap();
for sheet in BASE_SHEETS {
let first_row = if sheet == "S" { 4 } else { 1 };
for row in first_row..=ROWS {
for col in 1..=COLS {
match rng.below(8) {
0 | 1 => {}
2 | 3 => {
wb.set(
sheet,
at(row, col),
CellInput::Literal(Value::Number(rng.below(9) as f64)),
)
.unwrap();
}
_ => {
let f = formula(rng, sheet);
wb.set(sheet, at(row, col), CellInput::Formula(f.clone()))
.unwrap_or_else(|e| panic!("generated formula {f} rejected: {e:?}"));
}
}
}
}
}
wb.set(
"S",
at(ROWS, COLS),
CellInput::Formula("=IFERROR(X!A1,42)".to_owned()),
)
.unwrap();
wb
}
fn apply_edit(rng: &mut Rng, wb: &mut Workbook) -> Vec<(String, Address)> {
match rng.below(13) {
0 | 1 | 2 => {
let (sheet, a) = free_cell(rng, wb);
wb.set(
sheet,
a,
CellInput::Literal(Value::Number(rng.below(20) as f64)),
)
.unwrap();
vec![(sheet.to_owned(), a)]
}
3 | 4 => {
let (sheet, a) = free_cell(rng, wb);
let f = formula(rng, sheet);
wb.set(sheet, a, CellInput::Formula(f.clone()))
.unwrap_or_else(|e| panic!("generated formula {f} rejected: {e:?}"));
vec![(sheet.to_owned(), a)]
}
5 => {
let (sheet, a) = free_cell(rng, wb);
wb.clear(sheet, a);
vec![(sheet.to_owned(), a)]
}
6 => {
let a = at(1, 1);
let b = at(1, 2);
let ha = wb.get("S", a).and_then(|c| match c.value() {
Value::Text(t) => Some(t.clone()),
_ => None,
});
let hb = wb.get("S", b).and_then(|c| match c.value() {
Value::Text(t) => Some(t.clone()),
_ => None,
});
let (ha, hb) = (
ha.unwrap_or_else(|| HEADERS[0].to_owned()),
hb.unwrap_or_else(|| HEADERS[1].to_owned()),
);
let next = |h: &str| -> String {
let i = HEADERS.iter().position(|x| *x == h).unwrap_or(0);
HEADERS[(i + 1) % HEADERS.len()].to_owned()
};
let (na, nb) = if next(&ha) == hb {
(hb.clone(), ha.clone())
} else {
(next(&ha), hb.clone())
};
wb.set("S", a, CellInput::Literal(Value::Text(na))).unwrap();
wb.set("S", b, CellInput::Literal(Value::Text(nb))).unwrap();
vec![("S".to_owned(), a), ("S".to_owned(), b)]
}
7 => {
let old_ref = wb.name("NAMEA").map(|n| n.r#ref.clone()).expect("defined");
let candidates: Vec<&str> = NAME_REFS
.iter()
.copied()
.filter(|r| wb.sheet("T").is_some() || !r.starts_with("T!"))
.collect();
let new_ref = candidates[rng.below(candidates.len())];
wb.redefine_name("NAMEA", new_ref).unwrap();
let mut edited = cells_of_ref(&old_ref);
edited.extend(cells_of_ref(new_ref));
edited
}
8 => {
let adding = wb.sheet("X").is_none();
if adding {
wb.add_sheet(Worksheet::new("X")).unwrap();
} else {
wb.remove_sheet("X");
}
let mut edited = Vec::new();
for row in 1..=ROWS {
for col in 1..=COLS {
edited.push(("X".to_owned(), at(row, col)));
}
}
edited
}
9 => {
let (sheet, a) = free_cell(rng, wb);
let f = formula(rng, sheet);
wb.sheet_mut(sheet)
.unwrap()
.set(a, Cell::with_formula(f, Value::Empty));
vec![(sheet.to_owned(), a)]
}
10 => {
wb.redefine_table("TBL", TABLE_REF).unwrap();
vec![("S".to_owned(), at(1, 1))]
}
11 => {
let (from, to) = if wb.sheet("T").is_some() {
("T", "T2")
} else {
("T2", "T")
};
wb.rename_sheet(from, to).unwrap();
let mut edited = Vec::new();
for row in 1..=ROWS {
for col in 1..=COLS {
edited.push((from.to_owned(), at(row, col)));
}
}
edited
}
_ => {
let (sheet, a) = free_cell(rng, wb);
wb.set(
sheet,
a,
CellInput::Literal(Value::Text(format!("t{}", rng.below(5)))),
)
.unwrap();
vec![(sheet.to_owned(), a)]
}
}
}
fn run_seed(seed: u64) -> Result<(), String> {
let mut rng = Rng::new(seed);
let mut warm = build_workbook(&mut rng);
let mut cold = fresh(&warm);
warm.recalc(&ctx());
cold.recalc(&ctx());
if warm.to_json().unwrap() != cold.to_json().unwrap() {
return Err(format!(
"seed={seed} step=build\nwarm: {}\nfresh: {}",
warm.to_json().unwrap(),
cold.to_json().unwrap()
));
}
for step in 0..8 {
let edited = apply_edit(&mut rng, &mut warm);
let c = if step % 3 == 2 { ctx_later() } else { ctx() };
let mut cold = fresh(&warm);
if step % 2 == 0 {
warm.recalc(&c);
cold.recalc(&c);
} else {
warm.recalc_incremental(&c, &edited);
cold.recalc_incremental(&c, &edited);
}
let got = warm.to_json().unwrap();
let want = cold.to_json().unwrap();
if got != want {
let touched: Vec<String> = edited
.iter()
.map(|(s, a)| format!("{s}!{}", a.to_a1()))
.collect();
return Err(format!(
"seed={seed} step={step} edited={touched:?}\nwarm: {got}\nfresh: {want}"
));
}
}
Ok(())
}
fn seed_count() -> u64 {
std::env::var("TRUECALC_CACHE_SEEDS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(300)
}
fn seed_base() -> u64 {
std::env::var("TRUECALC_CACHE_SEED_BASE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0)
}
#[test]
fn warm_and_fresh_recalculations_agree() {
let base = seed_base();
let n = seed_count();
let mut failures = 0usize;
let mut reports: Vec<String> = Vec::new();
for seed in base..base + n {
if let Err(report) = run_seed(seed) {
failures += 1;
println!(" warm/fresh divergence at seed {seed}");
if reports.len() < 3 {
reports.push(report);
}
}
}
println!("warm-vs-fresh: {failures}/{n} divergences");
assert!(
failures == 0,
"{failures} divergence(s) over {n} seeds from {base}:\n\n{}",
reports.join("\n\n")
);
}
#[test]
fn the_generator_produces_the_constructs_it_claims() {
let mut saw_structured = false;
let mut saw_name = false;
let mut saw_volatile = false;
let mut saw_array = false;
let mut saw_cross_sheet = false;
let mut saw_missing_sheet_ref = false;
for seed in 0..60u64 {
let mut rng = Rng::new(seed);
let wb = build_workbook(&mut rng);
assert!(
!wb.tables().is_empty(),
"every generated workbook has a table"
);
for sheet in BASE_SHEETS {
for row in 1..=ROWS {
for col in 1..=COLS {
let Some(f) = wb.get(sheet, at(row, col)).and_then(|c| c.formula()) else {
continue;
};
saw_structured |= f.contains("TBL[");
saw_name |= f.contains("NAMEA");
saw_volatile |= f.contains("TODAY");
saw_array |= f.contains('{');
saw_cross_sheet |= f.contains("T!") || f.contains("S!");
saw_missing_sheet_ref |= f.contains("X!");
}
}
}
}
assert!(saw_structured, "no structured references generated");
assert!(saw_name, "no name references generated");
assert!(saw_volatile, "no volatile cells generated");
assert!(saw_array, "no array literals generated");
assert!(saw_cross_sheet, "no cross-sheet references generated");
assert!(
saw_missing_sheet_ref,
"no references to the add/remove sheet generated"
);
}
#[test]
fn the_comparison_detects_a_stale_grid() {
let mut wb = small_workbook();
wb.recalc(&ctx());
wb.set("S", addr("A1"), CellInput::Literal(Value::Number(99.0)))
.unwrap();
let mut cold = fresh(&wb);
cold.recalc(&ctx());
assert_ne!(
wb.to_json().unwrap(),
cold.to_json().unwrap(),
"the warm-vs-fresh comparison must be able to observe a stale grid"
);
}