| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "testing" |
| 7 | |
| 8 | "reasonix/internal/boot" |
| 9 | "reasonix/internal/control" |
| 10 | "reasonix/internal/event" |
| 11 | ) |
| 12 | |
| 13 | // stubRuntimeRebuilder records how often the /reload build seam ran and |
| 14 | // returns a fixed result (or error). |
| 15 | func stubRuntimeRebuilder(res *boot.BuildResult, err error) (runtimeRebuilder, *int) { |
| 16 | calls := new(int) |
| 17 | return func(context.Context, controllerBuildSpec, *control.Controller) (*boot.BuildResult, error) { |
| 18 | *calls++ |
| 19 | return res, err |
| 20 | }, calls |
| 21 | } |
| 22 | |
| 23 | func reloadTestModel(ctrl control.SessionAPI, rebuild runtimeRebuilder) *chatTUI { |
| 24 | pending := []string{} |
| 25 | return &chatTUI{ctrl: ctrl, rebuildRuntime: rebuild, pendingCommit: &pending} |
| 26 | } |
| 27 | |
| 28 | // TestReloadDispositionDecisionTable pins the /reload queue semantics: no |
| 29 | // rebuild seam reports unavailable, idle rebuilds now, and a turn/approval or |
| 30 | // runtime switch in flight queues exactly one reload. |
| 31 | func TestReloadDispositionDecisionTable(t *testing.T) { |
| 32 | rebuilder, _ := stubRuntimeRebuilder(nil, errors.New("unused")) |
| 33 | idleCtrl := func() *control.Controller { return control.New(control.Options{}) } |
| 34 | |
| 35 | // No rebuild seam (headless/test surface): unavailable even when idle. |
| 36 | m := reloadTestModel(idleCtrl(), nil) |
| 37 | if got := m.reloadDisposition(); got != reloadUnavailable { |
| 38 | t.Fatalf("no rebuildRuntime: reloadDisposition = %v, want reloadUnavailable", got) |
| 39 | } |
| 40 | // Idle with a seam: rebuild immediately. |
| 41 | m = reloadTestModel(idleCtrl(), rebuilder) |
| 42 | if got := m.reloadDisposition(); got != reloadNow { |
| 43 | t.Fatalf("idle: reloadDisposition = %v, want reloadNow", got) |
| 44 | } |
| 45 | // A runtime switch in flight queues. |
| 46 | m.modelSwitchPending = true |
| 47 | if got := m.reloadDisposition(); got != reloadQueued { |
| 48 | t.Fatalf("switch pending: reloadDisposition = %v, want reloadQueued", got) |
| 49 | } |
| 50 | // Active work (here: a pending approval) queues too. |
| 51 | m = reloadTestModel(idleCtrl(), rebuilder) |
| 52 | m.pendingApproval = &event.Approval{} |
| 53 | if got := m.reloadDisposition(); got != reloadQueued { |
| 54 | t.Fatalf("pending approval: reloadDisposition = %v, want reloadQueued", got) |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | // TestRunReloadCommandQueuesOnceAndDrainsWhenIdle covers the full busy → queue |
| 59 | // → idle → rebuild arc: exactly one queued reload, exactly one build, and the |
| 60 | // swap message carrying both controllers (the old one retires after the swap). |
| 61 | func TestRunReloadCommandQueuesOnceAndDrainsWhenIdle(t *testing.T) { |
| 62 | oldCtrl := control.New(control.Options{Label: "old"}) |
| 63 | newCtrl := control.New(control.Options{Label: "new"}) |
| 64 | rebuilder, calls := stubRuntimeRebuilder(&boot.BuildResult{Controller: newCtrl}, nil) |
| 65 | m := reloadTestModel(oldCtrl, rebuilder) |
| 66 | m.modelSwitchPending = true |
| 67 | |
| 68 | if cmd := m.runReloadCommand(); cmd != nil { |
| 69 | t.Fatal("busy /reload returned a cmd, want nil (queued instead)") |
| 70 | } |
| 71 | if !m.pendingReload { |
| 72 | t.Fatal("busy /reload did not queue a reload") |
| 73 | } |
| 74 | // A second /reload while busy coalesces into the same queued reload. |
| 75 | if cmd := m.runReloadCommand(); cmd != nil { |
| 76 | t.Fatal("second busy /reload returned a cmd, want nil") |
| 77 | } |
| 78 | if *calls != 0 { |
| 79 | t.Fatalf("rebuild ran %d times while busy, want 0", *calls) |
| 80 | } |
| 81 | |
| 82 | // The switch settles; the drain schedules exactly one rebuild. |
| 83 | m.modelSwitchPending = false |
| 84 | cmd := m.drainQueuedRuntimeReload() |
| 85 | if cmd == nil { |
| 86 | t.Fatal("drainQueuedRuntimeReload returned nil, want the rebuild cmd") |
| 87 | } |
| 88 | if m.pendingReload { |
| 89 | t.Fatal("queued reload flag survived the drain") |
| 90 | } |
| 91 | msg := cmd() |
| 92 | swMsg, ok := msg.(modelSwitchMsg) |
| 93 | if !ok { |
| 94 | t.Fatalf("rebuild cmd returned %T, want modelSwitchMsg", msg) |
| 95 | } |
| 96 | if swMsg.err != nil { |
| 97 | t.Fatalf("rebuild failed: %v", swMsg.err) |
| 98 | } |
| 99 | if swMsg.ctrl != newCtrl { |
| 100 | t.Fatal("modelSwitchMsg did not carry the rebuilt controller") |
| 101 | } |
| 102 | if swMsg.oldCtrl != oldCtrl { |
| 103 | t.Fatal("modelSwitchMsg did not carry the outgoing controller for the post-swap close") |
| 104 | } |
| 105 | if *calls != 1 { |
| 106 | t.Fatalf("rebuild ran %d times, want exactly 1", *calls) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | // TestScheduleRuntimeReloadFailureKeepsOldController pins the fail-atomic |
| 111 | // contract: a failed build produces a "reload"-prefixed error message and |
| 112 | // leaves the running model on the old controller (the swap only happens in |
| 113 | // the modelSwitchMsg success branch). |
| 114 | func TestScheduleRuntimeReloadFailureKeepsOldController(t *testing.T) { |
| 115 | oldCtrl := control.New(control.Options{Label: "old"}) |
| 116 | rebuilder, calls := stubRuntimeRebuilder(nil, errors.New("build exploded")) |
| 117 | m := reloadTestModel(oldCtrl, rebuilder) |
| 118 | |
| 119 | cmd := m.scheduleRuntimeReload() |
| 120 | if cmd == nil { |
| 121 | t.Fatal("scheduleRuntimeReload returned nil cmd") |
| 122 | } |
| 123 | msg := cmd() |
| 124 | swMsg, ok := msg.(modelSwitchMsg) |
| 125 | if !ok { |
| 126 | t.Fatalf("rebuild cmd returned %T, want modelSwitchMsg", msg) |
| 127 | } |
| 128 | if swMsg.err == nil { |
| 129 | t.Fatal("failed build produced a nil error") |
| 130 | } |
| 131 | if swMsg.failurePrefix != "reload" { |
| 132 | t.Fatalf("failurePrefix = %q, want %q", swMsg.failurePrefix, "reload") |
| 133 | } |
| 134 | if m.ctrl != oldCtrl { |
| 135 | t.Fatal("failed reload replaced the controller before the swap handler ran") |
| 136 | } |
| 137 | if *calls != 1 { |
| 138 | t.Fatalf("rebuild ran %d times, want 1", *calls) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | // TestRunReloadCommandWithoutSeamDoesNotQueue: a session with no rebuild seam |
| 143 | // reports unavailable instead of queueing a reload that could never drain. |
| 144 | func TestRunReloadCommandWithoutSeamDoesNotQueue(t *testing.T) { |
| 145 | m := reloadTestModel(control.New(control.Options{}), nil) |
| 146 | if cmd := m.runReloadCommand(); cmd != nil { |
| 147 | t.Fatal("/reload without a rebuild seam returned a cmd") |
| 148 | } |
| 149 | if m.pendingReload { |
| 150 | t.Fatal("/reload without a rebuild seam queued a reload") |
| 151 | } |
| 152 | } |
| 153 |