Files
2026-07-13 13:00:08 +08:00

240 lines
7.9 KiB
Go

package autoresearch
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// TestDirectionFingerprintDistinguishesLongPrefixes: two directions sharing a
// >56-char slug prefix used to collapse to one fingerprint, wrongly counting
// the second as a repeat and inflating StaleCount toward a forced pivot.
func TestDirectionFingerprintDistinguishesLongPrefixes(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
task, err := store.CreateTask("Long prefix fingerprints", CreateOptions{})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
prefix := "Benchmark the desktop frontend markdown rendering pipeline for "
progress, err := store.RecordDirection(task.ID, Direction{
Summary: prefix + "large tables",
AcceptedEvidenceIDs: []string{"f1"},
Now: time.Date(2026, 7, 7, 10, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("RecordDirection first: %v", err)
}
if progress.StaleCount != 0 {
t.Fatalf("first direction stale = %d, want 0", progress.StaleCount)
}
progress, err = store.RecordDirection(task.ID, Direction{
Summary: prefix + "code blocks",
AcceptedEvidenceIDs: []string{"f2"},
Now: time.Date(2026, 7, 7, 10, 1, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("RecordDirection second: %v", err)
}
// A genuinely different direction with accepted evidence must not be
// counted as a repeat.
if progress.StaleCount != 0 {
t.Fatalf("distinct long-prefix direction treated as repeat: stale = %d, want 0", progress.StaleCount)
}
// An exact repeat must still be detected.
progress, err = store.RecordDirection(task.ID, Direction{
Summary: prefix + "code blocks",
AcceptedEvidenceIDs: []string{"f3"},
Now: time.Date(2026, 7, 7, 10, 2, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("RecordDirection third: %v", err)
}
if progress.StaleCount != 1 {
t.Fatalf("exact repeat not detected: stale = %d, want 1", progress.StaleCount)
}
}
// TestDirectionFingerprintDistinguishesCJK: directions differing only in CJK
// text slugify to the same string ("task") and used to collide.
func TestDirectionFingerprintDistinguishesCJK(t *testing.T) {
a := directionFingerprint("评测甲方案的渲染性能")
b := directionFingerprint("评测乙方案的渲染性能")
if a == b {
t.Fatalf("CJK-only-diff directions share a fingerprint: %q", a)
}
if directionFingerprint("评测甲方案的渲染性能") != a {
t.Fatal("fingerprint is not deterministic")
}
}
// TestDirectionFingerprintBackwardCompatShortASCII: short ASCII summaries keep
// the bare slug so fingerprints recorded by older versions still match.
func TestDirectionFingerprintBackwardCompatShortASCII(t *testing.T) {
got := directionFingerprint("Profile markdown rendering")
want := slugify("Profile markdown rendering")
if got != want {
t.Fatalf("short ASCII fingerprint = %q, want legacy slug %q", got, want)
}
}
// TestRecordDirectionMigratesLegacyFingerprint: a directions_tried.json entry
// written by an older version (bare truncated slug) must still match its own
// summary on repeat, not be double-counted as a new direction.
func TestRecordDirectionMigratesLegacyFingerprint(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
task, err := store.CreateTask("Legacy fingerprint migration", CreateOptions{})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
summary := "Benchmark the desktop frontend markdown rendering pipeline for large tables"
// Simulate a legacy entry: fingerprint from the old bare slugify.
dirPath := filepath.Join(task.Root, "state", "directions_tried.json")
legacy := []DirectionTried{{
Fingerprint: slugify(summary),
Summary: summary,
FirstSeenIteration: 1,
LastSeenIteration: 1,
Count: 1,
}}
data, err := json.Marshal(legacy)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dirPath, data, 0o644); err != nil {
t.Fatal(err)
}
progress, err := store.RecordDirection(task.ID, Direction{
Summary: summary,
Now: time.Date(2026, 7, 7, 11, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("RecordDirection: %v", err)
}
// Repeat of the legacy direction: stale should increment (repeat + no
// accepted evidence), and the entry should be migrated, not duplicated.
if progress.StaleCount != 1 {
t.Fatalf("legacy repeat not detected: stale = %d, want 1", progress.StaleCount)
}
raw, err := os.ReadFile(dirPath)
if err != nil {
t.Fatal(err)
}
var directions []DirectionTried
if err := json.Unmarshal(raw, &directions); err != nil {
t.Fatal(err)
}
if len(directions) != 1 {
t.Fatalf("directions = %+v, want single migrated entry", directions)
}
if directions[0].Count != 2 {
t.Fatalf("migrated count = %d, want 2", directions[0].Count)
}
if directions[0].Fingerprint != directionFingerprint(summary) {
t.Fatalf("entry not migrated to new fingerprint: %q", directions[0].Fingerprint)
}
}
// TestTailJSONLLinesMatchesFullScan: the tail reader must return exactly the
// same entries as a full scan for every limit, including limits larger than
// the file and files bigger than one read chunk.
func TestTailJSONLLinesMatchesFullScan(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
task, err := store.CreateTask("Tail read equivalence", CreateOptions{})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
// Write enough heartbeats that the log exceeds one 64KiB chunk.
long := strings.Repeat("x", 700)
for i := 0; i < 150; i++ {
if err := store.AppendHeartbeat(task.ID, Heartbeat{
Status: HeartbeatTurnDone,
Iteration: i + 1,
Message: fmt.Sprintf("turn-%03d %s", i, long),
CreatedAt: time.Date(2026, 7, 7, 12, 0, i%60, 0, time.UTC),
}); err != nil {
t.Fatalf("AppendHeartbeat %d: %v", i, err)
}
}
all, err := store.Heartbeats(task.ID, 0)
if err != nil {
t.Fatalf("Heartbeats(0): %v", err)
}
if len(all) != 150 {
t.Fatalf("full scan = %d heartbeats, want 150", len(all))
}
for _, limit := range []int{1, 3, 149, 150, 500} {
got, err := store.Heartbeats(task.ID, limit)
if err != nil {
t.Fatalf("Heartbeats(%d): %v", limit, err)
}
want := all
if limit < len(all) {
want = all[len(all)-limit:]
}
if len(got) != len(want) {
t.Fatalf("Heartbeats(%d) = %d entries, want %d", limit, len(got), len(want))
}
for i := range got {
if got[i].Iteration != want[i].Iteration {
t.Fatalf("Heartbeats(%d)[%d].Iteration = %d, want %d", limit, i, got[i].Iteration, want[i].Iteration)
}
}
}
// LastHeartbeat must be the newest entry.
last, ok, err := store.LastHeartbeat(task.ID)
if err != nil || !ok {
t.Fatalf("LastHeartbeat: ok=%v err=%v", ok, err)
}
if last.Iteration != 150 {
t.Fatalf("LastHeartbeat iteration = %d, want 150", last.Iteration)
}
}
// TestFindingsBoundedTailMatchesFullScan mirrors the heartbeat check for the
// findings log, which desktop views read with a limit.
func TestFindingsBoundedTailMatchesFullScan(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
task, err := store.CreateTask("Findings tail equivalence", CreateOptions{})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
for i := 0; i < 25; i++ {
if err := store.AppendFinding(task.ID, Finding{
ID: fmt.Sprintf("f%03d", i),
Kind: FindingKindManual,
Summary: fmt.Sprintf("finding %03d", i),
Accepted: i%2 == 0,
CreatedAt: time.Date(2026, 7, 7, 13, 0, i%60, 0, time.UTC),
}); err != nil {
t.Fatalf("AppendFinding %d: %v", i, err)
}
}
all, err := store.Findings(task.ID, 0)
if err != nil {
t.Fatalf("Findings(0): %v", err)
}
if len(all) != 25 || all[0].ID != "f024" {
t.Fatalf("full scan = %d findings first %q, want 25 / f024 (newest first)", len(all), all[0].ID)
}
got, err := store.Findings(task.ID, 5)
if err != nil {
t.Fatalf("Findings(5): %v", err)
}
if len(got) != 5 || got[0].ID != "f024" || got[4].ID != "f020" {
t.Fatalf("Findings(5) = %+v, want newest five f024..f020", got)
}
}