76816ae188
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
252 lines
7.7 KiB
Go
252 lines
7.7 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"antidrift/internal/ai"
|
|
"antidrift/internal/domain"
|
|
"antidrift/internal/evidence"
|
|
"antidrift/internal/session"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func newTestServer(t *testing.T) *Server {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
path := filepath.Join(t.TempDir(), "state.json")
|
|
ctrl, err := session.New(path)
|
|
if err != nil {
|
|
t.Fatalf("controller: %v", err)
|
|
}
|
|
return NewServer(ctrl)
|
|
}
|
|
|
|
func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func TestPlanningThenCommitmentReachesActive(t *testing.T) {
|
|
s := newTestServer(t)
|
|
r := s.Router()
|
|
|
|
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
|
|
t.Fatalf("/planning code %d", w.Code)
|
|
}
|
|
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}`
|
|
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
|
|
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
|
|
}
|
|
if s.ctrl.State().RuntimeState != domain.RuntimeActive {
|
|
t.Fatalf("expected Active, got %s", s.ctrl.State().RuntimeState)
|
|
}
|
|
}
|
|
|
|
func TestCommitmentRejectsInvalidInput(t *testing.T) {
|
|
s := newTestServer(t)
|
|
r := s.Router()
|
|
_ = post(t, r, "/planning", "")
|
|
body := `{"next_action":"","success_condition":"x","timebox_secs":1500}`
|
|
w := post(t, r, "/commitment", body)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestIllegalTransitionReturns409(t *testing.T) {
|
|
s := newTestServer(t)
|
|
r := s.Router()
|
|
// /complete from Locked is illegal.
|
|
w := post(t, r, "/complete", "")
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("expected 409, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestActiveStatePayloadCarriesEvidence(t *testing.T) {
|
|
s := newTestServer(t)
|
|
r := s.Router()
|
|
_ = post(t, r, "/planning", "")
|
|
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}`
|
|
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
|
|
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
|
|
}
|
|
// A focus update should appear in the serialized state.
|
|
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "antidrift", Health: evidence.EvidenceHealth{Available: true}})
|
|
|
|
js := s.stateJSON()
|
|
if !strings.Contains(js, `"evidence"`) {
|
|
t.Fatalf("payload missing evidence object: %s", js)
|
|
}
|
|
if !strings.Contains(js, `"class":"code"`) {
|
|
t.Fatalf("payload missing current window: %s", js)
|
|
}
|
|
}
|
|
|
|
func TestLockedStateHasNullEvidence(t *testing.T) {
|
|
s := newTestServer(t)
|
|
js := s.stateJSON()
|
|
if !strings.Contains(js, `"evidence":null`) {
|
|
t.Fatalf("locked payload should have null evidence: %s", js)
|
|
}
|
|
}
|
|
|
|
type stubCoach struct {
|
|
prop ai.Proposal
|
|
err error
|
|
}
|
|
|
|
func (s stubCoach) Coach(ctx context.Context, intent string) (ai.Proposal, error) {
|
|
return s.prop, s.err
|
|
}
|
|
|
|
func TestCoachRouteHappyPath(t *testing.T) {
|
|
s := newTestServer(t)
|
|
s.ctrl.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}})
|
|
r := s.Router()
|
|
_ = post(t, r, "/planning", "")
|
|
if w := post(t, r, "/coach", `{"intent":"do something"}`); w.Code != http.StatusOK {
|
|
t.Fatalf("/coach code %d body %s", w.Code, w.Body.String())
|
|
}
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if cv := s.ctrl.State().Coach; cv != nil && cv.Status == "ready" {
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatalf("coach never reached ready: %+v", s.ctrl.State().Coach)
|
|
}
|
|
|
|
func TestCoachRouteOutsidePlanning(t *testing.T) {
|
|
s := newTestServer(t)
|
|
s.ctrl.SetCoach(stubCoach{})
|
|
r := s.Router()
|
|
if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestCoachRouteInvalidJSON(t *testing.T) {
|
|
s := newTestServer(t)
|
|
r := s.Router()
|
|
_ = post(t, r, "/planning", "")
|
|
if w := post(t, r, "/coach", `not json`); w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestCoachRouteUnavailableDegrades(t *testing.T) {
|
|
s := newTestServer(t) // no SetCoach
|
|
r := s.Router()
|
|
_ = post(t, r, "/planning", "")
|
|
if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusOK {
|
|
t.Fatalf("unavailable coach should still 200, got %d", w.Code)
|
|
}
|
|
if cv := s.ctrl.State().Coach; cv == nil || cv.Status != "error" {
|
|
t.Fatalf("want error status, got %+v", cv)
|
|
}
|
|
}
|
|
|
|
func TestRefocusAndOnTaskOutsideActiveIs400(t *testing.T) {
|
|
s := newTestServer(t)
|
|
r := s.Router()
|
|
if w := post(t, r, "/refocus", ""); w.Code != http.StatusBadRequest {
|
|
t.Fatalf("refocus outside Active: want 400, got %d", w.Code)
|
|
}
|
|
if w := post(t, r, "/ontask", ""); w.Code != http.StatusBadRequest {
|
|
t.Fatalf("ontask outside Active: want 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
type stubNudger struct{ msg string }
|
|
|
|
func (s stubNudger) Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) {
|
|
return s.msg, nil
|
|
}
|
|
|
|
func TestActiveStatePayloadCarriesNudge(t *testing.T) {
|
|
s := newTestServer(t)
|
|
s.ctrl.SetNudge(stubNudger{msg: "drifted to unrelated work"})
|
|
r := s.Router()
|
|
_ = post(t, r, "/planning", "")
|
|
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500,"allowed_window_classes":["code"]}`
|
|
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
|
|
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
|
|
}
|
|
// Two distinct on-task titles trip the nudge (history >= 2; first call has no
|
|
// debounce to wait on).
|
|
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}})
|
|
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "b.go", Health: evidence.EvidenceHealth{Available: true}})
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if d := s.ctrl.State().Drift; d != nil && d.Nudge != "" {
|
|
break
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
js := s.stateJSON()
|
|
if !strings.Contains(js, `"nudge":"drifted to unrelated work"`) {
|
|
t.Fatalf("payload missing nudge: %s", js)
|
|
}
|
|
}
|
|
|
|
func TestServesStaticAssets(t *testing.T) {
|
|
s := newTestServer(t)
|
|
r := s.Router()
|
|
|
|
cases := []struct {
|
|
path string
|
|
ctSubstr string
|
|
}{
|
|
{"/app.css", "css"},
|
|
{"/app.js", "javascript"},
|
|
}
|
|
for _, tc := range cases {
|
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("GET %s code = %d, want 200", tc.path, w.Code)
|
|
}
|
|
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, tc.ctSubstr) {
|
|
t.Errorf("GET %s Content-Type = %q, want substring %q", tc.path, ct, tc.ctSubstr)
|
|
}
|
|
if w.Body.Len() == 0 {
|
|
t.Errorf("GET %s body is empty", tc.path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) {
|
|
s := newTestServer(t)
|
|
r := s.Router()
|
|
_ = post(t, r, "/planning", "")
|
|
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"]}`
|
|
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
|
|
t.Fatalf("commitment: want 200, got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
if got := s.ctrl.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" {
|
|
t.Fatalf("commitment did not carry allowed classes: %#v", got)
|
|
}
|
|
// Now Active: overrides succeed.
|
|
if w := post(t, r, "/ontask", ""); w.Code != http.StatusOK {
|
|
t.Fatalf("ontask while Active: want 200, got %d", w.Code)
|
|
}
|
|
if w := post(t, r, "/refocus", ""); w.Code != http.StatusOK {
|
|
t.Fatalf("refocus while Active: want 200, got %d", w.Code)
|
|
}
|
|
}
|