| 1 | package calc |
| 2 | |
| 3 | import ( |
| 4 | "testing" |
| 5 | "time" |
| 6 | ) |
| 7 | |
| 8 | // zzTestFn is a minimal passthrough registered purely so the two |
| 9 | // call-argument cycle tests below can exercise evalCall's argument |
| 10 | // evaluation. Task 7 ships no real functions — funcTable starts empty, |
| 11 | // task 8 populates it — so nothing else in this package's own tests |
| 12 | // would otherwise evaluate a Call's arguments at all. The name is |
| 13 | // deliberately not a real spreadsheet function's name, to avoid |
| 14 | // colliding with whatever task 8 registers later. |
| 15 | func init() { |
| 16 | funcTable["ZZTESTFN"] = func(args []Value) Value { |
| 17 | if len(args) == 0 { |
| 18 | return Value{} |
| 19 | } |
| 20 | return args[0] |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | // evalWithTimeout runs fn in a goroutine and fails the test if it does |
| 25 | // not return within 2 seconds — the same pattern TestCycleDetection uses. |
| 26 | // The failure mode of broken cycle detection is a wedged process, so a |
| 27 | // broken test must fail, not hang alongside it. |
| 28 | func evalWithTimeout(t *testing.T, fn func() Value) Value { |
| 29 | t.Helper() |
| 30 | done := make(chan Value, 1) |
| 31 | go func() { done <- fn() }() |
| 32 | select { |
| 33 | case v := <-done: |
| 34 | return v |
| 35 | case <-time.After(2 * time.Second): |
| 36 | t.Fatal("cycle detection hung") |
| 37 | return Value{} |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | func assertCircular(t *testing.T, v Value) { |
| 42 | t.Helper() |
| 43 | if v.Kind != KindErr || v.Err != ErrCircular { |
| 44 | t.Fatalf("cycle = %v, want #CIRCULAR!", v) |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | // A cell that references itself is a one-node cycle: the simplest case, |
| 49 | // and the one most likely to be broken by an off-by-one in the colour |
| 50 | // check (e.g. checking "grey" only for a *different* node). |
| 51 | func TestCycleDetectionSelfReference(t *testing.T) { |
| 52 | g := NewGraph() |
| 53 | g.SetFormula(cellA1, mustParse(t, "A1+1")) |
| 54 | assertCircular(t, evalWithTimeout(t, func() Value { return g.Value(cellA1, env) })) |
| 55 | } |
| 56 | |
| 57 | // A longer ring (A1 -> B1 -> C1 -> A1) exercises more than one grey node |
| 58 | // on the stack at once before the cycle closes. |
| 59 | func TestCycleDetectionLongRing(t *testing.T) { |
| 60 | g := NewGraph() |
| 61 | g.SetFormula(cellA1, mustParse(t, "B1")) |
| 62 | g.SetFormula(cellB1, mustParse(t, "C1")) |
| 63 | g.SetFormula(cellC1, mustParse(t, "A1")) |
| 64 | assertCircular(t, evalWithTimeout(t, func() Value { return g.Value(cellA1, env) })) |
| 65 | } |
| 66 | |
| 67 | // A cycle reachable through a Range argument to a call: evalCallArg's |
| 68 | // range branch calls env.CellValue directly for each cell in the range |
| 69 | // rather than re-entering Eval, so this specifically checks that the |
| 70 | // direct call still goes through the graph's colour-marked CellValue |
| 71 | // (i.e. it is not a way to bypass cycle detection). |
| 72 | func TestCycleDetectionThroughRangeArgument(t *testing.T) { |
| 73 | g := NewGraph() |
| 74 | g.SetFormula(cellA1, mustParse(t, "ZZTESTFN(B1:B1)")) |
| 75 | g.SetFormula(cellB1, mustParse(t, "A1")) |
| 76 | assertCircular(t, evalWithTimeout(t, func() Value { return g.Value(cellA1, env) })) |
| 77 | } |
| 78 | |
| 79 | // A cycle reachable through an ordinary (non-range) call argument: the |
| 80 | // general case of evalCall's argument evaluation participating correctly |
| 81 | // in cycle detection. |
| 82 | func TestCycleDetectionThroughCallArgument(t *testing.T) { |
| 83 | g := NewGraph() |
| 84 | g.SetFormula(cellA1, mustParse(t, "B1")) |
| 85 | g.SetFormula(cellB1, mustParse(t, "ZZTESTFN(A1)")) |
| 86 | assertCircular(t, evalWithTimeout(t, func() Value { return g.Value(cellA1, env) })) |
| 87 | } |
| 88 | |