- Server: nests API, persistence, core-first picking, borrowed flag (built by the backend agent against EVENTS.md; tests pass with -race) - UI: nests in the sidebar, one feed per nest, header shows the core team, New nest dialog, borrowed crows labeled, per-nest waiting count - start.command: one-click local server in controlled mode (mock crows, localhost only, state in .crows/) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQnSw4RRWp8uqpj3nsnnXF
215 lines
7.2 KiB
Go
215 lines
7.2 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestPickCoreFirstThenBorrow(t *testing.T) {
|
|
f := NewFlock(MockBrain{}, DefaultGuardrails(), nil)
|
|
nest := Nest{ID: "launch", Crows: []string{"Pepper", "Inky"}}
|
|
|
|
// Within the core, picks rotate.
|
|
if got := f.pick(nest, 1); !slices.Equal(got, []string{"Pepper"}) {
|
|
t.Fatalf("first solo pick: %v", got)
|
|
}
|
|
if got := f.pick(nest, 1); !slices.Equal(got, []string{"Inky"}) {
|
|
t.Fatalf("second solo pick should rotate within the core: %v", got)
|
|
}
|
|
|
|
// A murder of 3 needs one more than the core: the whole core flies, plus one borrowed.
|
|
got := f.pick(nest, 3)
|
|
if !slices.Contains(got, "Pepper") || !slices.Contains(got, "Inky") {
|
|
t.Fatalf("core crows should fly first: %v", got)
|
|
}
|
|
if got[2] != "Hugin" {
|
|
t.Fatalf("first borrowed crow should be the first roster crow outside the core: %v", got)
|
|
}
|
|
// Borrowing rotates too, and never picks a core crow.
|
|
if got := f.pick(nest, 3); got[2] != "Munin" {
|
|
t.Fatalf("borrowing should rotate: %v", got)
|
|
}
|
|
|
|
// The todo nest keeps the old whole-flock rotation, unaffected by other nests.
|
|
if got := f.pick(defaultNest, 3); !slices.Equal(got, roster[:3]) {
|
|
t.Fatalf("todo nest should rotate the whole roster: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestPickWholeFlockCore(t *testing.T) {
|
|
f := NewFlock(MockBrain{}, DefaultGuardrails(), nil)
|
|
nest := Nest{ID: "all", Crows: slices.Clone(roster)}
|
|
got := f.pick(nest, 3)
|
|
if !slices.Equal(got, roster[:3]) {
|
|
t.Fatalf("got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestNestValidation(t *testing.T) {
|
|
s, _ := LoadNests(filepath.Join(t.TempDir(), "nests.json"))
|
|
bad := []struct {
|
|
name string
|
|
crows []string
|
|
}{
|
|
{" ", []string{"Pepper"}},
|
|
{strings.Repeat("a", 41), []string{"Pepper"}},
|
|
{"!!!", []string{"Pepper"}},
|
|
{"No crows", nil},
|
|
{"Too many", append(slices.Clone(roster), "Hugin")},
|
|
{"Stranger", []string{"Pepper", "Tweety"}},
|
|
{"Sentry inside", []string{"Sentry"}},
|
|
{"Twice", []string{"Pepper", "Pepper"}},
|
|
}
|
|
for _, b := range bad {
|
|
_, err := s.Create(b.name, b.crows)
|
|
var ne *NestError
|
|
if err == nil || !errors.As(err, &ne) || ne.Clash {
|
|
t.Errorf("Create(%q, %v): want invalid, got %v", b.name, b.crows, err)
|
|
}
|
|
}
|
|
|
|
n, err := s.Create(" Launch -- Copy! ", []string{"Pepper", "Inky"})
|
|
if err != nil || n.ID != "launch-copy" || n.Name != "Launch -- Copy!" {
|
|
t.Fatalf("got %+v, %v", n, err)
|
|
}
|
|
if _, err := s.Create(strings.Repeat("a", 40), []string{"Jet"}); err != nil {
|
|
t.Fatalf("40 characters should be fine: %v", err)
|
|
}
|
|
for _, name := range []string{"launch copy", "TODO"} {
|
|
_, err := s.Create(name, []string{"Jet"})
|
|
var ne *NestError
|
|
if !errors.As(err, &ne) || !ne.Clash {
|
|
t.Errorf("Create(%q): want clash, got %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNestsPersistAndCap(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "nests.json")
|
|
s, _ := LoadNests(path)
|
|
for i := 1; i < maxNests; i++ { // todo is nest number one
|
|
if _, err := s.Create(fmt.Sprintf("Nest %d", i), []string{"Soot"}); err != nil {
|
|
t.Fatalf("nest %d: %v", i, err)
|
|
}
|
|
}
|
|
if _, err := s.Create("One too many", []string{"Soot"}); err == nil {
|
|
t.Fatal("want the nest cap to refuse")
|
|
}
|
|
|
|
reloaded, err := LoadNests(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
all := reloaded.All()
|
|
if len(all) != maxNests || all[0].ID != "todo" || all[1].ID != "nest-1" || all[maxNests-1].ID != "nest-19" {
|
|
t.Fatalf("reload lost order or nests: %d nests, first %v", len(all), all[:2])
|
|
}
|
|
if n, ok := reloaded.Get("nest-7"); !ok || !slices.Equal(n.Crows, []string{"Soot"}) {
|
|
t.Fatalf("Get after reload: %+v %v", n, ok)
|
|
}
|
|
}
|
|
|
|
func TestServerNests(t *testing.T) {
|
|
ts, _ := newTestServer(t, DefaultGuardrails(), time.Minute)
|
|
|
|
var got struct {
|
|
Roster []string `json:"roster"`
|
|
Nests []Nest `json:"nests"`
|
|
}
|
|
getJSON(t, ts.URL+"/api/nests", &got)
|
|
if !slices.Equal(got.Roster, roster) || len(got.Nests) != 1 || got.Nests[0].ID != "todo" || got.Nests[0].Crows == nil {
|
|
t.Fatalf("GET /api/nests: %+v", got)
|
|
}
|
|
|
|
code, out := post(t, ts.URL+"/api/nests", `{"name":"Launch copy","crows":["Pepper","Inky"]}`)
|
|
if code != http.StatusCreated || out["nest_id"] != "launch-copy" {
|
|
t.Fatalf("create: %d %v", code, out)
|
|
}
|
|
if code, out := post(t, ts.URL+"/api/nests", `{"name":"launch COPY","crows":["Jet"]}`); code != http.StatusConflict || out["message"] == nil {
|
|
t.Fatalf("clash: want 409 with message, got %d %v", code, out)
|
|
}
|
|
if code, out := post(t, ts.URL+"/api/nests", `{"name":"Bad","crows":["Elder"]}`); code != http.StatusBadRequest || out["message"] == nil {
|
|
t.Fatalf("bad crow: want 400 with message, got %d %v", code, out)
|
|
}
|
|
if code, _ := post(t, ts.URL+"/api/nests", `{"name":"x","crows":`+strings.Repeat(" ", maxBody)+`[]}`); code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("want 413, got %d", code)
|
|
}
|
|
getJSON(t, ts.URL+"/api/nests", &got)
|
|
if len(got.Nests) != 2 || got.Nests[1].ID != "launch-copy" {
|
|
t.Fatalf("after create: %+v", got.Nests)
|
|
}
|
|
|
|
if code, out := post(t, ts.URL+"/api/lists", `{"text":"Write a poem","requester":"t","nest_id":"nope"}`); code != http.StatusNotFound || out["message"] == nil {
|
|
t.Fatalf("unknown nest: want 404 with message, got %d %v", code, out)
|
|
}
|
|
}
|
|
|
|
func TestServerBorrowedEvents(t *testing.T) {
|
|
ts, _ := newTestServer(t, DefaultGuardrails(), time.Minute)
|
|
post(t, ts.URL+"/api/nests", `{"name":"Launch copy","crows":["Pepper","Inky"]}`)
|
|
yes := true
|
|
|
|
// "should" makes the mock Sentry call a murder of 3: the 2-crow core plus one borrowed.
|
|
_, out := post(t, ts.URL+"/api/lists", `{"text":"Should we launch on a Tuesday","requester":"t","nest_id":"launch-copy"}`)
|
|
events, _ := stream(t, ts, out["list_id"].(string), &yes)
|
|
if events[0].Data["nest_id"] != "launch-copy" {
|
|
t.Fatalf("list.started nest_id: %v", events[0].Data)
|
|
}
|
|
borrowed := flights(t, events)
|
|
if !slices.Equal(borrowed["Pepper"], []bool{false}) || !slices.Equal(borrowed["Inky"], []bool{false}) ||
|
|
!slices.Equal(borrowed["Hugin"], []bool{true}) || !slices.Equal(borrowed["Sentry"], []bool{false}) ||
|
|
!slices.Equal(borrowed["Elder"], []bool{false}) {
|
|
t.Fatalf("borrowed flags: %v", borrowed)
|
|
}
|
|
|
|
// The todo nest (the default) never borrows.
|
|
_, out = post(t, ts.URL+"/api/lists", `{"text":"Should we launch on a Tuesday","requester":"t"}`)
|
|
events, _ = stream(t, ts, out["list_id"].(string), &yes)
|
|
if events[0].Data["nest_id"] != "todo" {
|
|
t.Fatalf("default nest_id: %v", events[0].Data)
|
|
}
|
|
for crow, flags := range flights(t, events) {
|
|
if slices.Contains(flags, true) {
|
|
t.Fatalf("%s borrowed in the todo nest", crow)
|
|
}
|
|
}
|
|
}
|
|
|
|
// flights maps each crow to the borrowed flag of each of its crow.flying events.
|
|
func flights(t *testing.T, events []sseEvent) map[string][]bool {
|
|
m := map[string][]bool{}
|
|
for _, e := range events {
|
|
if e.Name != "crow.flying" {
|
|
continue
|
|
}
|
|
b, ok := e.Data["borrowed"].(bool)
|
|
if !ok {
|
|
t.Fatalf("crow.flying without a borrowed bool: %v", e.Data)
|
|
}
|
|
c := e.Data["crow"].(string)
|
|
m[c] = append(m[c], b)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func getJSON(t *testing.T, url string, v any) {
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("GET %s: %d", url, resp.StatusCode)
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|