240 lines
7.9 KiB
Go
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)
|
|
}
|
|
}
|