`go test -tags bench ./internal/tldr -run TestBench` runs the real /tldr pipeline against one or more Ollama models and prompt variants on a chat dump from disk (text copied from Discord, "Author: message" lines, or a DiscordChatExporter JSON export) and writes a self-contained HTML dashboard plus a JSON of the results. Scoring uses an optional facts file (a fact passes only when one bullet holds all its details) and automatic checks: numbers not in the chat, copying the chat, an English intro, cut-off output. TestFactsSkeleton proposes candidate facts from the chat to prune by hand. Chat dumps and results stay local: bench-out/ and the private folders are git-ignored. See internal/tldr/tldrbench/README.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
232 lines
8.3 KiB
Go
232 lines
8.3 KiB
Go
package tldrbench
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/bwmarrin/discordgo"
|
|
)
|
|
|
|
func write(t *testing.T, name, content string) string {
|
|
t.Helper()
|
|
p := filepath.Join(t.TempDir(), name)
|
|
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func texts(msgs []*discordgo.Message) []string {
|
|
var out []string
|
|
for _, m := range msgs {
|
|
out = append(out, m.Author.Username+"|"+m.Content)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestLoadAuthorLines(t *testing.T) {
|
|
msgs, err := LoadChat(write(t, "chat.txt", "Ana: olá a todos\nRui: bom dia\nsegunda linha do Rui\n\nAna: http://x.pt/a?b=1: link\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []string{"Ana|olá a todos", "Rui|bom dia\nsegunda linha do Rui", "Ana|http://x.pt/a?b=1: link"}
|
|
if got := texts(msgs); strings.Join(got, ";") != strings.Join(want, ";") {
|
|
t.Errorf("got %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestLoadClientCopy(t *testing.T) {
|
|
chat := "Ana — Today at 14:32\nolá pessoal\nvamos ao jantar?\n[14:33]\nque horas?\nRui — Today at 14:35\nsexta às 20h\nAna — 12/10/2026 14:40\nboa\n"
|
|
msgs, err := LoadChat(write(t, "copy.txt", chat))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []string{"Ana|olá pessoal", "Ana|vamos ao jantar?", "Ana|que horas?", "Rui|sexta às 20h", "Ana|boa"}
|
|
if got := texts(msgs); strings.Join(got, ";") != strings.Join(want, ";") {
|
|
t.Errorf("got %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestLoadExport(t *testing.T) {
|
|
js := `{"messages":[
|
|
{"id":"1","type":"Default","content":"olha isto https://youtu.be/x","author":{"id":"9","name":"ana","nickname":"Ana","isBot":false},"embeds":[{"title":"Um vídeo"}]},
|
|
{"id":"2","type":"Reply","content":"vi","author":{"id":"8","name":"rui","isBot":false},"reference":{"messageId":"1"},"attachments":[{"fileName":"a.png"}]},
|
|
{"id":"3","type":"GuildMemberJoin","content":"","author":{"id":"7","name":"x"}},
|
|
{"id":"4","type":"Default","content":"beep","author":{"id":"6","name":"bot","isBot":true}}]}`
|
|
msgs, err := LoadChat(write(t, "export.json", js))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(msgs) != 4 {
|
|
t.Fatalf("got %d messages", len(msgs))
|
|
}
|
|
if msgs[0].Author.Username != "Ana" || len(msgs[0].Embeds) != 1 {
|
|
t.Errorf("first message wrong: %+v", msgs[0])
|
|
}
|
|
if msgs[1].Type != discordgo.MessageTypeReply || msgs[1].ReferencedMessage != msgs[0] || len(msgs[1].Attachments) != 1 {
|
|
t.Errorf("reply wrong: %+v", msgs[1])
|
|
}
|
|
if msgs[2].Type == discordgo.MessageTypeDefault || !msgs[3].Author.Bot {
|
|
t.Error("system and bot messages should be marked so the bot's own filters drop them")
|
|
}
|
|
}
|
|
|
|
func TestLoadErrors(t *testing.T) {
|
|
if _, err := LoadChat(filepath.Join(t.TempDir(), "missing.txt")); err == nil {
|
|
t.Error("missing file should fail")
|
|
}
|
|
if _, err := LoadChat(write(t, "empty.txt", "\n\n")); err == nil {
|
|
t.Error("empty chat should fail")
|
|
}
|
|
if _, err := LoadChat(write(t, "bad.json", "{nope")); err == nil {
|
|
t.Error("broken json should fail")
|
|
}
|
|
}
|
|
|
|
func TestEvaluate(t *testing.T) {
|
|
facts := writeFacts(t, `[
|
|
{"name":"dinner","all":["jantar","20h"]},
|
|
{"name":"meeting","all":["reuni","terça"]},
|
|
{"name":"hosting","all":["120","80"]}]`)
|
|
chat := "Ana: jantar sexta às 20h\nRui: o hosting subiu de 80€ para 120€\nRita: reunião para terça às 11h\n"
|
|
out := "- Jantar de equipa na sexta às 20h, 9 pessoas.\n- A reunião passa para terça às 11h.\n mais uma linha\n- Hosting subiu para 120€ por mês, 300 pedidos"
|
|
sc := Evaluate(out, chat, facts)
|
|
|
|
if sc.FactsHit != 2 || sc.FactsTotal != 3 || len(sc.Missed) != 1 || sc.Missed[0] != "hosting" {
|
|
t.Errorf("facts: %+v", sc)
|
|
}
|
|
// "9" and "300" are not in the chat; "20", "11", "120" are.
|
|
if strings.Join(sc.Invented, ",") != "9,300" {
|
|
t.Errorf("invented = %v", sc.Invented)
|
|
}
|
|
if sc.Bullets != 3 || !sc.CutOff || sc.Copied || sc.EnglishIntro {
|
|
t.Errorf("flags: %+v", sc)
|
|
}
|
|
|
|
if !Evaluate("Here is an overview of the chat:\n- ok.", "", nil).EnglishIntro {
|
|
t.Error("English intro not detected")
|
|
}
|
|
if !Evaluate("- "+strings.Repeat("palavra ", 120)+".", "", nil).Copied {
|
|
t.Error("one giant bullet should count as copied")
|
|
}
|
|
if Evaluate("- Tudo bem.", "", nil).CutOff {
|
|
t.Error("a finished sentence is not cut off")
|
|
}
|
|
// A fact needs all its patterns in one bullet.
|
|
split := Evaluate("- Jantar sexta.\n- Às 20h há reunião.", "", facts[:1])
|
|
if split.FactsHit != 0 {
|
|
t.Error("pattern spread over two bullets must not pass")
|
|
}
|
|
}
|
|
|
|
func writeFacts(t *testing.T, js string) []Fact {
|
|
t.Helper()
|
|
f, err := LoadFacts(write(t, "facts.json", js))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return f
|
|
}
|
|
|
|
func TestLoadFactsErrors(t *testing.T) {
|
|
for name, js := range map[string]string{
|
|
"no patterns": `[{"name":"x","all":[]}]`,
|
|
"bad regex": `[{"name":"x","all":["("]}]`,
|
|
"bad json": `nope`,
|
|
} {
|
|
if _, err := LoadFacts(write(t, "f.json", js)); err == nil {
|
|
t.Errorf("%s should fail", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReportRender(t *testing.T) {
|
|
rep := &Report{
|
|
When: time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC),
|
|
Chat: ChatInfo{Source: "chat.txt", Messages: 100, Kept: 60, Chunks: 1, InputChars: 3000},
|
|
Threads: 8,
|
|
Facts: []string{"dinner", "meeting"},
|
|
Models: []string{"m1", "m2"},
|
|
Variants: []string{"current", "alt"},
|
|
Runs: []Run{
|
|
{Model: "m1", Variant: "current", N: 1, Seconds: 60, FirstText: 20, Output: "- <b>x</b> & y", Score: Score{FactsHit: 2, FactsTotal: 2, Hit: []string{"dinner", "meeting"}}},
|
|
{Model: "m1", Variant: "current", N: 2, Seconds: 70, Output: "- ok", Score: Score{FactsHit: 1, FactsTotal: 2, Hit: []string{"dinner"}, Missed: []string{"meeting"}, Invented: []string{"7"}}},
|
|
{Model: "m2", Variant: "current", N: 1, Error: "ollama unreachable"},
|
|
{Model: "m1", Variant: "alt", N: 1, Seconds: 40, Output: "- ok", Score: Score{FactsHit: 0, FactsTotal: 2, Missed: []string{"dinner", "meeting"}}},
|
|
},
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := rep.WriteHTML(&buf); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
html := buf.String()
|
|
for _, want := range []string{"2 models, 2 prompts", "1.5 of 2", "failed", "not run", "<b>x</b> & y", "Best on this chat", "<strong>m1</strong>", "missed: meeting", "numbers not in the chat: 7"} {
|
|
if !strings.Contains(html, want) {
|
|
t.Errorf("html missing %q", want)
|
|
}
|
|
}
|
|
if strings.Contains(html, "<b>x</b>") {
|
|
t.Error("output must be escaped")
|
|
}
|
|
if strings.Contains(html, "http://") || strings.Contains(html, "https://") || strings.Contains(html, "<script") {
|
|
t.Error("page must make no network requests")
|
|
}
|
|
|
|
p := filepath.Join(t.TempDir(), "r.json")
|
|
if err := rep.WriteJSON(p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
back, err := ReadReport(p)
|
|
if err != nil || len(back.Runs) != 4 || back.Runs[1].Invented[0] != "7" {
|
|
t.Errorf("round trip failed: %v %+v", err, back)
|
|
}
|
|
}
|
|
|
|
func TestSuggestFacts(t *testing.T) {
|
|
lines := []string{
|
|
"Ana: bom dia",
|
|
"Ana: o custo do hosting subiu de 80€ para 120€ por mês",
|
|
"Rui: vamos ao jantar de equipa na sexta às 20h no Cantinho",
|
|
"Rita: lol",
|
|
"Rui: olha isto [link: youtu.be] (preview: \"Tutorial\")",
|
|
"Tiago: preciso de um backup completo",
|
|
}
|
|
facts := SuggestFacts(lines, 10)
|
|
if len(facts) != 3 {
|
|
t.Fatalf("want 3 candidates (figures, day+time, link), got %d: %+v", len(facts), facts)
|
|
}
|
|
if !strings.Contains(facts[0].Note, "hosting") || facts[0].Note != lines[1] {
|
|
t.Errorf("first candidate should be the hosting line, got %+v", facts[0])
|
|
}
|
|
// Patterns must be usable as they are: they load, and they match the line they came from.
|
|
js, _ := json.Marshal(facts)
|
|
loaded, err := LoadFacts(write(t, "skeleton.json", string(js)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i, f := range loaded {
|
|
if sc := Evaluate("- "+facts[i].Note, "", []Fact{f}); sc.FactsHit != 1 {
|
|
t.Errorf("suggested patterns %v do not match their own line %q", facts[i].All, facts[i].Note)
|
|
}
|
|
if len(f.All) == 0 || len(f.All) > 3 {
|
|
t.Errorf("want 1 to 3 patterns, got %v", f.All)
|
|
}
|
|
for _, p := range f.All {
|
|
if strings.Contains(strings.ToLower(p), "ana") && !strings.Contains(strings.ToLower(f.Note[strings.Index(f.Note, ":"):]), "ana") {
|
|
t.Errorf("author name leaked into pattern %q", p)
|
|
}
|
|
}
|
|
}
|
|
if got := SuggestFacts(lines, 2); len(got) != 2 {
|
|
t.Errorf("max not applied: %d", len(got))
|
|
}
|
|
if got := SuggestFacts([]string{"Ana: oi", "Rui: sim"}, 5); len(got) != 0 {
|
|
t.Errorf("small talk should give no candidates: %+v", got)
|
|
}
|
|
}
|