package tldr import ( "context" "errors" "fmt" "net/http" "strconv" "strings" "sync" "testing" "time" "github.com/bwmarrin/discordgo" ) func TestParseSince(t *testing.T) { cases := []struct { in string want time.Duration err bool }{ {"30m", 30 * time.Minute, false}, {"6h", 6 * time.Hour, false}, {"2d", 48 * time.Hour, false}, {" 2D ", 48 * time.Hour, false}, {"30d", maxWindow, false}, {"9999h", maxWindow, false}, {"0h", 0, true}, {"", 0, true}, {"1h30m", 0, true}, {"yesterday", 0, true}, {"-5m", 0, true}, } for _, c := range cases { got, err := ParseSince(c.in) if (err != nil) != c.err { t.Errorf("ParseSince(%q) err = %v, want err=%v", c.in, err, c.err) continue } if !c.err && got != c.want { t.Errorf("ParseSince(%q) = %v, want %v", c.in, got, c.want) } } } func TestSnowflakeFromTime(t *testing.T) { // Known pairing: a snowflake's timestamp is (id >> 22) + discord epoch. ts := time.UnixMilli(1700000000000) id, err := strconv.ParseInt(SnowflakeFromTime(ts), 10, 64) if err != nil { t.Fatal(err) } if got := (id >> 22) + discordEpochMs; got != 1700000000000 { t.Errorf("round trip = %d", got) } if SnowflakeFromTime(time.UnixMilli(0)) != "0" { t.Errorf("pre-epoch time should clamp to 0") } } func TestCompareIDs(t *testing.T) { // Lexical comparison would get "9" vs "10" wrong. if CompareIDs("9", "10") >= 0 || CompareIDs("10", "9") <= 0 || CompareIDs("5", "5") != 0 { t.Error("numeric comparison broken") } } func msg(id, author, content string) *discordgo.Message { return &discordgo.Message{ ID: id, Content: content, Author: &discordgo.User{ID: author, Username: author}, } } func TestCondense(t *testing.T) { bot := msg("1", "bot", "hi") bot.Author.Bot = true system := msg("2", "a", "joined") system.Type = discordgo.MessageTypeGuildMemberJoin attachOnly := msg("3", "a", "") attachOnly.Attachments = []*discordgo.MessageAttachment{{}} withAttach := msg("4", "a", "olha a foto") withAttach.Attachments = []*discordgo.MessageAttachment{{}} mention := msg("5", "a", "hey <@42> and <@!42>\n\nlook") mention.Mentions = []*discordgo.User{{ID: "42", Username: "bob", GlobalName: "Bob"}} long := msg("6", "a", strings.Repeat("x", 900)) for name, m := range map[string]*discordgo.Message{ "bot": bot, "system": system, "blank": msg("7", "a", " \n "), "attachment only": attachOnly, "emoji only": msg("8", "a", "👍🔥"), "custom emoji": msg("9", "a", "<:pog:123456789>"), "link only": msg("10", "a", "https://example.com/some/very/long/path?x=1"), "filler ok": msg("11", "a", "Ok!"), "filler lol": msg("12", "a", "lol"), "filler kkk": msg("13", "a", "kkkk"), } { if _, _, ok := condense(m); ok { t.Errorf("%s should be dropped", name) } } check := func(m *discordgo.Message, want string) { t.Helper() _, got, ok := condense(m) if !ok || got != want { t.Errorf("condense(%q) = %q, %v; want %q", m.Content, got, ok, want) } } check(msg("20", "a", "sim"), "sim") // short answers can be votes, keep them check(msg("21", "a", "olha isto https://www.example.com/x?y=1 fixe"), "olha isto [link: example.com] fixe") check(msg("22", "a", "vejam ."), "vejam [link: youtu.be].") check(msg("23", "a", "haha https://tenor.com/view/cat-123 mesmo"), "haha [gif] mesmo") check(msg("24", "a", "foto https://cdn.discordapp.com/a/b/c.PNG?ex=1 ok"), "foto [image] ok") check(withAttach, "olha a foto [attachment]") check(mention, "hey @Bob and @Bob look") _, got, _ := condense(long) if r := []rune(got); len(r) != maxPerMsgChars+1 { t.Errorf("long message = %d runes, want %d", len(r), maxPerMsgChars+1) } } func TestSystemPromptHasExample(t *testing.T) { // The prompt that was benchmarked is the instructions followed by one worked example. if !strings.HasPrefix(systemPrompt, instructions+"\n\nExample.\nMessages:\n") || !strings.Contains(systemPrompt, "Overview:\n- ") { t.Error("system prompt should be the instructions plus the worked example") } } func TestCondenseRichContent(t *testing.T) { check := func(m *discordgo.Message, want string) { t.Helper() _, got, ok := condense(m) if !ok || got != want { t.Errorf("condense(%q) = %q, %v; want %q", m.Content, got, ok, want) } } dropped := func(name string, m *discordgo.Message) { t.Helper() if _, got, ok := condense(m); ok { t.Errorf("%s should be dropped, got %q", name, got) } } photo := msg("1", "a", "olha") photo.Attachments = []*discordgo.MessageAttachment{{Filename: "IMG_1.jpg", ContentType: "image/jpeg"}, {Filename: "x.gif"}} check(photo, "olha [image] [gif]") photoOnly := msg("2", "a", "") photoOnly.Attachments = []*discordgo.MessageAttachment{{Filename: "IMG_1.jpg", ContentType: "image/jpeg"}} dropped("photo only", photoOnly) doc := msg("3", "a", "") doc.Attachments = []*discordgo.MessageAttachment{{Filename: "orçamento-setembro.pdf", ContentType: "application/pdf"}} check(doc, "[file: orçamento-setembro.pdf]") video := msg("4", "a", "vejam https://youtu.be/abc") video.Embeds = []*discordgo.MessageEmbed{{Type: discordgo.EmbedTypeVideo, Title: " Como renovar certificados TLS "}} check(video, `vejam [link: youtu.be] (preview: "Como renovar certificados TLS")`) linkOnly := msg("5", "a", "https://youtu.be/abc") linkOnly.Embeds = []*discordgo.MessageEmbed{{Type: discordgo.EmbedTypeVideo, Title: "Tutorial de Grafana"}} check(linkOnly, `[link: youtu.be] (preview: "Tutorial de Grafana")`) gif := msg("6", "a", "https://tenor.com/view/cat-123") gif.Embeds = []*discordgo.MessageEmbed{{Type: discordgo.EmbedTypeGifv, Title: "cat"}} dropped("gif only", gif) ref := msg("7", "Ana", "") ref.Author.GlobalName = "Ana" reply := msg("8", "Rui", "concordo") reply.Type = discordgo.MessageTypeReply reply.ReferencedMessage = ref check(reply, "(reply to Ana) concordo") selfReply := msg("9", "Ana", "e mais uma coisa") selfReply.Type = discordgo.MessageTypeReply selfReply.ReferencedMessage = ref check(selfReply, "e mais uma coisa") deleted := msg("10", "Rui", "ok mas porquê") deleted.Type = discordgo.MessageTypeReply check(deleted, "ok mas porquê") } func TestBuildTranscriptMergesAuthors(t *testing.T) { tr := BuildTranscript([]ChannelBatch{{ID: "1", Name: "dev", Messages: []*discordgo.Message{ msg("1", "Ana", "primeira"), msg("2", "Ana", "segunda"), msg("3", "Rui", "lol"), // filler between two Ana messages is dropped msg("4", "Ana", "terceira"), msg("5", "Rui", "resposta"), }}}) want := "Ana: primeira / segunda / terceira\nRui: resposta\n" if len(tr.Chunks) != 1 || tr.Chunks[0] != want { t.Errorf("chunk = %q, want %q", tr.Chunks[0], want) } if tr.Messages != 4 || tr.LastProcessed["1"] != "5" { t.Errorf("messages=%d cursor=%s", tr.Messages, tr.LastProcessed["1"]) } } func TestBuildTranscriptCutsLongRunsOfOneAuthor(t *testing.T) { var msgs []*discordgo.Message for i := 1; i <= 10; i++ { msgs = append(msgs, msg(strconv.Itoa(i), "a", strings.Repeat("w", 300))) } tr := BuildTranscript([]ChannelBatch{{ID: "1", Name: "c", Messages: msgs}}) for _, line := range strings.Split(tr.Chunks[0], "\n") { if len(line) > maxMergedChars+20 { t.Errorf("merged line is %d chars, over the %d cap", len(line), maxMergedChars) } } if tr.Messages != 10 { t.Errorf("messages = %d", tr.Messages) } } func TestDetectLanguage(t *testing.T) { pt := "o deploy de ontem partiu o login que nao tinha corrido, vou ver para ti mas já está feito. foi uma coisa de pelo menos duas horas, são horas de parar" en := "the deploy broke the login and that is the thing we saw, you are right that it was the cause of this and it is for the team to fix with the rest" if got := detectLanguage([]string{pt}); got != "Portuguese" { t.Errorf("pt = %q", got) } if got := detectLanguage([]string{en}); got != "English" { t.Errorf("en = %q", got) } if got := detectLanguage([]string{"ok", "lol"}); got != "" { t.Errorf("too little text should be unsure, got %q", got) } if got := detectLanguage([]string{pt + " " + en}); got != "" { t.Errorf("mixed text should be unsure, got %q", got) } } func TestSummarizePromptNamesDetectedLanguage(t *testing.T) { f := &fakeSummarizer{} pt := strings.Repeat("o deploy de ontem partiu o login que nao tinha corrido, vou ver para ti mas já está feito. ", 3) if _, err := Summarize(context.Background(), f, []string{pt}, nil, nil); err != nil { t.Fatal(err) } if !strings.Contains(f.users[0], "Write the overview in Portuguese.") { t.Errorf("user prompt should name Portuguese: %q", f.users[0][len(f.users[0])-80:]) } f = &fakeSummarizer{} if _, err := Summarize(context.Background(), f, []string{"ok"}, nil, nil); err != nil { t.Fatal(err) } if !strings.Contains(f.users[0], "same language as the messages") { t.Error("an unsure guess should fall back to the generic language line") } } func TestBuildTranscriptCursorAndChunks(t *testing.T) { bot := msg("11", "bot", "ignored") bot.Author.Bot = true batches := []ChannelBatch{ {ID: "100", Name: "general", Messages: []*discordgo.Message{msg("10", "a", "one"), bot}}, {ID: "200", Name: "dev", Messages: []*discordgo.Message{msg("20", "b", "two")}}, } tr := BuildTranscript(batches) if tr.Messages != 2 || tr.Channels != 2 || tr.Truncated || len(tr.Chunks) != 1 { t.Fatalf("unexpected transcript: %+v", tr) } // The skipped bot message still counts as processed so it never resurfaces. if tr.LastProcessed["100"] != "11" || tr.LastProcessed["200"] != "20" { t.Errorf("cursors = %v", tr.LastProcessed) } want := "## #general\na: one\n## #dev\nb: two\n" if tr.Chunks[0] != want { t.Errorf("chunk = %q, want %q", tr.Chunks[0], want) } } func TestBuildTranscriptSingleChannelHasNoHeaderAndSplitsWithinBudget(t *testing.T) { var msgs []*discordgo.Message line := strings.Repeat("y", 400) for i := 1; i <= 100; i++ { msgs = append(msgs, msg(strconv.Itoa(i), "a", line)) } tr := BuildTranscript([]ChannelBatch{{ID: "1", Name: "busy", Messages: msgs}}) if len(tr.Chunks) < 2 { t.Fatalf("expected several chunks, got %d", len(tr.Chunks)) } for idx, c := range tr.Chunks { if len(c) > chunkBudgetChars { t.Errorf("chunk %d is %d chars, over budget", idx, len(c)) } if strings.Contains(c, "## #") { t.Errorf("chunk %d has a channel header, a single channel should not", idx) } } if tr.Messages != 100 || tr.Truncated { t.Errorf("all 100 should fit: %+v", tr) } } func TestBuildTranscriptSeveralChannelsRepeatHeadersPerChunk(t *testing.T) { var one, two []*discordgo.Message line := strings.Repeat("y", 400) for i := 1; i <= 60; i++ { one = append(one, msg(strconv.Itoa(i), "a", line)) two = append(two, msg(strconv.Itoa(1000+i), "b", line)) } tr := BuildTranscript([]ChannelBatch{{ID: "1", Name: "one", Messages: one}, {ID: "2", Name: "two", Messages: two}}) if len(tr.Chunks) < 2 { t.Fatalf("expected several chunks, got %d", len(tr.Chunks)) } for idx, c := range tr.Chunks { if !strings.HasPrefix(c, "## #") { t.Errorf("chunk %d does not start with a channel header", idx) } if len(c) > chunkBudgetChars { t.Errorf("chunk %d is %d chars, over budget", idx, len(c)) } } } func TestBuildTranscriptTruncatesAndHoldsCursor(t *testing.T) { line := strings.Repeat("z", 480) var msgs []*discordgo.Message for i := 1; i <= 400; i++ { // ~190k chars, far over the 90k total budget msgs = append(msgs, msg(strconv.Itoa(i), "a", line)) } later := msg("999", "b", "never reached") tr := BuildTranscript([]ChannelBatch{ {ID: "1", Name: "a", Messages: msgs}, {ID: "2", Name: "b", Messages: []*discordgo.Message{later}}, }) if !tr.Truncated || len(tr.Chunks) > maxChunks { t.Fatalf("expected truncation within %d chunks: truncated=%v chunks=%d", maxChunks, tr.Truncated, len(tr.Chunks)) } last := tr.LastProcessed["1"] if n, _ := strconv.Atoi(last); n != tr.Messages { t.Errorf("cursor %s should sit exactly at the last included message (%d)", last, tr.Messages) } if _, ok := tr.LastProcessed["2"]; ok { t.Error("a channel after the cut-off must not get a cursor") } } type fakeFetcher struct { all []*discordgo.Message // ascending calls int } func (f *fakeFetcher) ChannelMessages(_ string, limit int, _, afterID, _ string, _ ...discordgo.RequestOption) ([]*discordgo.Message, error) { f.calls++ var out []*discordgo.Message for _, m := range f.all { if CompareIDs(m.ID, afterID) > 0 { out = append(out, m) if len(out) == limit { break } } } // Discord hands each page back newest first. for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { out[i], out[j] = out[j], out[i] } return out, nil } func TestFetchAfterPagesInOrder(t *testing.T) { f := &fakeFetcher{} for i := 1; i <= 250; i++ { f.all = append(f.all, msg(strconv.Itoa(i), "a", "m")) } got, err := FetchAfter(f, "c", "0", 1000) if err != nil || len(got) != 250 || f.calls != 3 { t.Fatalf("len=%d calls=%d err=%v", len(got), f.calls, err) } for i, m := range got { if m.ID != strconv.Itoa(i+1) { t.Fatalf("out of order at %d: %s", i, m.ID) } } f.calls = 0 got, _ = FetchAfter(f, "c", "0", 120) if len(got) != 120 || got[119].ID != "120" || f.calls != 2 { t.Errorf("limit not honoured: len=%d last=%s calls=%d", len(got), got[len(got)-1].ID, f.calls) } } type fakeSummarizer struct { mu sync.Mutex calls []string users []string err error } func (f *fakeSummarizer) Chat(_ context.Context, system, user string) (string, error) { f.mu.Lock() defer f.mu.Unlock() f.calls = append(f.calls, system) f.users = append(f.users, user) if f.err != nil { return "", f.err } return fmt.Sprintf("summary-%d", len(f.calls)), nil } func TestSummarizeSingleAndMerge(t *testing.T) { f := &fakeSummarizer{} out, err := Summarize(context.Background(), f, []string{"a"}, nil, nil) if err != nil || out != "summary-1" || len(f.calls) != 1 { t.Fatalf("single chunk: %q %v calls=%d", out, err, len(f.calls)) } f = &fakeSummarizer{} var steps [][2]int out, err = Summarize(context.Background(), f, []string{"a", "b", "c"}, func(done, total int) { steps = append(steps, [2]int{done, total}) }, nil) if err != nil || len(f.calls) != 4 || out != "summary-4" { t.Fatalf("map-reduce: %q %v calls=%d", out, err, len(f.calls)) } if f.calls[3] != mergePrompt || f.calls[0] != systemPrompt { t.Error("final call should use the merge prompt, the rest the summary prompt") } if len(steps) != 4 || steps[0] != [2]int{0, 4} || steps[3] != [2]int{3, 4} { t.Errorf("progress = %v", steps) } if _, err := Summarize(context.Background(), &fakeSummarizer{err: errors.New("boom")}, []string{"a"}, nil, nil); err == nil { t.Error("model errors must propagate") } if _, err := Summarize(context.Background(), f, nil, nil, nil); err == nil { t.Error("empty input must error") } } const readPerms = int64(discordgo.PermissionViewChannel | discordgo.PermissionReadMessageHistory) type fakeDiscord struct { *fakeFetcher botPerms int64 fetched map[string]string // channel ID -> the "after" it was first fetched with fetchErr error } func newFakeDiscord(msgs ...*discordgo.Message) *fakeDiscord { return &fakeDiscord{fakeFetcher: &fakeFetcher{all: msgs}, botPerms: readPerms, fetched: map[string]string{}} } func (d *fakeDiscord) ChannelMessages(ch string, limit int, b, after, a string, o ...discordgo.RequestOption) ([]*discordgo.Message, error) { if _, seen := d.fetched[ch]; !seen { d.fetched[ch] = after } if d.fetchErr != nil { return nil, d.fetchErr } return d.fakeFetcher.ChannelMessages(ch, limit, b, after, a, o...) } func (d *fakeDiscord) UserChannelPermissions(string, string, ...discordgo.RequestOption) (int64, error) { return d.botPerms, nil } type fakeStore struct { cursors map[int64]int64 saved map[int64]int64 } func (s *fakeStore) GetCursors(context.Context, int64, int64) (map[int64]int64, error) { return s.cursors, nil } func (s *fakeStore) SetCursors(_ context.Context, _, _ int64, c map[int64]int64) error { s.saved = c return nil } func req(since time.Duration, now time.Time) Request { return Request{GuildID: "1", ChannelID: "10", UserID: "7", BotID: "bot", UserPerms: readPerms, Since: since, Now: now} } func TestRunSummarizesOnlyTheRequestedChannel(t *testing.T) { now := time.Now() oldID := SnowflakeFromTime(now.Add(-30 * 24 * time.Hour)) // far older than the 7d floor recent, _ := strconv.ParseInt(SnowflakeFromTime(now.Add(-time.Hour)), 10, 64) d := newFakeDiscord( msg(strconv.FormatInt(recent+1, 10), "a", "deploy is broken"), msg(strconv.FormatInt(recent+2, 10), "b", "fixing it now"), ) staleCursor, _ := strconv.ParseInt(oldID, 10, 64) // The user also has a cursor in another channel, which must be left alone. store := &fakeStore{cursors: map[int64]int64{10: staleCursor, 99: 12345}} sum := &fakeSummarizer{} res, err := Run(context.Background(), d, store, sum, req(0, now), nil, nil) if err != nil { t.Fatal(err) } if res.Empty || res.Messages != 2 || res.Summary != "summary-1" { t.Fatalf("result = %+v", res) } if len(d.fetched) != 1 { t.Errorf("fetched channels = %v, want only channel 10", d.fetched) } if got := d.fetched["10"]; CompareIDs(got, SnowflakeFromTime(now.Add(-maxWindow))) < 0 { t.Errorf("stale cursor reached back past the 7-day floor: %s", got) } if len(store.saved) != 1 || store.saved[10] != recent+2 { t.Errorf("cursors saved = %v, want only channel 10 -> %d", store.saved, recent+2) } if strings.Contains(sum.users[0], "## #") { t.Error("a single-channel prompt should carry no channel header") } } func TestRunRefusesWhenTheUserCannotReadTheChannel(t *testing.T) { d := newFakeDiscord(msg("1", "a", "secret plans")) sum := &fakeSummarizer{} r := req(0, time.Now()) r.UserPerms = discordgo.PermissionViewChannel // can see the channel but not its history _, err := Run(context.Background(), d, &fakeStore{}, sum, r, nil, nil) if !errors.Is(err, ErrUserCannotRead) { t.Fatalf("err = %v, want ErrUserCannotRead", err) } if len(d.fetched) != 0 || len(sum.calls) != 0 { t.Errorf("nothing should be fetched or summarized: fetched=%v calls=%d", d.fetched, len(sum.calls)) } } func TestRunRefusesWhenTheBotCannotReadTheChannel(t *testing.T) { d := newFakeDiscord(msg("1", "a", "hello")) d.botPerms = discordgo.PermissionViewChannel _, err := Run(context.Background(), d, &fakeStore{}, &fakeSummarizer{}, req(0, time.Now()), nil, nil) if !errors.Is(err, ErrBotCannotRead) || len(d.fetched) != 0 { t.Errorf("err=%v fetched=%v", err, d.fetched) } } func TestRunMapsADiscord403ToBotCannotRead(t *testing.T) { d := newFakeDiscord() d.fetchErr = &discordgo.RESTError{Response: &http.Response{StatusCode: http.StatusForbidden}} _, err := Run(context.Background(), d, &fakeStore{}, &fakeSummarizer{}, req(0, time.Now()), nil, nil) if !errors.Is(err, ErrBotCannotRead) { t.Errorf("err = %v, want ErrBotCannotRead", err) } d.fetchErr = errors.New("network down") if _, err := Run(context.Background(), d, &fakeStore{}, &fakeSummarizer{}, req(0, time.Now()), nil, nil); err == nil || errors.Is(err, ErrBotCannotRead) { t.Errorf("other fetch errors must not look like a permission problem: %v", err) } } func TestRunSinceModeLeavesCursorsAlone(t *testing.T) { now := time.Now() recent, _ := strconv.ParseInt(SnowflakeFromTime(now.Add(-time.Minute)), 10, 64) d := newFakeDiscord(msg(strconv.FormatInt(recent+1, 10), "a", "hello")) store := &fakeStore{} res, err := Run(context.Background(), d, store, &fakeSummarizer{}, req(time.Hour, now), nil, nil) if err != nil || res.Empty { t.Fatalf("res=%+v err=%v", res, err) } if store.saved != nil { t.Errorf("since mode must not move cursors, saved %v", store.saved) } } func TestRunNothingNew(t *testing.T) { res, err := Run(context.Background(), newFakeDiscord(), &fakeStore{}, &fakeSummarizer{}, req(0, time.Now()), nil, nil) if err != nil || !res.Empty { t.Fatalf("res=%+v err=%v", res, err) } } // fakeStreamer records whether each call went through Chat or ChatStream. type fakeStreamer struct { mu sync.Mutex log []string } func (f *fakeStreamer) Chat(context.Context, string, string) (string, error) { f.mu.Lock() defer f.mu.Unlock() f.log = append(f.log, "chat") return "partial", nil } func (f *fakeStreamer) ChatStream(_ context.Context, _, _ string, onText func(string)) (string, error) { f.mu.Lock() f.log = append(f.log, "stream") f.mu.Unlock() for _, s := range []string{"he", "hello", "hello world"} { if onText != nil { onText(s) } } return "hello world", nil } func TestSummarizeStreamsTheSingleChunk(t *testing.T) { f := &fakeStreamer{} var seen []string out, err := Summarize(context.Background(), f, []string{"a"}, nil, func(s string) { seen = append(seen, s) }) if err != nil || out != "hello world" { t.Fatalf("out=%q err=%v", out, err) } if strings.Join(f.log, ",") != "stream" || len(seen) != 3 || seen[2] != "hello world" { t.Errorf("log=%v seen=%v", f.log, seen) } } func TestSummarizeStreamsOnlyTheFinalMerge(t *testing.T) { f := &fakeStreamer{} var seen []string out, err := Summarize(context.Background(), f, []string{"a", "b", "c"}, nil, func(s string) { seen = append(seen, s) }) if err != nil || out != "hello world" { t.Fatalf("out=%q err=%v", out, err) } // The three per-chunk passes are intermediate notes and must not stream. if strings.Join(f.log, ",") != "chat,chat,chat,stream" || len(seen) != 3 { t.Errorf("log=%v seen=%v", f.log, seen) } } func TestSummarizeWithoutPartialUsesPlainChat(t *testing.T) { f := &fakeStreamer{} out, err := Summarize(context.Background(), f, []string{"a"}, nil, nil) if err != nil || out != "partial" || strings.Join(f.log, ",") != "chat" { t.Errorf("out=%q err=%v log=%v", out, err, f.log) } } func TestSummarizeFallsBackWhenSummarizerCannotStream(t *testing.T) { called := false out, err := Summarize(context.Background(), &fakeSummarizer{}, []string{"a"}, nil, func(string) { called = true }) if err != nil || out != "summary-1" || called { t.Errorf("out=%q err=%v partialCalled=%v", out, err, called) } } func TestThrottle(t *testing.T) { clock := time.Unix(1000, 0) now := func() time.Time { return clock } var got []string f := throttle(2*time.Second, now, func(s string) { got = append(got, s) }) f("a") // first call always passes clock = clock.Add(time.Second) f("b") // too soon clock = clock.Add(time.Second) f("c") // 2s since the last edit clock = clock.Add(500 * time.Millisecond) f("d") // too soon again if strings.Join(got, "") != "ac" { t.Errorf("edits = %v, want [a c]", got) } if throttle(time.Second, now, nil) != nil { t.Error("a nil callback should stay nil") } }