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
This commit is contained in:
14 files changed
+782
-62
No files matched your search
+214
@@ -0,0 +1,214 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user