Fix round 1: KindEmpty, cap rangeCells, document evalCall's error convention, more cycle tests
Four items from review: 1. KindEmpty is now ValueKind's zero value (was KindNum), so an untouched cell is distinguishable from one containing 0 while still coercing to 0 for arithmetic and "" for concatenation; compareValues substitutes the other operand's zero-identity so blank = 0, blank = "", and blank = FALSE all read TRUE, matching Excel. 2. rangeCells is capped at maxRangeCells (200,000, the spec's own per-tab cell ceiling) and reports ok=false with no cells rather than silently truncating. collectRefs leaves an over-cap range untracked as a precedent (disclosed trade-off); evalCallArg turns it into a single #REF! argument. Previously this fired unconditionally inside SetFormula's collectRefs call, independent of any range-taking function existing. 3. evalCall's doc comment now states its error-argument convention explicitly: arguments reach fn unmodified, including KindErr ones, because IFERROR/ISERROR need the raw value — each function decides for itself, rather than evalCall short-circuiting the way evalBinary and evalUnary do. 4. Added self-reference, 3-cycle ring, Range-argument-mediated, and Call-argument-mediated cycle tests (graph_cycle_test.go), each with the same 2s-timeout pattern as the original TestCycleDetection. Registers a minimal test-only ZZTESTFN passthrough so the argument-evaluation path is actually exercised, since funcTable ships empty until task 8.
7 files changed,
+310
−43
internal/sheets/calc/eval.go+38 −18internal/sheets/calc/eval_arith.go+56 −18internal/sheets/calc/eval_call.go+22 −3internal/sheets/calc/eval_test.go+72 −3internal/sheets/calc/graph.go+10 −1internal/sheets/calc/graph_cycle_test.go+87 −0internal/sheets/calc/graph_range_test.go+25 −0
Intent
diff --git a/internal/sheets/calc/eval.go b/internal/sheets/calc/eval.go| index 6856457..c7ad758 100644 |
| --- a/internal/sheets/calc/eval.go |
| +++ b/internal/sheets/calc/eval.go |
| @@ -2,18 +2,20 @@ package calc |
| import "time" |
| -// ValueKind tags which field of a Value is live. KindNum is the zero |
| +// ValueKind tags which field of a Value is live. KindEmpty is the zero |
| // value, so a Value nobody has set (e.g. env.CellValue's answer for a |
| -// cell that has never been written) reads as the number 0 — the same way |
| -// an empty cell behaves in arithmetic. It renders as "" only where a |
| -// caller explicitly asks for text (see toStr); the zero Value itself does |
| -// not carry a distinct "empty" tag, since nothing in this task's tests |
| -// needs one and adding a fifth kind and its coercion rules everywhere is |
| -// a decision better made against a real corpus (task 8). |
| +// cell that has never been written) reads as empty, not as the number 0 — |
| +// distinguishable from a cell that genuinely contains 0. Arithmetic and |
| +// text coercion (toNumber, toStr) still give an empty cell the identity |
| +// each context needs (0 for arithmetic, "" for concatenation), the same |
| +// way Excel does; what an empty Value is NOT is silently indistinguishable |
| +// from KindNum{Num: 0} to a function like COUNT that needs to tell them |
| +// apart (task 8). |
| type ValueKind uint8 |
| const ( |
| - KindNum ValueKind = iota |
| + KindEmpty ValueKind = iota |
| + KindNum |
| KindStr |
| KindBool |
| KindErr |
| @@ -22,8 +24,9 @@ const ( |
| // Value is a computed cell value. Cell values are IEEE doubles end to |
| // end — Num carries the number verbatim, with no rounding at this |
| // boundary; currency and other display formatting are a concern outside |
| -// this package. Kind selects which field is meaningful: Num for KindNum, |
| -// Str for KindStr, Bool for KindBool, Err for KindErr. |
| +// this package. Kind selects which field is meaningful: none for |
| +// KindEmpty, Num for KindNum, Str for KindStr, Bool for KindBool, Err for |
| +// KindErr. |
| type Value struct { |
| Kind ValueKind |
| Num float64 |
| @@ -65,17 +68,30 @@ func refKey(r *Ref) CellKey { |
| return CellKey{RowID: uint64(r.RowPos + 1), ColID: uint64(r.ColPos + 1)} |
| } |
| +// maxRangeCells caps how many cells a single Range expands to in |
| +// rangeCells. The spec's own hard ceiling is 200,000 cells per tab (see |
| +// spec §4.4's budgets), so no legitimately addressable range can cover |
| +// more than that — this cap sits at exactly that ceiling, which means it |
| +// can only ever fire on a malformed or pathological reference, never on a |
| +// real one. Without it, a single formula's Range — e.g. one spanning a |
| +// tab at its own cap — would synchronously materialize up to 200,000 |
| +// CellKeys, twice over: once as call arguments when a range-taking |
| +// function evaluates it (evalCallArg), and again as dependency-graph |
| +// precedent edges every time SetFormula parses that same formula |
| +// (collectRefs). This is rangeCells' fan-in counterpart to Recalc's |
| +// maxDirtyPerPass, which caps the symmetric fan-out case. |
| +const maxRangeCells = 200_000 |
| + |
| // rangeCells expands a Range into every CellKey it covers, normalizing a |
| // range written back-to-front (e.g. "A5:A1"). Used both to fetch a |
| // function call argument's values and to build the dependency graph's |
| // precedent edges for a formula that reads a range. |
| // |
| -// This walks every cell in the range, which is fine for the small ranges |
| -// a spreadsheet mostly uses but not for "the whole column" — a formula |
| -// over A1:A1048576 would materialize a million keys. Nothing in this |
| -// task's brief exercises that shape; a sparse or interval-based |
| -// representation is a follow-up for whichever task first needs one. |
| -func rangeCells(rng *Range) []CellKey { |
| +// ok is false, with cells nil, when the range would exceed maxRangeCells — |
| +// nothing is materialized in that case, mirroring Recalc's own cap: the |
| +// caller decides how to report it rather than rangeCells silently |
| +// truncating the range to some prefix of itself. |
| +func rangeCells(rng *Range) (cells []CellKey, ok bool) { |
| r0, r1 := rng.From.RowPos, rng.To.RowPos |
| c0, c1 := rng.From.ColPos, rng.To.ColPos |
| if r0 > r1 { |
| @@ -84,13 +100,17 @@ func rangeCells(rng *Range) []CellKey { |
| if c0 > c1 { |
| c0, c1 = c1, c0 |
| } |
| - out := make([]CellKey, 0, (r1-r0+1)*(c1-c0+1)) |
| + count := (r1 - r0 + 1) * (c1 - c0 + 1) |
| + if count > maxRangeCells { |
| + return nil, false |
| + } |
| + out := make([]CellKey, 0, count) |
| for row := r0; row <= r1; row++ { |
| for col := c0; col <= c1; col++ { |
| out = append(out, CellKey{RowID: uint64(row + 1), ColID: uint64(col + 1)}) |
| } |
| } |
| - return out |
| + return out, true |
| } |
| // Eval walks n and returns its value under env. Errors are values, not Go |
diff --git a/internal/sheets/calc/eval_arith.go b/internal/sheets/calc/eval_arith.go| index 9c0343f..9392821 100644 |
| --- a/internal/sheets/calc/eval_arith.go |
| +++ b/internal/sheets/calc/eval_arith.go |
| @@ -86,14 +86,31 @@ func evalCompare(op string, l, r Value) Value { |
| } |
| // compareValues orders l against r the way Excel's comparison operators |
| -// do. Within the same kind: numbers compare numerically, text compares |
| -// case-insensitively (Excel's default — "abc" = "ABC"), and booleans |
| -// order false before true. Across kinds, every number sorts below every |
| -// piece of text, and every piece of text sorts below every boolean, |
| -// regardless of the text's literal content — the string "9" is still |
| -// greater than the number 100, because it is text, not because of what it |
| -// says. |
| +// do. A blank cell first coerces to whatever "nothing" means for the |
| +// *other* side's kind — 0 against a number, "" against text, FALSE |
| +// against a boolean, so blank = 0, blank = "", and blank = FALSE all read |
| +// TRUE, matching Excel — and two blanks compare equal outright. Neither |
| +// substitution changes the Value stored anywhere; it only affects this |
| +// one comparison, which is why an empty cell still counts as empty to |
| +// COUNT/COUNTA (task 8) even though "=" treats it as equal to 0. |
| +// |
| +// Once neither side is empty: within the same kind, numbers compare |
| +// numerically, text compares case-insensitively (Excel's default — |
| +// "abc" = "ABC"), and booleans order false before true. Across kinds, |
| +// every number sorts below every piece of text, and every piece of text |
| +// sorts below every boolean, regardless of the text's literal content — |
| +// the string "9" is still greater than the number 100, because it is |
| +// text, not because of what it says. |
| func compareValues(l, r Value) int { |
| + if l.Kind == KindEmpty && r.Kind == KindEmpty { |
| + return 0 |
| + } |
| + if l.Kind == KindEmpty { |
| + l = emptyAs(r.Kind) |
| + } |
| + if r.Kind == KindEmpty { |
| + r = emptyAs(l.Kind) |
| + } |
| lr, rr := typeRank(l), typeRank(r) |
| if lr != rr { |
| if lr < rr { |
| @@ -125,8 +142,23 @@ func compareValues(l, r Value) int { |
| } |
| } |
| +// emptyAs returns the identity value a blank cell coerces to when |
| +// compared against a cell of kind k, so compareValues never has to rank |
| +// KindEmpty itself against KindNum/KindStr/KindBool. |
| +func emptyAs(k ValueKind) Value { |
| + switch k { |
| + case KindStr: |
| + return Value{Kind: KindStr, Str: ""} |
| + case KindBool: |
| + return Value{Kind: KindBool, Bool: false} |
| + default: // KindNum (KindEmpty vs KindEmpty is handled before this is called) |
| + return Value{Kind: KindNum, Num: 0} |
| + } |
| +} |
| + |
| // typeRank orders the value kinds for cross-kind comparison: number, |
| -// then text, then boolean. |
| +// then text, then boolean. Never called with KindEmpty — compareValues |
| +// substitutes it away first via emptyAs. |
| func typeRank(v Value) int { |
| switch v.Kind { |
| case KindStr: |
| @@ -138,14 +170,17 @@ func typeRank(v Value) int { |
| } |
| } |
| -// toNumber coerces v for an arithmetic operator: a number as itself, a |
| -// boolean as 1/0 (Excel's own arithmetic coercion), and text only if it |
| -// parses as a number outright (no currency symbols, percent signs or |
| -// thousands separators — that normalization belongs to whatever task |
| -// parses user input into a formula, not to evaluation). Never called on a |
| -// KindErr Value: callers check for an error operand before coercing. |
| +// toNumber coerces v for an arithmetic operator: an empty cell as 0 |
| +// (Excel's own arithmetic coercion for a blank), a number as itself, a |
| +// boolean as 1/0, and text only if it parses as a number outright (no |
| +// currency symbols, percent signs or thousands separators — that |
| +// normalization belongs to whatever task parses user input into a |
| +// formula, not to evaluation). Never called on a KindErr Value: callers |
| +// check for an error operand before coercing. |
| func toNumber(v Value) (float64, ErrKind) { |
| switch v.Kind { |
| + case KindEmpty: |
| + return 0, ErrNone |
| case KindNum: |
| return v.Num, ErrNone |
| case KindBool: |
| @@ -164,12 +199,15 @@ func toNumber(v Value) (float64, ErrKind) { |
| } |
| } |
| -// toStr renders v as text for "&" concatenation: a string as itself, a |
| -// number in its plain decimal form (no thousands separator or currency — |
| -// display formatting is a concern outside this package), a boolean as |
| -// TRUE/FALSE. |
| +// toStr renders v as text for "&" concatenation: an empty cell as "" (not |
| +// "0" — this is the whole reason KindEmpty exists as its own kind rather |
| +// than collapsing into KindNum{0}), a string as itself, a number in its |
| +// plain decimal form (no thousands separator or currency — display |
| +// formatting is a concern outside this package), a boolean as TRUE/FALSE. |
| func toStr(v Value) string { |
| switch v.Kind { |
| + case KindEmpty: |
| + return "" |
| case KindStr: |
| return v.Str |
| case KindBool: |
diff --git a/internal/sheets/calc/eval_call.go b/internal/sheets/calc/eval_call.go| index 12de78a..e1bdbaf 100644 |
| --- a/internal/sheets/calc/eval_call.go |
| +++ b/internal/sheets/calc/eval_call.go |
| @@ -18,6 +18,16 @@ var funcTable = map[string]Func{} |
| // evalCall dispatches a function call: look up t.Name (already upper-cased |
| // by the parser), flatten every argument, and invoke it. |
| +// |
| +// Argument values reach fn exactly as evaluated, including any that are |
| +// KindErr — evalCall does not short-circuit on an error argument the way |
| +// evalBinary and evalUnary short-circuit on an error operand. That is |
| +// deliberate, not an oversight: IFERROR and ISERROR (task 8) need the raw |
| +// error value to inspect, so blanket propagation here would break the one |
| +// place a formula is allowed to look at an error instead of becoming one. |
| +// Each registered Func decides for itself whether an error argument means |
| +// "become that error" (most functions) or something else (IFERROR, |
| +// ISERROR). |
| func evalCall(t *Call, env Env) Value { |
| fn, ok := funcTable[t.Name] |
| if !ok { |
| @@ -31,14 +41,23 @@ func evalCall(t *Call, env Env) Value { |
| } |
| // evalCallArg evaluates one call argument into one or more Values: a |
| -// range expands to its cells in row-major order, anything else evaluates |
| -// to exactly one Value. |
| +// range expands to its cells in row-major order (via env.CellValue |
| +// directly — not by re-entering Eval, but env.CellValue is the same |
| +// dependency-aware lookup a *Ref goes through, so this is not a way to |
| +// dodge cycle detection or a graph's dirty-set scoping), anything else |
| +// evaluates to exactly one Value through Eval. |
| +// |
| +// A range over maxRangeCells resolves to a single #REF! argument rather |
| +// than materializing it — see rangeCells. |
| func evalCallArg(n Node, env Env) []Value { |
| rng, ok := n.(*Range) |
| if !ok { |
| return []Value{Eval(n, env)} |
| } |
| - cells := rangeCells(rng) |
| + cells, ok := rangeCells(rng) |
| + if !ok { |
| + return []Value{{Kind: KindErr, Err: ErrRef}} |
| + } |
| out := make([]Value, len(cells)) |
| for i, k := range cells { |
| out[i] = env.CellValue(k) |
diff --git a/internal/sheets/calc/eval_test.go b/internal/sheets/calc/eval_test.go| index a008c7d..618724a 100644 |
| --- a/internal/sheets/calc/eval_test.go |
| +++ b/internal/sheets/calc/eval_test.go |
| @@ -18,9 +18,9 @@ func mustParse(t *testing.T, src string) Node { |
| return n |
| } |
| -// emptyEnv answers every cell as an untouched, empty cell (KindNum zero) |
| -// and pins Now to the zero time. Good enough for tests that never read a |
| -// cell or a clock. |
| +// emptyEnv answers every cell as an untouched, empty cell (Value{}, which |
| +// is KindEmpty — the zero ValueKind) and pins Now to the zero time. Good |
| +// enough for tests that never read a cell or a clock. |
| type emptyEnv struct{} |
| func (emptyEnv) CellValue(CellKey) Value { return Value{} } |
| @@ -62,3 +62,72 @@ func TestErrorsPropagate(t *testing.T) { |
| t.Fatalf("propagation = %v, want #REF!", v) |
| } |
| } |
| + |
| +// An untouched cell (emptyEnv answers every CellValue with the zero |
| +// Value) reads as 0 in arithmetic — Excel's own coercion for a blank. |
| +func TestEmptyCellArithmeticReadsAsZero(t *testing.T) { |
| + v := Eval(mustParse(t, "A1+1"), emptyEnv{}) |
| + if v.Kind != KindNum || v.Num != 1 { |
| + t.Fatalf("empty+1 = %v, want 1", v) |
| + } |
| +} |
| + |
| +// The same untouched cell concatenates as "", not "0" — this is the |
| +// behavioural point of KindEmpty existing as its own kind rather than |
| +// collapsing into KindNum{0}. |
| +func TestEmptyCellConcatenatesAsEmptyString(t *testing.T) { |
| + v := Eval(mustParse(t, `A1&"x"`), emptyEnv{}) |
| + if v.Kind != KindStr || v.Str != "x" { |
| + t.Fatalf(`empty&"x" = %v, want "x"`, v) |
| + } |
| +} |
| + |
| +// A blank cell compares equal to 0, to "", and to FALSE — Excel coerces |
| +// blank to whatever "nothing" means for the other operand's type. |
| +func TestEmptyCellComparesEqualToEachTypesZeroValue(t *testing.T) { |
| + for _, formula := range []string{"A1=0", `A1=""`, "A1=FALSE"} { |
| + v := Eval(mustParse(t, formula), emptyEnv{}) |
| + if v.Kind != KindBool || !v.Bool { |
| + t.Errorf("%s = %v, want TRUE", formula, v) |
| + } |
| + } |
| +} |
| + |
| +// A blank cell is distinguishable from a cell that genuinely contains 0 |
| +// — the property task 8's COUNT vs. COUNTA needs — even though the two |
| +// compare equal under "=" (see TestEmptyCellComparesEqualToEachTypesZeroValue). |
| +func TestEmptyCellIsDistinguishableFromZero(t *testing.T) { |
| + empty := Eval(mustParse(t, "A1"), emptyEnv{}) |
| + if empty.Kind != KindEmpty { |
| + t.Fatalf("untouched cell Kind = %v, want KindEmpty", empty.Kind) |
| + } |
| + if empty.Kind == KindNum { |
| + t.Fatal("empty cell must not be indistinguishable from KindNum{0}") |
| + } |
| +} |
| + |
| +// rangeCells must not silently truncate an over-cap range to some prefix |
| +// of itself: past maxRangeCells it reports ok=false with no cells at all. |
| +func TestRangeCellsCapsAtMaxRangeCells(t *testing.T) { |
| + atCap := &Range{From: &Ref{RowPos: 0, ColPos: 0}, To: &Ref{RowPos: maxRangeCells - 1, ColPos: 0}} |
| + cells, ok := rangeCells(atCap) |
| + if !ok || len(cells) != maxRangeCells { |
| + t.Fatalf("range at exactly the cap: ok=%v len=%d, want ok=true len=%d", ok, len(cells), maxRangeCells) |
| + } |
| + |
| + overCap := &Range{From: &Ref{RowPos: 0, ColPos: 0}, To: &Ref{RowPos: maxRangeCells, ColPos: 0}} // one more row |
| + cells, ok = rangeCells(overCap) |
| + if ok || cells != nil { |
| + t.Fatalf("range one cell over the cap: ok=%v cells=%v, want ok=false cells=nil", ok, cells) |
| + } |
| +} |
| + |
| +// A call argument that is a range over the cap resolves to a single |
| +// #REF! value, not a truncated slice of the range's cells. |
| +func TestEvalCallArgOverCapRangeIsRefError(t *testing.T) { |
| + overCap := &Range{From: &Ref{RowPos: 0, ColPos: 0}, To: &Ref{RowPos: maxRangeCells, ColPos: 0}} |
| + got := evalCallArg(overCap, emptyEnv{}) |
| + if len(got) != 1 || got[0].Kind != KindErr || got[0].Err != ErrRef { |
| + t.Fatalf("over-cap range argument = %v, want a single #REF!", got) |
| + } |
| +} |
diff --git a/internal/sheets/calc/graph.go b/internal/sheets/calc/graph.go| index c8a744e..9dff996 100644 |
| --- a/internal/sheets/calc/graph.go |
| +++ b/internal/sheets/calc/graph.go |
| @@ -75,6 +75,13 @@ func (g *Graph) SetFormula(key CellKey, n Node) { |
| // every *Ref directly, every cell a *Range covers, recursively through |
| // binary, unary and call nodes. This is what SetFormula uses to build |
| // precedent/dependent edges. |
| +// |
| +// A *Range over maxRangeCells is left untracked here rather than adding |
| +// hundreds of thousands of edges for one formula: the formula still |
| +// evaluates correctly (evalCallArg turns the same oversized range into a |
| +// #REF! argument), but a change to a cell inside that range will not by |
| +// itself dirty this formula until a full recalc walks it again. Disclosed |
| +// trade-off, not a silent one — see maxRangeCells. |
| func collectRefs(n Node) []CellKey { |
| var out []CellKey |
| var walk func(Node) |
| @@ -83,7 +90,9 @@ func collectRefs(n Node) []CellKey { |
| case *Ref: |
| out = append(out, refKey(t)) |
| case *Range: |
| - out = append(out, rangeCells(t)...) |
| + if cells, ok := rangeCells(t); ok { |
| + out = append(out, cells...) |
| + } |
| case *Binary: |
| walk(t.Left) |
| walk(t.Right) |
diff --git a/internal/sheets/calc/graph_cycle_test.go b/internal/sheets/calc/graph_cycle_test.go| new file mode 100644 |
| index 0000000..644b76a |
| --- /dev/null |
| +++ b/internal/sheets/calc/graph_cycle_test.go |
| @@ -0,0 +1,87 @@ |
| +package calc |
| + |
| +import ( |
| + "testing" |
| + "time" |
| +) |
| + |
| +// zzTestFn is a minimal passthrough registered purely so the two |
| +// call-argument cycle tests below can exercise evalCall's argument |
| +// evaluation. Task 7 ships no real functions — funcTable starts empty, |
| +// task 8 populates it — so nothing else in this package's own tests |
| +// would otherwise evaluate a Call's arguments at all. The name is |
| +// deliberately not a real spreadsheet function's name, to avoid |
| +// colliding with whatever task 8 registers later. |
| +func init() { |
| + funcTable["ZZTESTFN"] = func(args []Value) Value { |
| + if len(args) == 0 { |
| + return Value{} |
| + } |
| + return args[0] |
| + } |
| +} |
| + |
| +// evalWithTimeout runs fn in a goroutine and fails the test if it does |
| +// not return within 2 seconds — the same pattern TestCycleDetection uses. |
| +// The failure mode of broken cycle detection is a wedged process, so a |
| +// broken test must fail, not hang alongside it. |
| +func evalWithTimeout(t *testing.T, fn func() Value) Value { |
| + t.Helper() |
| + done := make(chan Value, 1) |
| + go func() { done <- fn() }() |
| + select { |
| + case v := <-done: |
| + return v |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("cycle detection hung") |
| + return Value{} |
| + } |
| +} |
| + |
| +func assertCircular(t *testing.T, v Value) { |
| + t.Helper() |
| + if v.Kind != KindErr || v.Err != ErrCircular { |
| + t.Fatalf("cycle = %v, want #CIRCULAR!", v) |
| + } |
| +} |
| + |
| +// A cell that references itself is a one-node cycle: the simplest case, |
| +// and the one most likely to be broken by an off-by-one in the colour |
| +// check (e.g. checking "grey" only for a *different* node). |
| +func TestCycleDetectionSelfReference(t *testing.T) { |
| + g := NewGraph() |
| + g.SetFormula(cellA1, mustParse(t, "A1+1")) |
| + assertCircular(t, evalWithTimeout(t, func() Value { return g.Value(cellA1, env) })) |
| +} |
| + |
| +// A longer ring (A1 -> B1 -> C1 -> A1) exercises more than one grey node |
| +// on the stack at once before the cycle closes. |
| +func TestCycleDetectionLongRing(t *testing.T) { |
| + g := NewGraph() |
| + g.SetFormula(cellA1, mustParse(t, "B1")) |
| + g.SetFormula(cellB1, mustParse(t, "C1")) |
| + g.SetFormula(cellC1, mustParse(t, "A1")) |
| + assertCircular(t, evalWithTimeout(t, func() Value { return g.Value(cellA1, env) })) |
| +} |
| + |
| +// A cycle reachable through a Range argument to a call: evalCallArg's |
| +// range branch calls env.CellValue directly for each cell in the range |
| +// rather than re-entering Eval, so this specifically checks that the |
| +// direct call still goes through the graph's colour-marked CellValue |
| +// (i.e. it is not a way to bypass cycle detection). |
| +func TestCycleDetectionThroughRangeArgument(t *testing.T) { |
| + g := NewGraph() |
| + g.SetFormula(cellA1, mustParse(t, "ZZTESTFN(B1:B1)")) |
| + g.SetFormula(cellB1, mustParse(t, "A1")) |
| + assertCircular(t, evalWithTimeout(t, func() Value { return g.Value(cellA1, env) })) |
| +} |
| + |
| +// A cycle reachable through an ordinary (non-range) call argument: the |
| +// general case of evalCall's argument evaluation participating correctly |
| +// in cycle detection. |
| +func TestCycleDetectionThroughCallArgument(t *testing.T) { |
| + g := NewGraph() |
| + g.SetFormula(cellA1, mustParse(t, "B1")) |
| + g.SetFormula(cellB1, mustParse(t, "ZZTESTFN(A1)")) |
| + assertCircular(t, evalWithTimeout(t, func() Value { return g.Value(cellA1, env) })) |
| +} |
diff --git a/internal/sheets/calc/graph_range_test.go b/internal/sheets/calc/graph_range_test.go| new file mode 100644 |
| index 0000000..e47c0f5 |
| --- /dev/null |
| +++ b/internal/sheets/calc/graph_range_test.go |
| @@ -0,0 +1,25 @@ |
| +package calc |
| + |
| +import "testing" |
| + |
| +// A formula whose range exceeds maxRangeCells still evaluates (as a |
| +// single #REF! argument — see TestEvalCallArgOverCapRangeIsRefError), but |
| +// SetFormula must not build hundreds of thousands of precedent edges for |
| +// it: collectRefs leaves an over-cap range untracked rather than |
| +// expanding it. |
| +func TestCollectRefsLeavesOverCapRangeUntracked(t *testing.T) { |
| + overCap := &Range{From: &Ref{RowPos: 0, ColPos: 0}, To: &Ref{RowPos: maxRangeCells, ColPos: 0}} |
| + refs := collectRefs(overCap) |
| + if len(refs) != 0 { |
| + t.Fatalf("collectRefs on an over-cap range returned %d refs, want 0 (untracked, not partially expanded)", len(refs)) |
| + } |
| +} |
| + |
| +// A range at exactly the cap is still tracked in full. |
| +func TestCollectRefsTracksRangeAtCap(t *testing.T) { |
| + atCap := &Range{From: &Ref{RowPos: 0, ColPos: 0}, To: &Ref{RowPos: maxRangeCells - 1, ColPos: 0}} |
| + refs := collectRefs(atCap) |
| + if len(refs) != maxRangeCells { |
| + t.Fatalf("collectRefs on a range at the cap returned %d refs, want %d", len(refs), maxRangeCells) |
| + } |
| +} |