Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Evaluate calculation chain #2172

Merged
merged 12 commits into from
Sep 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion ClosedXML.Tests/Excel/CalcEngine/ArrayFormulaTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ public void ReferencingItselfIsCircularError()

Assert.That(() => _ = ws.Cell("A2").Value,
Throws.TypeOf<InvalidOperationException>()
.With.Message.EqualTo("Cell A2 is a part of circular reference."));
.With.Message.EqualTo("Formula in a cell '$Sheet1'!$A1 is part of a cycle."));
}
}
}
147 changes: 147 additions & 0 deletions ClosedXML.Tests/Excel/CalcEngine/CalcEngineListenerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
using ClosedXML.Excel;
using NUnit.Framework;

namespace ClosedXML.Tests.Excel.CalcEngine
{
/// <summary>
/// Tests that calc engine adjusts its internal state in response to changes of workbook structure.
/// </summary>
[TestFixture]
internal class CalcEngineListenerTests
{
[Test]
public void Formulas_dependent_on_specific_sheet_are_dirty_after_sheet_addition()
{
using var wb = new XLWorkbook();
var sutWs = wb.AddWorksheet();
sutWs.Cell("A1").FormulaA1 = "new!A1";
Assert.AreEqual(XLError.CellReference, sutWs.Cell("A1").Value);

var newWs = wb.AddWorksheet("new");
newWs.Cell("A1").Value = 5;

// Cell contains last calculated value
Assert.AreEqual(XLError.CellReference, sutWs.Cell("A1").CachedValue);

// But once asked for real value, it calculates it.
Assert.True(sutWs.Cell("A1").NeedsRecalculation);
Assert.AreEqual(5.0, sutWs.Cell("A1").Value);
}

[Test]
public void Formulas_dependent_on_specific_sheet_are_dirty_after_sheet_deletion()
{
using var wb = new XLWorkbook();
var keptWs = wb.AddWorksheet();
var deletedWs = wb.AddWorksheet("deleted");

deletedWs.Cell("A1").Value = 5;
keptWs.Cell("A1").FormulaA1 = "deleted!A1";
Assert.AreEqual(5.0, keptWs.Cell("A1").Value);

deletedWs.Delete();

// Cell contains last calculated value
Assert.AreEqual(5.0, keptWs.Cell("A1").CachedValue);

// But once asked for real value, it calculates it.
Assert.True(keptWs.Cell("A1").NeedsRecalculation);
Assert.AreEqual(XLError.CellReference, keptWs.Cell("A1").Value);
}

[Test]
public void Formulas_are_shifted_when_area_is_added_and_cells_shifted_down()
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet();
ws.Cell("A1").FormulaA1 = "B1*2";
ws.Cell("B1").FormulaA1 = "C1*2";
ws.Cell("C1").FormulaA1 = "1+2";

ws.RecalculateAllFormulas();

ws.Range("A1:B1").InsertRowsAbove(2);

Assert.AreEqual(12.0, ws.Cell("A3").Value);
Assert.False(ws.Cell("A3").NeedsRecalculation);
Assert.False(ws.Cell("B3").NeedsRecalculation);

// Dependency tree should pick up the change
ws.Cell("C1").FormulaA1 = "2+2";
Assert.True(ws.Cell("A3").NeedsRecalculation);
Assert.True(ws.Cell("B3").NeedsRecalculation);
Assert.AreEqual(16.0, ws.Cell("A3").Value);
}

[Test]
public void Formulas_are_shifted_when_area_is_added_and_cells_shifted_right()
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet();
ws.Cell("A1").FormulaA1 = "A2*2";
ws.Cell("A2").FormulaA1 = "A3*2";
ws.Cell("A3").FormulaA1 = "1+2";

ws.RecalculateAllFormulas();

ws.Cell("A2").InsertCellsBefore(4);

Assert.AreEqual(12.0, ws.Cell("A1").Value);
Assert.False(ws.Cell("E2").NeedsRecalculation);

// Dependency tree should pick up the change
ws.Cell("A3").FormulaA1 = "2+2";
Assert.True(ws.Cell("E2").NeedsRecalculation);
Assert.True(ws.Cell("A1").NeedsRecalculation);
Assert.AreEqual(16.0, ws.Cell("A1").Value);
}

[Test]
public void Formulas_are_shifted_when_area_is_deleted_and_cells_shifted_up()
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet();
ws.Cell("A5").FormulaA1 = "1+2";
ws.Cell("B5").FormulaA1 = "A5*2";
ws.Cell("C5").FormulaA1 = "B5*2";

ws.RecalculateAllFormulas();

ws.Range("B2:C4").Delete(XLShiftDeletedCells.ShiftCellsUp);

Assert.AreEqual(12.0, ws.Cell("C2").Value);
Assert.False(ws.Cell("B2").NeedsRecalculation);
Assert.False(ws.Cell("A2").NeedsRecalculation);

// Dependency tree should pick up the change
ws.Cell("A5").FormulaA1 = "2+2";
Assert.True(ws.Cell("B2").NeedsRecalculation);
Assert.True(ws.Cell("C2").NeedsRecalculation);
Assert.AreEqual(16.0, ws.Cell("C2").Value);
}

[Test]
public void Formulas_are_shifted_when_area_is_deleted_and_cells_shifted_left()
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet();
ws.Cell("D1").FormulaA1 = "1+2";
ws.Cell("E2").FormulaA1 = "D1*2";
ws.Cell("D3").FormulaA1 = "E2*2";

ws.RecalculateAllFormulas();

ws.Range("A1:C5").Delete(XLShiftDeletedCells.ShiftCellsLeft);

Assert.AreEqual(12.0, ws.Cell("A3").Value);
Assert.False(ws.Cell("B2").NeedsRecalculation);
Assert.False(ws.Cell("A1").NeedsRecalculation);

// Dependency tree should pick up the change
ws.Cell("A1").FormulaA1 = "2+2";
Assert.True(ws.Cell("B2").NeedsRecalculation);
Assert.True(ws.Cell("A3").NeedsRecalculation);
Assert.AreEqual(16.0, ws.Cell("A3").Value);
}
}
}
53 changes: 32 additions & 21 deletions ClosedXML.Tests/Excel/CalcEngine/DependencyTreeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ public void Remove_formula_from_dependency_tree()
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet();
var tree = new DependencyTree(wb);
var tree = new DependencyTree();
tree.AddSheetTree(ws);
var cellFormula = AddFormula(tree, ws, "B3", "=C4");
Assert.False(tree.IsEmpty);

Expand All @@ -145,7 +146,8 @@ public void Removing_formula_doesnt_remove_precedent_area_from_tree_when_another
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet();
var tree = new DependencyTree(wb);
var tree = new DependencyTree();
tree.AddSheetTree(ws);
var cellFormulaA1 = AddFormula(tree, ws, "A1", "=C4 + B1");
var cellFormulaA2 = AddFormula(tree, ws, "A2", "=B1 / C4");
Assert.False(tree.IsEmpty);
Expand All @@ -168,8 +170,9 @@ public void Removing_formula_doesnt_remove_precedent_area_from_tree_when_another
public void Mark_dirty_single_chain_is_fully_marked()
{
using var wb = new XLWorkbook();
var tree = new DependencyTree(wb);
var tree = new DependencyTree();
var ws = wb.AddWorksheet();
tree.AddSheetTree(ws);
AddFormula(tree, ws, "A2", "=A1");
AddFormula(tree, ws, "A3", "=A2");
AddFormula(tree, ws, "A4", "=A3");
Expand All @@ -182,24 +185,27 @@ public void Mark_dirty_single_chain_is_fully_marked()
public void Mark_dirty_split_and_join_is_fully_marked()
{
using var wb = new XLWorkbook();
var tree = new DependencyTree(wb);
var ws1 = wb.AddWorksheet();
AddFormula(tree, ws1, "B2", "=B1");
AddFormula(tree, ws1, "C1", "=B2");
AddFormula(tree, ws1, "C3", "=B2");
AddFormula(tree, ws1, "D2", "=C1 + C3");

MarkDirty(tree, ws1, "B1");
AssertDirty(ws1, "B2", "C1", "C3", "D2");
var tree = new DependencyTree();
var ws = wb.AddWorksheet();
tree.AddSheetTree(ws);
AddFormula(tree, ws, "B2", "=B1");
AddFormula(tree, ws, "C1", "=B2");
AddFormula(tree, ws, "C3", "=B2");
AddFormula(tree, ws, "D2", "=C1 + C3");

MarkDirty(tree, ws, "B1");
AssertDirty(ws, "B2", "C1", "C3", "D2");
}

[Test]
public void Mark_dirty_uses_correct_sheet()
{
using var wb = new XLWorkbook();
var tree = new DependencyTree(wb);
var tree = new DependencyTree();
var ws1 = wb.AddWorksheet("Sheet1");
tree.AddSheetTree(ws1);
var ws2 = wb.AddWorksheet("Sheet2");
tree.AddSheetTree(ws2);

// Make a chain, where each cell is on an opposite sheet
AddFormula(tree, ws1, "B1", "=Sheet2!A1");
Expand All @@ -225,8 +231,9 @@ public void Mark_dirty_uses_correct_sheet()
public void Mark_dirty_stops_at_dirty_cell()
{
using var wb = new XLWorkbook();
var tree = new DependencyTree(wb);
var tree = new DependencyTree();
var ws = wb.AddWorksheet();
tree.AddSheetTree(ws);
AddFormula(tree, ws, "A2", "=A1");
AddFormula(tree, ws, "A3", "=A2");
AddFormula(tree, ws, "A4", "=A3");
Expand All @@ -243,8 +250,9 @@ public void Mark_dirty_stops_at_dirty_cell()
public void Mark_dirty_wont_crash_on_cycle()
{
using var wb = new XLWorkbook();
var tree = new DependencyTree(wb);
var tree = new DependencyTree();
var ws = wb.AddWorksheet();
tree.AddSheetTree(ws);
AddFormula(tree, ws, "B1", "=D1 + A1");
AddFormula(tree, ws, "C1", "=B1");
AddFormula(tree, ws, "D1", "=C1");
Expand All @@ -260,8 +268,9 @@ public void Mark_dirty_wont_crash_on_cycle()
public void Mark_dirty_affects_precedents_with_partial_overlap()
{
using var wb = new XLWorkbook();
var tree = new DependencyTree(wb);
var tree = new DependencyTree();
var ws = wb.AddWorksheet();
tree.AddSheetTree(ws);
AddFormula(tree, ws, "D1", "=A1:B3");

// B3:D4 overlaps with A1:B3 in B3
Expand All @@ -273,8 +282,9 @@ public void Mark_dirty_affects_precedents_with_partial_overlap()
public void Mark_dirty_can_affect_multiple_chains_at_once()
{
using var wb = new XLWorkbook();
var tree = new DependencyTree(wb);
var tree = new DependencyTree();
var ws = wb.AddWorksheet();
tree.AddSheetTree(ws);
AddFormula(tree, ws, "B1", "=A1");
AddFormula(tree, ws, "B2", "=A2");
AddFormula(tree, ws, "B3", "=A3");
Expand All @@ -288,10 +298,11 @@ public void Mark_dirty_can_affect_multiple_chains_at_once()

private static XLCellFormula AddFormula(DependencyTree tree, IXLWorksheet sheet, string address, string formula)
{
// Set directly, so the cell is not marked as a dirty.
var cell = (XLCell)sheet.Cell(address);
cell.FormulaA1 = formula;
cell.Formula = XLCellFormula.NormalA1(formula);
var cellArea = new XLBookArea(sheet.Name, new XLSheetRange(cell.SheetPoint, cell.SheetPoint));
tree.AddFormula(cellArea, cell.Formula);
tree.AddFormula(cellArea, cell.Formula, sheet.Workbook);
return cell.Formula;
}

Expand Down Expand Up @@ -327,12 +338,12 @@ private static FormulaDependencies GetDependencies(string formula, string formul
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet("Sheet");
init?.Invoke(wb);
var tree = new DependencyTree(wb);
var tree = new DependencyTree();
var cell = ws.Cell(formulaAddress);
cell.SetFormulaA1(formula);

var cellFormula = ((XLCell)cell).Formula;
var dependencies = tree.AddFormula(new XLBookArea(ws.Name, cellFormula.Range), cellFormula);
var dependencies = tree.AddFormula(new XLBookArea(ws.Name, cellFormula.Range), cellFormula, wb);
return dependencies;
}

Expand Down
4 changes: 2 additions & 2 deletions ClosedXML.Tests/Excel/Cells/XLCellTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -874,10 +874,10 @@ public void FormulaWithCircularReferenceFails()
A2.FormulaA1 = "A1 + 1";

Assert.Throws(
Is.TypeOf<InvalidOperationException>().And.Message.Contains("circular"),
Is.TypeOf<InvalidOperationException>().And.Message.Contains("cycle"),
() => _ = A1.Value);
Assert.Throws(
Is.TypeOf<InvalidOperationException>().And.Message.Contains("circular"),
Is.TypeOf<InvalidOperationException>().And.Message.Contains("cycle"),
() => _ = A2.Value);
}
}
Expand Down
17 changes: 14 additions & 3 deletions ClosedXML.Tests/Excel/Loading/LoadingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -385,10 +385,21 @@ public void LoadFormulaCachedValue(string formula, object expectedCachedValue)
[Test]
public void LoadingOptions()
{
using (var stream = TestHelper.GetStreamFromResource(TestHelper.GetResourcePath(@"Other\ExternalLinks\WorkbookWithExternalLink.xlsx")))
using (var stream = TestHelper.GetStreamFromResource(TestHelper.GetResourcePath(@"Examples\Misc\Formulas.xlsx")))
{
Assert.DoesNotThrow(() => new XLWorkbook(stream, new LoadOptions { RecalculateAllFormulas = false }));
Assert.Throws<NotImplementedException>(() => new XLWorkbook(stream, new LoadOptions { RecalculateAllFormulas = true }));
Assert.DoesNotThrow(() =>
{
// The value in the file is blank and kept.
using var wb = new XLWorkbook(stream, new LoadOptions { RecalculateAllFormulas = false });
Assert.AreEqual(Blank.Value, wb.Worksheets.Single().Cell("C2").CachedValue);
});

Assert.DoesNotThrow(() =>
{
// The value in the file is blank, but recalculation sets it to correct 3.
using var wb = new XLWorkbook(stream, new LoadOptions { RecalculateAllFormulas = true });
Assert.AreEqual(3, wb.Worksheets.Single().Cell("C2").CachedValue);
});

Assert.AreEqual(30, new XLWorkbook(stream, new LoadOptions { Dpi = new Point(30, 14) }).DpiX);
Assert.AreEqual(14, new XLWorkbook(stream, new LoadOptions { Dpi = new Point(30, 14) }).DpiY);
Expand Down
39 changes: 39 additions & 0 deletions ClosedXML.Tests/Excel/Worksheets/XLWorksheetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1275,5 +1275,44 @@ public void FirstColumnUsed_ReturnsFirstColumnWithUsedCell(XLCellsUsedOptions op
var column = ws.FirstColumnUsed(options);
Assert.AreEqual(expectedColumn, column.ColumnNumber());
}

[Test]
public void RecalculateAllFormulas_recalculates_all_formulas_in_sheet_and_leaves_rest_dirty()
{
using var wb = new XLWorkbook();
var sut = wb.AddWorksheet("sut");
var other = wb.AddWorksheet("other");

other.Cell("A1").Value = 7;
other.Cell("A2").FormulaA1 = "A1+3";
Assert.AreEqual(10.0, other.Cell("A2").Value);

// Change the supporting value, but without recalculation of dependent
// formula, thus the value stays the same.
other.Cell("A1").Value = 5;

Assert.True(other.Cell("A2").NeedsRecalculation);
Assert.AreEqual(10.0, other.Cell("A2").CachedValue);

// Tested formula depends on a dirty formula from other sheet.
sut.Cell("A1").FormulaA1 = "other!A2+5";
sut.Cell("A2").FormulaA1 = "1+2";

Assert.AreEqual(Blank.Value, sut.Cell("A1").CachedValue);
Assert.AreEqual(Blank.Value, sut.Cell("A2").CachedValue);

sut.RecalculateAllFormulas();

// Formulas in other sheets kept the value - not affected by recalculation of a sut sheet.
Assert.True(other.Cell("A2").NeedsRecalculation);
Assert.AreEqual(10.0, other.Cell("A2").CachedValue);

// Formulas in test sheet were recalculated - they are affected by recalculation of a sut sheet.
Assert.False(sut.Cell("A1").NeedsRecalculation);
Assert.AreEqual(15.0, sut.Cell("A1").CachedValue);

Assert.False(sut.Cell("A2").NeedsRecalculation);
Assert.AreEqual(3.0, sut.Cell("A2").CachedValue);
}
}
}