oficina / sheets Public

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.
Paul Campbell 828c1f2a9d95a7d2ec035baaf0f655adc2d23919 parent d0fa42b
7 files changed, +310 −43
  • internal/sheets/calc/eval.go +38 −18
  • internal/sheets/calc/eval_arith.go +56 −18
  • internal/sheets/calc/eval_call.go +22 −3
  • internal/sheets/calc/eval_test.go +72 −3
  • internal/sheets/calc/graph.go +10 −1
  • internal/sheets/calc/graph_cycle_test.go +87 −0
  • internal/sheets/calc/graph_range_test.go +25 −0
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)
+ }
+}