package main // server_control_test.go — M8-4 ServerController state machine 單元測試 // // 不實際 spawn server binary(那是 integration test 的事)。 // 這裡只驗證: // 1. 初始 state = Idle // 2. setState 會更新 state + lastError // 3. 雙鎖不會 deadlock(Start/Stop 併發呼叫不卡住) // 4. ForceKill 在沒有 proc 時不 panic // 5. Restart 在沒有 proc 時會嘗試 Start 且使用 preferredPort=0 // // M8-4 補丁追加(regression): // 6. Stop / ForceKill 進場會 cancel watcher(MAJ-1) // 7. handleWatchFailure 在 state != Running 時不 transition(MAJ-2) // 8. logPump scanDone 觸發後仍會 drain lineCh 中剩餘行(MAJ-5) import ( "context" "io" "sync/atomic" "testing" "time" ) func newTestApp() *App { a := &App{ pythonMode: PythonModeAuto, } a.logBuf = NewLogBuffer() a.ctrl = NewServerController(a) return a } func TestServerController_InitialState(t *testing.T) { a := newTestApp() if got := a.ctrl.State(); got != ServerStateIdle { t.Fatalf("initial state=%s, want %s", got, ServerStateIdle) } } func TestServerController_setState(t *testing.T) { a := newTestApp() // ctx 為 nil → setState 不會 emit,但仍會更新欄位 a.ctrl.setState(ServerStateError, "simulated") if got := a.ctrl.State(); got != ServerStateError { t.Fatalf("state=%s, want error", got) } a.ctrl.mu.Lock() lastErr := a.ctrl.lastError a.ctrl.mu.Unlock() if lastErr != "simulated" { t.Fatalf("lastError=%q, want simulated", lastErr) } } func TestServerController_Stop_FromIdleIsNoOp(t *testing.T) { a := newTestApp() if err := a.ctrl.Stop(); err != nil { t.Fatalf("Stop from Idle returned err=%v", err) } if got := a.ctrl.State(); got != ServerStateIdle { t.Fatalf("state after Stop(Idle)=%s, want Idle", got) } } func TestServerController_Stop_FromErrorIsNoOp(t *testing.T) { a := newTestApp() a.ctrl.setState(ServerStateError, "boom") if err := a.ctrl.Stop(); err != nil { t.Fatalf("Stop from Error returned err=%v", err) } // Stop 從 Error 狀態是 no-op,state 不變 if got := a.ctrl.State(); got != ServerStateError { t.Fatalf("state after Stop(Error)=%s, want Error", got) } } func TestServerController_ForceKill_NoProcess(t *testing.T) { a := newTestApp() // 沒有 proc,ForceKill 應該只把 state 設為 Stopped 並不 panic if err := a.ctrl.ForceKill(); err != nil { t.Fatalf("ForceKill err=%v", err) } if got := a.ctrl.State(); got != ServerStateStopped { t.Fatalf("state after ForceKill=%s, want stopped", got) } } func TestServerController_snapshotStatus_Idle(t *testing.T) { a := newTestApp() st := a.snapshotStatus() if st.State != ServerStateIdle { t.Fatalf("status.State=%s, want idle", st.State) } if st.PID != 0 || st.Port != 0 { t.Fatalf("idle status should have zero PID/Port, got pid=%d port=%d", st.PID, st.Port) } } func TestGetRecentLogs_EmptyBuffer(t *testing.T) { a := newTestApp() logs := a.GetRecentLogs(10) if len(logs) != 0 { t.Fatalf("empty buffer GetRecentLogs len=%d, want 0", len(logs)) } } func TestGetSystemInfo(t *testing.T) { a := newTestApp() a.dataDir = "/tmp/visiona-local-test" info := a.GetSystemInfo() if info.DataDir != a.dataDir { t.Fatalf("info.DataDir=%q, want %q", info.DataDir, a.dataDir) } if info.LogsDir != "/tmp/visiona-local-test/logs" { t.Fatalf("info.LogsDir=%q", info.LogsDir) } if info.Platform == "" { t.Fatalf("info.Platform empty") } } func TestClearLogs_ResetBuffer(t *testing.T) { a := newTestApp() for i := 0; i < 5; i++ { a.logBuf.Append(LogLine{Line: "x"}) } a.ClearLogs() if a.logBuf.Size() != 0 { t.Fatalf("buffer size after ClearLogs=%d", a.logBuf.Size()) } } func TestGetServerStatusV2_Delegates(t *testing.T) { a := newTestApp() st := a.GetServerStatusV2() if st.State != ServerStateIdle { t.Fatalf("GetServerStatusV2 state=%s", st.State) } } // ----------------------------------------------------------------------- // MAJ-1 regression:Stop / ForceKill 必須 cancel watcher // ----------------------------------------------------------------------- // installFakeWatcher 模擬 startServerV2 啟動 watcher 的副作用: // 在 a.watchCancel 安插一個 CancelFunc,呼叫時會把 cancelled 設為 1。 // 回傳 cancelled 計數器供測試斷言。 func installFakeWatcher(a *App) *int32 { var cancelled int32 _, cancel := context.WithCancel(context.Background()) wrappedCancel := func() { atomic.AddInt32(&cancelled, 1) cancel() } a.mu.Lock() a.watchCancel = wrappedCancel a.mu.Unlock() return &cancelled } func TestServerController_Stop_CancelsWatcher_MAJ1(t *testing.T) { a := newTestApp() // 先把 state 弄到 Running,模擬 server 已啟動 a.ctrl.setState(ServerStateRunning, "") cancelled := installFakeWatcher(a) if err := a.ctrl.Stop(); err != nil { t.Fatalf("Stop returned err=%v", err) } // MAJ-1 修復:Stop 必須 cancel watcher if got := atomic.LoadInt32(cancelled); got != 1 { t.Fatalf("watchCancel call count after Stop=%d, want 1 (MAJ-1)", got) } // watchCancel 應被清為 nil,避免重複呼叫 a.mu.Lock() wc := a.watchCancel a.mu.Unlock() if wc != nil { t.Fatalf("a.watchCancel after Stop should be nil (MAJ-1)") } if got := a.ctrl.State(); got != ServerStateStopped { t.Fatalf("state after Stop(Running)=%s, want stopped", got) } } func TestServerController_ForceKill_CancelsWatcher_MAJ1(t *testing.T) { a := newTestApp() a.ctrl.setState(ServerStateRunning, "") cancelled := installFakeWatcher(a) if err := a.ctrl.ForceKill(); err != nil { t.Fatalf("ForceKill err=%v", err) } if got := atomic.LoadInt32(cancelled); got != 1 { t.Fatalf("watchCancel call count after ForceKill=%d, want 1 (MAJ-1)", got) } a.mu.Lock() wc := a.watchCancel a.mu.Unlock() if wc != nil { t.Fatalf("a.watchCancel after ForceKill should be nil (MAJ-1)") } } func TestServerController_Stop_NoWatcher_NoPanic_MAJ1(t *testing.T) { // 沒有 watcher 時 Stop 也不該 panic a := newTestApp() a.ctrl.setState(ServerStateRunning, "") // 不安裝 fake watcher if err := a.ctrl.Stop(); err != nil { t.Fatalf("Stop without watcher err=%v", err) } } // ----------------------------------------------------------------------- // MAJ-2 regression:handleWatchFailure 在 state != Running 時不 transition // ----------------------------------------------------------------------- func TestHandleWatchFailure_NoOpWhenStopped_MAJ2(t *testing.T) { a := newTestApp() // 模擬使用者剛主動 Stop 完成 a.ctrl.setState(ServerStateStopped, "") // watcher goroutine 在我們 Stop 完之後才偵測到「失敗 3 次」,呼叫 handleWatchFailure。 // 修復前:會把 Stopped 翻成 Error + 發崩潰通知。 // 修復後:state != Running,直接 return。 a.ctrl.handleWatchFailure(8421, "stale watcher") if got := a.ctrl.State(); got != ServerStateStopped { t.Fatalf("state after stale handleWatchFailure=%s, want stopped (MAJ-2)", got) } a.ctrl.mu.Lock() lastErr := a.ctrl.lastError a.ctrl.mu.Unlock() if lastErr == "stale watcher" { t.Fatalf("lastError should not be set when handleWatchFailure no-ops (MAJ-2)") } } func TestHandleWatchFailure_NoOpWhenIdle_MAJ2(t *testing.T) { a := newTestApp() // 預設 Idle a.ctrl.handleWatchFailure(8421, "should not apply") if got := a.ctrl.State(); got != ServerStateIdle { t.Fatalf("state after stale handleWatchFailure on Idle=%s, want idle (MAJ-2)", got) } } func TestHandleWatchFailure_NoOpWhenError_MAJ2(t *testing.T) { a := newTestApp() a.ctrl.setState(ServerStateError, "first failure") // 第二次 watcher 失敗不該重複設 state a.ctrl.handleWatchFailure(8421, "second failure") if got := a.ctrl.State(); got != ServerStateError { t.Fatalf("state=%s, want error", got) } a.ctrl.mu.Lock() lastErr := a.ctrl.lastError a.ctrl.mu.Unlock() // MAJ-2 修復:state 已是 Error 時直接 return,不覆蓋 lastError if lastErr != "first failure" { t.Fatalf("lastError=%q, want first failure (MAJ-2 should not overwrite)", lastErr) } } func TestHandleWatchFailure_AppliesWhenRunning_MAJ2(t *testing.T) { // Sanity check:state == Running 時仍然會正常 transition 到 Error a := newTestApp() a.ctrl.setState(ServerStateRunning, "") a.ctrl.handleWatchFailure(8421, "real failure") if got := a.ctrl.State(); got != ServerStateError { t.Fatalf("state=%s, want error", got) } a.ctrl.mu.Lock() lastErr := a.ctrl.lastError a.ctrl.mu.Unlock() if lastErr != "real failure" { t.Fatalf("lastError=%q, want real failure", lastErr) } } // ----------------------------------------------------------------------- // MAJ-5 regression:logPump scanDone 觸發後 drain lineCh 剩餘行 // ----------------------------------------------------------------------- // fakePipe 模擬 server 子程序的 stdout pipe — 一次回傳預先準備好的 N 行後 EOF。 type fakePipe struct { data []byte pos int } func (f *fakePipe) Read(p []byte) (int, error) { if f.pos >= len(f.data) { return 0, io.EOF } n := copy(p, f.data[f.pos:]) f.pos += n return n, nil } func (f *fakePipe) Close() error { return nil } func TestLogPump_DrainsLineChOnScanDone_MAJ5(t *testing.T) { a := newTestApp() // a.ctx == nil 確保不 emit Wails event,但 ring buffer 仍會 append // 注意:a.logBuf 已由 newTestApp 初始化 // 準備 50 行假 log,scanner 會一口氣讀完 → 全部塞進 lineCh(cap 128) // → scanner goroutine 結束 → close(scanDone)。 // main loop 可能在 ticker 還沒 tick 前就同時看到 lineCh ready 與 scanDone ready, // 修復前:select 隨機選 scanDone case → 直接 return → lineCh 內所有行被丟掉。 // 修復後:scanDone case 加 drain,所有行都會進 ring buffer。 const lineCount = 50 var buf []byte for i := 0; i < lineCount; i++ { buf = append(buf, []byte("test-line-")...) buf = append(buf, byte('0'+(i/10))) buf = append(buf, byte('0'+(i%10))) buf = append(buf, '\n') } pipe := &fakePipe{data: buf} // 同步呼叫 logPump(不開 goroutine),讓測試確定地等到它 return done := make(chan struct{}) go func() { a.logPump(pipe, "stdout", io.Discard) close(done) }() select { case <-done: case <-time.After(2 * time.Second): t.Fatalf("logPump did not return within 2s") } // 驗證:ring buffer 內應有全部 50 行(MAJ-5 修復前可能只有部分) got := a.logBuf.Size() if got != lineCount { t.Fatalf("ring buffer size after logPump=%d, want %d (MAJ-5: scanDone should drain lineCh)", got, lineCount) } // 進一步驗證最後一行有進去(最關鍵 — 通常崩潰 stack trace 是最後幾行) snap := a.logBuf.Snapshot(1) if len(snap) != 1 { t.Fatalf("snapshot last line len=%d", len(snap)) } if snap[0].Line != "test-line-49" { t.Fatalf("last line=%q, want test-line-49 (MAJ-5)", snap[0].Line) } }