Files
SmokeyandClaude Opus 5.5 2d85cab716 Nests: team rooms with core crows and borrowing
- 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
2026-10-27 13:45:04 -06:00

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)
}
}