package chat
import (
"fmt"
"testing"
"time"
)
// fakeChannels is a set of in-memory channels behaving like the plane's
// History: oldest-first, newest page first, with a continuation seq.
type fakeChannels struct {
msgs map[string][]Message // oldest-first per code
reads int // how many channel reads the merge cost
}
func (f *fakeChannels) back(code string, before uint64, limit int) ([]Message, uint64, error) {
f.reads++
all := f.msgs[code]
// Everything strictly older than `before` (0 = from the newest).
end := len(all)
if before > 0 {
end = 0
for i, m := range all {
if m.Seq < before {
end = i + 1
}
}
}
start := end - limit
if start < 0 {
start = 0
}
page := append([]Message(nil), all[start:end]...)
var next uint64
if start > 0 {
next = all[start].Seq
}
return page, next, nil
}
// at builds a message stamped n seconds after a fixed base, so the
// interleaving is explicit in the test rather than incidental.
func at(code string, seq uint64, sec int, text string) Message {
base := time.Date(2026, 9, 13, 12, 0, 0, 0, time.UTC)
return Message{Seq: seq, Kind: "msg", Repo: code, User: "dan", Text: text,
At: base.Add(time.Duration(sec) * time.Second).Format(time.RFC3339Nano)}
}
// The merged room is the channels in one time order, and paging through
// it visits every message exactly once. An off-by-one in the cursor
// either drops a line or shows it twice, and both look like a working
// room until somebody counts.
func TestMergeBackVisitsEveryMessageOnce(t *testing.T) {
f := &fakeChannels{msgs: map[string][]Message{
"a": {at("a", 1, 0, "a1"), at("a", 2, 3, "a2"), at("a", 3, 6, "a3")},
"b": {at("b", 10, 1, "b1"), at("b", 11, 4, "b2")},
"c": {at("c", 100, 2, "c1"), at("c", 101, 5, "c2"), at("c", 102, 7, "c3")},
}}
codes := []string{"a", "b", "c"}
// Newest first across all three: c3(7) a3(6) c2(5) b2(4) a2(3) c1(2) b1(1) a1(0)
want := []string{"c3", "a3", "c2", "b2", "a2", "c1", "b1", "a1"}
for _, limit := range []int{1, 2, 3, 8, 20} {
var got []string
cur := Cursor{}
for {
page, next, err := MergeBack(codes, cur, limit, f.back)
if err != nil {
t.Fatal(err)
}
if len(page) == 0 {
break
}
for _, m := range page {
got = append(got, m.Text)
}
cur = next
if len(got) > len(want) {
t.Fatalf("limit %d: the merge is repeating messages: %v", limit, got)
}
}
if len(got) != len(want) {
t.Errorf("FAIL: limit %d visited %d messages, want %d: %v", limit, len(got), len(want), got)
continue
}
for i := range want {
if got[i] != want[i] {
t.Errorf("FAIL: limit %d position %d is %q, want %q (%v)", limit, i, got[i], want[i], got)
break
}
}
}
}
// A merged page costs one read per channel, not one per message, and
// not one per page-of-history.
func TestMergeBackReadsEachChannelOncePerPage(t *testing.T) {
f := &fakeChannels{msgs: map[string][]Message{}}
for i, code := range []string{"a", "b", "c", "d"} {
for s := 1; s <= 25; s++ {
f.msgs[code] = append(f.msgs[code],
at(code, uint64(i*1000+s), s*4+i, fmt.Sprintf("%s%d", code, s)))
}
}
f.reads = 0
page, _, err := MergeBack([]string{"a", "b", "c", "d"}, Cursor{}, 10, f.back)
if err != nil {
t.Fatal(err)
}
if len(page) != 10 {
t.Fatalf("FAIL: page holds %d, want 10", len(page))
}
if f.reads != 4 {
t.Errorf("FAIL: one page cost %d channel reads, want 4 (one per channel)", f.reads)
}
}
// Fractional seconds: At is RFC3339Nano, which TRIMS trailing zeros, so
// ".1" and ".05" do not order correctly as strings. The merge must
// parse. This is the case that a lexicographic compare gets wrong.
func TestMergeBackOrdersFractionalSecondsNumerically(t *testing.T) {
base := time.Date(2026, 9, 13, 12, 0, 0, 0, time.UTC)
mk := func(code string, seq uint64, ns int, text string) Message {
return Message{Seq: seq, Repo: code, Text: text,
At: base.Add(time.Duration(ns)).Format(time.RFC3339Nano)}
}
f := &fakeChannels{msgs: map[string][]Message{
// .05s — lexicographically "0.05" < "0.1", and numerically too
"a": {mk("a", 1, 50*int(time.Millisecond), "fifty-ms")},
// .1s
"b": {mk("b", 2, 100*int(time.Millisecond), "hundred-ms")},
}}
page, _, err := MergeBack([]string{"a", "b"}, Cursor{}, 5, f.back)
if err != nil {
t.Fatal(err)
}
if len(page) != 2 {
t.Fatalf("FAIL: %d messages", len(page))
}
if page[0].Text != "hundred-ms" {
t.Errorf("FAIL: newest first put %q before %q", page[0].Text, page[1].Text)
}
}
// An exhausted channel stays exhausted. Without the Done half of the
// cursor, "read back to 0" is indistinguishable from "not read yet",
// and a finished channel restarts from its newest message — so the
// oldest page of a merged room would repeat the newest one forever.
func TestAnExhaustedChannelDoesNotRestart(t *testing.T) {
f := &fakeChannels{msgs: map[string][]Message{
"a": {at("a", 1, 0, "only")},
}}
page, cur, err := MergeBack([]string{"a"}, Cursor{}, 5, f.back)
if err != nil || len(page) != 1 {
t.Fatalf("first page: %d messages, err %v", len(page), err)
}
if !cur.Done["a"] {
t.Error("FAIL: a fully-read channel is not marked done")
}
page, _, err = MergeBack([]string{"a"}, cur, 5, f.back)
if err != nil {
t.Fatal(err)
}
if len(page) != 0 {
t.Errorf("FAIL: the exhausted channel served %d messages again", len(page))
}
}
// The team's own channel cannot collide with a repository's, which is
// keyed by the store's 40-hex project code.
func TestATeamCodeCannotLookLikeAProjectCode(t *testing.T) {
code := TeamCode("craft-design-group")
if len(code) == 40 {
t.Error("FAIL: a team code is the length of a project code")
}
for _, r := range code {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
return // contains something no hex code can
}
}
t.Errorf("FAIL: %q is all hex — it could be a project code", code)
}