| 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 |