package web import ( "crypto/sha256" "fmt" "net/http" "net/http/httptest" "net/url" "os" "os/exec" "path/filepath" "regexp" "strings" "testing" "time" "0x4200.cafe/notevi/sidecar" ) func logLines(lines ...string) string { return strings.Join(lines, "\n") + "\n" } const ( noteA = `{"time":"2026-07-20T10:00:00Z","op":"note","session":"s1","agent":"codex","model":"gpt-5.6-sol","reasoning":"max","dir":"/repo","change":"aaa","file":"src/lib.rs","start":10,"end":20,"note_id":1,"type":"struct","text":"Cache is a two-level map"}` noteB = `{"time":"2026-07-25T10:00:00Z","op":"note","session":"s2","agent":"claude","model":"claude-fable-5","reasoning":"xhigh","dir":"/repo","change":"aaa","file":"src/main.rs","note_id":1,"text":"entry point does too much"}` readA = `{"time":"2026-07-20T09:59:00Z","op":"read","session":"s1","agent":"codex","model":"gpt-5.6-sol","dir":"/repo","change":"aaa","file":"src/lib.rs","start":1,"end":30}` grepA = `{"time":"2026-07-20T09:58:00Z","op":"grep","session":"s1","agent":"codex","dir":"/repo","change":"aaa","file":"src/lib.rs","pattern":"Cache","matches":3}` ) func testSite(t *testing.T) *Site { t.Helper() s := buildModel("test", parseLog([]byte(logLines(grepA, readA, noteA, noteB))), "") s.Theme, s.Light = loadTheme("", "", "dark"), loadTheme("", "", "light") return s } // testThemes stands in for whatever themes this machine happens to have, so // the picker's two files are the same in every test run. func testThemes(dark, light *Theme) *themeSet { return themesFrom([]*Theme{dark, light}, dark, light) } func TestBuildModel(t *testing.T) { s := testSite(t) if s.Change != "aaa" { t.Errorf("site change = %q, want aaa", s.Change) } if len(s.Agents) != 2 || s.Agents[0].Bit != 1 || s.Agents[1].Bit != 2 { t.Errorf("agents = %+v", s.Agents) } f := s.Files["src/lib.rs"] if f == nil || f.Reads != 1 || f.Greps != 1 || len(f.Notes) != 1 { t.Fatalf("src/lib.rs = %+v", f) } if f.Cov[15] != 1 || f.Mask != 1 { t.Errorf("coverage = %v mask %d", f.Cov, f.Mask) } if got := s.Notes[0].When; got == "" { t.Error("note has no rendered timestamp") } } func TestNoteThreadRecursesThroughRepliesAndMentions(t *testing.T) { entries := parseLog([]byte(logLines( `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":1,"text":"root"}`, `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":2,"reply_to":1,"text":"child"}`, `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":3,"reply_to":2,"text":"grandchild"}`, `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":4,"text":"[mentions root](../note/1.html)"}`, `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":5,"reply_to":3,"text":"[also mentions child](note/2)"}`, `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":6,"reply_to":5,"text":"arbitrarily deeper"}`, ))) s := buildModel("test", entries, "") root, child := s.NoteByID(1), s.NoteByID(2) if len(root.Replies) != 1 || root.Replies[0].ID != 2 || len(root.Mentions) != 1 || root.Mentions[0].ID != 4 { t.Fatalf("root edges = replies %+v, mentions %+v", root.Replies, root.Mentions) } if len(child.Mentions) != 1 || child.Mentions[0].ID != 5 { t.Fatalf("child mentions = %+v", child.Mentions) } thread := s.NoteThread(1) if thread == nil || len(thread.Children) != 2 || thread.Children[0].Via != "reply" || thread.Children[1].Via != "mention" { t.Fatalf("root thread = %+v", thread) } // 1 → 2 → 3 → 5 → 6 proves the renderer's data is recursive rather // than a fixed parent/child pair. deep := thread.Children[0].Children[0].Children[0] if deep.Note.ID != 5 || len(deep.Children) != 1 || deep.Children[0].Note.ID != 6 { t.Fatalf("deep reply branch = %+v", deep) } // Note 5 also mentions note 2. It is fully rendered under note 3, then left // as a DAG reference on the second path instead of recursing twice. join := thread.Children[0].Children[1] if join.Note.ID != 5 || !join.Reference || join.Via != "mention" { t.Errorf("DAG join = %+v", join) } } // A ninth agent gets no color instead of overflowing the uint8 mask (and the // per-mask CSS it would generate). func TestAgentColorsAreBounded(t *testing.T) { var lines []string for i := 0; i < maxAgents+3; i++ { lines = append(lines, `{"time":"2026-07-20T10:00:00Z","op":"note","agent":"a`+string(rune('a'+i))+`","change":"aaa","note_id":1,"text":"x"}`) } s := buildModel("t", parseLog([]byte(logLines(lines...))), "") if len(s.Agents) != maxAgents+3 { t.Fatalf("agents = %d", len(s.Agents)) } for i, a := range s.Agents { if i < maxAgents && a.Bit == 0 { t.Errorf("agent %d has no bit", i) } if i >= maxAgents && a.Bit != 0 { t.Errorf("agent %d got bit %d, want none", i, a.Bit) } } if n := len(s.masks()); n != 1<