Compare commits

...

2 commits

Author SHA1 Message Date
8ca5070b26 test(spreadsheet): cover the remaining serialization and border branches
Some checks failed
email.yml / test(spreadsheet): cover the remaining serialization and border branches (push) Failing after 0s
repo hygiene / hygiene (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
The last reachable lines in data.rs, all in the persistence layer and
one style command:

- serialize/deserialize round-trip for named ranges (the NAME block
  line), text colour, and an unknown block tag (ignored for forward
  compatibility), plus a malformed NAME line inserting nothing.
- The legacy CSV deserializer's `|B` bold and `|F` formula metadata
  segments.
- apply_command with BorderTarget::None clearing all four edges (the
  "remove borders" action), the complement of the per-edge arm the
  existing test covers.
- A large-range self-reference reporting CycleDetected (pins the fix
  in the previous commit).

data.rs 97.91% -> 99.49%; engine total 98.50% -> 99.13%. Unit tests
325 -> 331.

Deliberately uncovered, as documented: the B17 invariant's
debug_assert/#ERROR! patch (only reachable on a dep-graph bug), the
topological-sort underflow guard, the let-else continue after a cell
disappears mid-iteration, and the `}` attribution regions after
unconditional returns.
2026-08-17 11:49:00 +00:00
e1dcbefda3 fix(spreadsheet): large-range cycle detection must report, not zero
The large-range fast path in DataEvalContext::get_range_values (used
for ranges over 64 cells) handled a re-entrant formula cell — a
reference cycle — by silently pushing 0.0. The small-range path in
get_cell_value reports FormulaError::CycleDetected for the same
situation. So a cyclic formula inside a large range contributed zero
to the sum instead of surfacing #CYCLE!.

The inconsistency is invisible in normal recalculation: the topological
sort in recalculate_incremental detects cycles before any evaluation,
so the branch only fires through the legacy recursive get_cell_value
API. The fix makes the two range paths agree.
2026-08-17 11:49:00 +00:00

View file

@ -306,6 +306,90 @@ mod serialization_tests {
sheet.redo();
assert_eq!(sheet.get_raw(0, 0), "test");
}
/// Named ranges must survive the serialize→deserialize round-trip:
/// `serialize_block` writes a `NAME` line per range and
/// `deserialize_block_line` reads it back.
#[test]
fn named_ranges_survive_roundtrip() {
let mut original = SpreadsheetData::default();
original.set_cell(0, 0, "10");
original.set_cell(1, 0, "20");
original
.named_ranges
.insert("Total".to_string(), (0, 0, 1, 0));
let serialized = original.serialize();
let mut restored = SpreadsheetData::default();
restored.deserialize(&serialized);
assert_eq!(
restored.named_ranges.get("Total"),
Some(&(0, 0, 1, 0)),
"the named range must come back verbatim"
);
}
/// A cell's text color survives the round-trip. The background colour
/// is covered by the existing round-trip test; the text colour was
/// serialized but never read back in a test.
#[test]
fn text_color_survives_roundtrip() {
let mut original = SpreadsheetData::default();
original.set_cell(0, 0, "x");
original
.cells
.get_mut(&CellId::new(0, 0))
.unwrap()
.style
.text_color = Some(Color::new(1.0, 0.5, 0.25, 1.0));
let serialized = original.serialize();
let mut restored = SpreadsheetData::default();
restored.deserialize(&serialized);
assert_eq!(
restored
.cells
.get(&CellId::new(0, 0))
.unwrap()
.style
.text_color,
Some(Color::new(1.0, 0.5, 0.25, 1.0))
);
}
/// Unknown block tags are ignored for forward compatibility, and a
/// `NAME` line with a malformed range inserts nothing.
#[test]
fn unknown_tags_and_malformed_name_lines_are_ignored() {
let mut sheet = SpreadsheetData::default();
sheet.deserialize_block_line("FUTURE\twhatever\tgoes\there");
assert!(sheet.cells.is_empty());
assert!(sheet.named_ranges.is_empty());
sheet.deserialize_block_line("NAME\tBroken\t0,1,2"); // only 3 of 4 parts
assert!(sheet.named_ranges.is_empty());
}
/// The legacy CSV format carries cell metadata as `|`-separated
/// segments (`F` formula, `B` bold). Both must be honoured.
#[test]
fn legacy_csv_metadata_segments_are_honoured() {
let mut sheet = SpreadsheetData::default();
sheet.deserialize("0,0,plain\n1,0,=A1|F=1+1\n2,0,bold|B");
assert_eq!(sheet.get_raw(0, 0), "plain");
assert_eq!(
sheet
.cells
.get(&CellId::new(1, 0))
.unwrap()
.formula
.as_deref(),
Some("=1+1")
);
assert!(sheet.cells.get(&CellId::new(2, 0)).unwrap().style.bold);
}
}
#[cfg(test)]
@ -714,6 +798,50 @@ mod mutation_helper_tests {
assert_eq!(sheet.get_raw(0, 0), "1");
}
/// `BorderTarget::None` clears all four edges — the "remove borders"
/// toolbar action — leaving no stale edge behind.
#[test]
fn apply_command_set_borders_none_clears_all_edges() {
let mut sheet = SpreadsheetData::default();
sheet.apply_command(WorkbookCommand::SetBorders {
row: 0,
col: 0,
target: BorderTarget::All,
edge: BorderEdge {
color: Color::new(0.0, 0.0, 0.0, 1.0),
width: 1.0,
},
top: true,
bottom: true,
left: true,
right: true,
});
let cell = sheet.cells.get(&CellId::new(0, 0)).unwrap();
assert!(cell.style.border_top.is_some());
assert!(cell.style.border_bottom.is_some());
assert!(cell.style.border_left.is_some());
assert!(cell.style.border_right.is_some());
sheet.apply_command(WorkbookCommand::SetBorders {
row: 0,
col: 0,
target: BorderTarget::None,
edge: BorderEdge {
color: Color::new(0.0, 0.0, 0.0, 1.0),
width: 1.0,
},
top: false,
bottom: false,
left: false,
right: false,
});
let cell = sheet.cells.get(&CellId::new(0, 0)).unwrap();
assert_eq!(cell.style.border_top, None);
assert_eq!(cell.style.border_bottom, None);
assert_eq!(cell.style.border_left, None);
assert_eq!(cell.style.border_right, None);
}
#[test]
fn mutate_cell_creates_and_records() {
let mut sheet = SpreadsheetData::default();
@ -1254,7 +1382,13 @@ impl<'a> formula2::EvalContext for DataEvalContext<'a> {
if !cell.computed_value.is_empty() {
values.push(Self::parse_cell_computed_value(&cell.computed_value)?);
} else if !self.visiting.borrow_mut().insert((r, c)) {
values.push(Value2::Number(0.0));
// Re-entered a formula cell mid-evaluation: a
// reference cycle. The small-range path in
// `get_cell_value` reports CycleDetected here;
// the large-range path used to silently push
// 0.0, so a cyclic formula inside a >64-cell
// range contributed zero instead of #CYCLE!.
return Err(FormulaError::CycleDetected);
} else {
let result = match cell.cached_ast {
Some(ref ast) => formula2::evaluate(ast, self),
@ -3449,4 +3583,28 @@ mod dep_graph_branch_tests {
sheet.set_cell(9, 0, "=SUM(A1:I9)");
assert!(sheet.get_display_value(9, 0).starts_with("#ERROR!"));
}
/// A self-referential formula inside a large (>64-cell) range reports a
/// cycle instead of silently contributing zero. The small-range path
/// already returned `CycleDetected`; the large-range fast path pushed
/// `0.0`, so a cyclic formula in a big range evaluated as zero.
#[test]
fn large_range_self_reference_reports_a_cycle() {
let mut sheet = SpreadsheetData::default();
// Insert the formula without recalculating: a normal `set_cell`
// would already mark it #CYCLE! through the topological sort, which
// is exactly the guard that never fires here.
let cell = CellData {
value: "=SUM(A1:I9)".to_string(),
formula: Some("=SUM(A1:I9)".to_string()),
..Default::default()
};
sheet.put_cell(0, 0, cell); // A1 = SUM over A1:I9, includes itself
let mut visiting = HashSet::new();
assert!(matches!(
sheet.get_cell_value(0, 0, &mut visiting),
Err(FormulaError::CycleDetected)
));
}
}