返回 DeepSeek-Reasonix
bounded_store_test.go
根目录 / internal / autoresearch / bounded_store_test.go
1 package autoresearch
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11 )
12
13 // TestDirectionFingerprintDistinguishesLongPrefixes: two directions sharing a
14 // >56-char slug prefix used to collapse to one fingerprint, wrongly counting
15 // the second as a repeat and inflating StaleCount toward a forced pivot.
16 func TestDirectionFingerprintDistinguishesLongPrefixes(t *testing.T) {
17 root := t.TempDir()
18 store := NewStore(root)
19 task, err := store.CreateTask("Long prefix fingerprints", CreateOptions{})
20 if err != nil {
21 t.Fatalf("CreateTask: %v", err)
22 }
23 prefix := "Benchmark the desktop frontend markdown rendering pipeline for "
24
25 progress, err := store.RecordDirection(task.ID, Direction{
26 Summary: prefix + "large tables",
27 AcceptedEvidenceIDs: []string{"f1"},
28 Now: time.Date(2026, 7, 7, 10, 0, 0, 0, time.UTC),
29 })
30 if err != nil {
31 t.Fatalf("RecordDirection first: %v", err)
32 }
33 if progress.StaleCount != 0 {
34 t.Fatalf("first direction stale = %d, want 0", progress.StaleCount)
35 }
36
37 progress, err = store.RecordDirection(task.ID, Direction{
38 Summary: prefix + "code blocks",
39 AcceptedEvidenceIDs: []string{"f2"},
40 Now: time.Date(2026, 7, 7, 10, 1, 0, 0, time.UTC),
41 })
42 if err != nil {
43 t.Fatalf("RecordDirection second: %v", err)
44 }
45 // A genuinely different direction with accepted evidence must not be
46 // counted as a repeat.
47 if progress.StaleCount != 0 {
48 t.Fatalf("distinct long-prefix direction treated as repeat: stale = %d, want 0", progress.StaleCount)
49 }
50
51 // An exact repeat must still be detected.
52 progress, err = store.RecordDirection(task.ID, Direction{
53 Summary: prefix + "code blocks",
54 AcceptedEvidenceIDs: []string{"f3"},
55 Now: time.Date(2026, 7, 7, 10, 2, 0, 0, time.UTC),
56 })
57 if err != nil {
58 t.Fatalf("RecordDirection third: %v", err)
59 }
60 if progress.StaleCount != 1 {
61 t.Fatalf("exact repeat not detected: stale = %d, want 1", progress.StaleCount)
62 }
63 }
64
65 // TestDirectionFingerprintDistinguishesCJK: directions differing only in CJK
66 // text slugify to the same string ("task") and used to collide.
67 func TestDirectionFingerprintDistinguishesCJK(t *testing.T) {
68 a := directionFingerprint("评测甲方案的渲染性能")
69 b := directionFingerprint("评测乙方案的渲染性能")
70 if a == b {
71 t.Fatalf("CJK-only-diff directions share a fingerprint: %q", a)
72 }
73 if directionFingerprint("评测甲方案的渲染性能") != a {
74 t.Fatal("fingerprint is not deterministic")
75 }
76 }
77
78 // TestDirectionFingerprintBackwardCompatShortASCII: short ASCII summaries keep
79 // the bare slug so fingerprints recorded by older versions still match.
80 func TestDirectionFingerprintBackwardCompatShortASCII(t *testing.T) {
81 got := directionFingerprint("Profile markdown rendering")
82 want := slugify("Profile markdown rendering")
83 if got != want {
84 t.Fatalf("short ASCII fingerprint = %q, want legacy slug %q", got, want)
85 }
86 }
87
88 // TestRecordDirectionMigratesLegacyFingerprint: a directions_tried.json entry
89 // written by an older version (bare truncated slug) must still match its own
90 // summary on repeat, not be double-counted as a new direction.
91 func TestRecordDirectionMigratesLegacyFingerprint(t *testing.T) {
92 root := t.TempDir()
93 store := NewStore(root)
94 task, err := store.CreateTask("Legacy fingerprint migration", CreateOptions{})
95 if err != nil {
96 t.Fatalf("CreateTask: %v", err)
97 }
98 summary := "Benchmark the desktop frontend markdown rendering pipeline for large tables"
99
100 // Simulate a legacy entry: fingerprint from the old bare slugify.
101 dirPath := filepath.Join(task.Root, "state", "directions_tried.json")
102 legacy := []DirectionTried{{
103 Fingerprint: slugify(summary),
104 Summary: summary,
105 FirstSeenIteration: 1,
106 LastSeenIteration: 1,
107 Count: 1,
108 }}
109 data, err := json.Marshal(legacy)
110 if err != nil {
111 t.Fatal(err)
112 }
113 if err := os.WriteFile(dirPath, data, 0o644); err != nil {
114 t.Fatal(err)
115 }
116
117 progress, err := store.RecordDirection(task.ID, Direction{
118 Summary: summary,
119 Now: time.Date(2026, 7, 7, 11, 0, 0, 0, time.UTC),
120 })
121 if err != nil {
122 t.Fatalf("RecordDirection: %v", err)
123 }
124 // Repeat of the legacy direction: stale should increment (repeat + no
125 // accepted evidence), and the entry should be migrated, not duplicated.
126 if progress.StaleCount != 1 {
127 t.Fatalf("legacy repeat not detected: stale = %d, want 1", progress.StaleCount)
128 }
129 raw, err := os.ReadFile(dirPath)
130 if err != nil {
131 t.Fatal(err)
132 }
133 var directions []DirectionTried
134 if err := json.Unmarshal(raw, &directions); err != nil {
135 t.Fatal(err)
136 }
137 if len(directions) != 1 {
138 t.Fatalf("directions = %+v, want single migrated entry", directions)
139 }
140 if directions[0].Count != 2 {
141 t.Fatalf("migrated count = %d, want 2", directions[0].Count)
142 }
143 if directions[0].Fingerprint != directionFingerprint(summary) {
144 t.Fatalf("entry not migrated to new fingerprint: %q", directions[0].Fingerprint)
145 }
146 }
147
148 // TestTailJSONLLinesMatchesFullScan: the tail reader must return exactly the
149 // same entries as a full scan for every limit, including limits larger than
150 // the file and files bigger than one read chunk.
151 func TestTailJSONLLinesMatchesFullScan(t *testing.T) {
152 root := t.TempDir()
153 store := NewStore(root)
154 task, err := store.CreateTask("Tail read equivalence", CreateOptions{})
155 if err != nil {
156 t.Fatalf("CreateTask: %v", err)
157 }
158 // Write enough heartbeats that the log exceeds one 64KiB chunk.
159 long := strings.Repeat("x", 700)
160 for i := 0; i < 150; i++ {
161 if err := store.AppendHeartbeat(task.ID, Heartbeat{
162 Status: HeartbeatTurnDone,
163 Iteration: i + 1,
164 Message: fmt.Sprintf("turn-%03d %s", i, long),
165 CreatedAt: time.Date(2026, 7, 7, 12, 0, i%60, 0, time.UTC),
166 }); err != nil {
167 t.Fatalf("AppendHeartbeat %d: %v", i, err)
168 }
169 }
170 all, err := store.Heartbeats(task.ID, 0)
171 if err != nil {
172 t.Fatalf("Heartbeats(0): %v", err)
173 }
174 if len(all) != 150 {
175 t.Fatalf("full scan = %d heartbeats, want 150", len(all))
176 }
177 for _, limit := range []int{1, 3, 149, 150, 500} {
178 got, err := store.Heartbeats(task.ID, limit)
179 if err != nil {
180 t.Fatalf("Heartbeats(%d): %v", limit, err)
181 }
182 want := all
183 if limit < len(all) {
184 want = all[len(all)-limit:]
185 }
186 if len(got) != len(want) {
187 t.Fatalf("Heartbeats(%d) = %d entries, want %d", limit, len(got), len(want))
188 }
189 for i := range got {
190 if got[i].Iteration != want[i].Iteration {
191 t.Fatalf("Heartbeats(%d)[%d].Iteration = %d, want %d", limit, i, got[i].Iteration, want[i].Iteration)
192 }
193 }
194 }
195 // LastHeartbeat must be the newest entry.
196 last, ok, err := store.LastHeartbeat(task.ID)
197 if err != nil || !ok {
198 t.Fatalf("LastHeartbeat: ok=%v err=%v", ok, err)
199 }
200 if last.Iteration != 150 {
201 t.Fatalf("LastHeartbeat iteration = %d, want 150", last.Iteration)
202 }
203 }
204
205 // TestFindingsBoundedTailMatchesFullScan mirrors the heartbeat check for the
206 // findings log, which desktop views read with a limit.
207 func TestFindingsBoundedTailMatchesFullScan(t *testing.T) {
208 root := t.TempDir()
209 store := NewStore(root)
210 task, err := store.CreateTask("Findings tail equivalence", CreateOptions{})
211 if err != nil {
212 t.Fatalf("CreateTask: %v", err)
213 }
214 for i := 0; i < 25; i++ {
215 if err := store.AppendFinding(task.ID, Finding{
216 ID: fmt.Sprintf("f%03d", i),
217 Kind: FindingKindManual,
218 Summary: fmt.Sprintf("finding %03d", i),
219 Accepted: i%2 == 0,
220 CreatedAt: time.Date(2026, 7, 7, 13, 0, i%60, 0, time.UTC),
221 }); err != nil {
222 t.Fatalf("AppendFinding %d: %v", i, err)
223 }
224 }
225 all, err := store.Findings(task.ID, 0)
226 if err != nil {
227 t.Fatalf("Findings(0): %v", err)
228 }
229 if len(all) != 25 || all[0].ID != "f024" {
230 t.Fatalf("full scan = %d findings first %q, want 25 / f024 (newest first)", len(all), all[0].ID)
231 }
232 got, err := store.Findings(task.ID, 5)
233 if err != nil {
234 t.Fatalf("Findings(5): %v", err)
235 }
236 if len(got) != 5 || got[0].ID != "f024" || got[4].ID != "f020" {
237 t.Fatalf("Findings(5) = %+v, want newest five f024..f020", got)
238 }
239 }
240
240 lines GO