Skip to content

Commit 825be4c

Browse files
committed
session/replaytest: address review feedback
1 parent 8d660ab commit 825be4c

11 files changed

Lines changed: 555 additions & 32 deletions

File tree

session/replaytest/cases_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,147 @@ func TestConcurrentSnapshotInvariant(t *testing.T) {
217217
}
218218
}
219219

220+
func TestReplayInvariantValidationErrors(t *testing.T) {
221+
tests := []struct {
222+
name string
223+
check func(Snapshot) error
224+
snapshot Snapshot
225+
want string
226+
}{
227+
{
228+
name: "summary replay session count", check: validateSummaryReplayWindow,
229+
want: "session count",
230+
},
231+
{
232+
name: "summary replay events", check: validateSummaryReplayWindow,
233+
snapshot: Snapshot{Sessions: []SessionSnapshot{{ID: standardSessionID}}},
234+
want: "retained events",
235+
},
236+
{
237+
name: "event count", check: validateEvents(eventExpectation{content: "expected"}),
238+
snapshot: Snapshot{Sessions: []SessionSnapshot{{ID: standardSessionID}}},
239+
want: "event count",
240+
},
241+
{
242+
name: "tool event count", check: validateToolCall,
243+
snapshot: Snapshot{Sessions: []SessionSnapshot{{ID: standardSessionID}}},
244+
want: "event count",
245+
},
246+
{
247+
name: "state value", check: validateStateUpdate,
248+
snapshot: Snapshot{Sessions: []SessionSnapshot{{ID: standardSessionID}}},
249+
want: "state",
250+
},
251+
{
252+
name: "summary update session count", check: validateSummaryUpdate,
253+
want: "session count",
254+
},
255+
{
256+
name: "tracks missing", check: validateTracks,
257+
snapshot: Snapshot{Sessions: []SessionSnapshot{{ID: standardSessionID}}},
258+
want: "tracks",
259+
},
260+
{
261+
name: "concurrent unknown session", check: validateConcurrentSnapshot,
262+
snapshot: Snapshot{Sessions: []SessionSnapshot{{ID: "unknown"}, {ID: "session-2"}}},
263+
want: "unexpected session",
264+
},
265+
{
266+
name: "recovery event contents", check: validateRecoverySnapshot,
267+
snapshot: Snapshot{
268+
Sessions: []SessionSnapshot{{
269+
Events: []EventSnapshot{{Content: "wrong"}, {Content: "retried"}},
270+
State: map[string]StateValueSnapshot{"status": JSONStateValue("recovered")},
271+
}},
272+
Memories: []MemorySnapshot{{}},
273+
},
274+
want: "event contents",
275+
},
276+
{
277+
name: "wrong only session id",
278+
check: func(snapshot Snapshot) error {
279+
_, err := onlySession(snapshot, standardSessionID)
280+
return err
281+
},
282+
snapshot: Snapshot{Sessions: []SessionSnapshot{{ID: "wrong"}}},
283+
want: "session id",
284+
},
285+
{
286+
name: "missing named session",
287+
check: func(snapshot Snapshot) error {
288+
_, err := findSessionSnapshot(snapshot, standardSessionID)
289+
return err
290+
},
291+
want: "not found",
292+
},
293+
}
294+
for _, test := range tests {
295+
t.Run(test.name, func(t *testing.T) {
296+
err := test.check(test.snapshot)
297+
if err == nil || !strings.Contains(err.Error(), test.want) {
298+
t.Fatalf("invariant error = %v, want %q", err, test.want)
299+
}
300+
})
301+
}
302+
}
303+
304+
func TestValidateSummaryRejectsInvalidPersistedFields(t *testing.T) {
305+
valid := SummarySnapshot{
306+
SessionID: standardSessionID,
307+
FilterKey: "branch/main",
308+
Text: "summary",
309+
Version: 1,
310+
UpdatedAt: standardTime,
311+
Boundary: map[string]any{
312+
"filter_key": "branch/main", "last_event_id": "event-1", "cutoff_at": standardTime,
313+
},
314+
}
315+
tests := []struct {
316+
name string
317+
mutate func(*SummarySnapshot)
318+
want string
319+
}{
320+
{name: "main fields", mutate: func(summary *SummarySnapshot) {
321+
summary.Version = 0
322+
}, want: "summary ="},
323+
{name: "boundary filter", mutate: func(summary *SummarySnapshot) {
324+
summary.Boundary["filter_key"] = "wrong"
325+
}, want: "filter_key"},
326+
{name: "boundary event", mutate: func(summary *SummarySnapshot) {
327+
delete(summary.Boundary, "last_event_id")
328+
}, want: "last_event_id"},
329+
{name: "boundary cutoff", mutate: func(summary *SummarySnapshot) {
330+
delete(summary.Boundary, "cutoff_at")
331+
}, want: "cutoff_at"},
332+
}
333+
for _, test := range tests {
334+
t.Run(test.name, func(t *testing.T) {
335+
summary := valid
336+
summary.Boundary = make(map[string]any, len(valid.Boundary))
337+
for key, value := range valid.Boundary {
338+
summary.Boundary[key] = value
339+
}
340+
test.mutate(&summary)
341+
err := validateSummary(summary, standardSessionID, "summary")
342+
if err == nil || !strings.Contains(err.Error(), test.want) {
343+
t.Fatalf("validateSummary() error = %v, want %q", err, test.want)
344+
}
345+
})
346+
}
347+
}
348+
349+
func TestSameStringSetRejectsInvalidValues(t *testing.T) {
350+
if sameStringSet([]any{"one"}, "one", "two") {
351+
t.Fatal("sameStringSet() accepted mismatched lengths")
352+
}
353+
if sameStringSet([]any{1}, "one") {
354+
t.Fatal("sameStringSet() accepted a non-string value")
355+
}
356+
if sameStringSet([]any{"two"}, "one") {
357+
t.Fatal("sameStringSet() accepted an unexpected string")
358+
}
359+
}
360+
220361
func replayCaseByName(t *testing.T, cases []ReplayCase, name string) ReplayCase {
221362
t.Helper()
222363
for _, replayCase := range cases {

session/replaytest/compare.go

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
package replaytest
1111

1212
import (
13+
"bytes"
1314
"encoding/json"
1415
"fmt"
1516
"math"
@@ -88,7 +89,9 @@ func snapshotValue(snapshot Snapshot) (any, error) {
8889
return nil, err
8990
}
9091
var value any
91-
if err := json.Unmarshal(encoded, &value); err != nil {
92+
decoder := json.NewDecoder(bytes.NewReader(encoded))
93+
decoder.UseNumber()
94+
if err := decoder.Decode(&value); err != nil {
9295
return nil, err
9396
}
9497
return value, nil
@@ -320,8 +323,8 @@ func scoreValuesEqual(path string, baseline, actual any, tolerance float64) bool
320323
if itemPath == path || !isMemoryItemPath(itemPath) {
321324
return false
322325
}
323-
baselineScore, baselineOK := baseline.(float64)
324-
actualScore, actualOK := actual.(float64)
326+
baselineScore, baselineOK := numericFloat64(baseline)
327+
actualScore, actualOK := numericFloat64(actual)
325328
return baselineOK && actualOK && math.Abs(baselineScore-actualScore) <= tolerance
326329
}
327330

@@ -330,11 +333,23 @@ func durationValuesEqual(path string, baseline, actual any, tolerance time.Durat
330333
!strings.Contains(path, "].events[") {
331334
return false
332335
}
333-
baselineDuration, baselineOK := baseline.(float64)
334-
actualDuration, actualOK := actual.(float64)
336+
baselineDuration, baselineOK := numericFloat64(baseline)
337+
actualDuration, actualOK := numericFloat64(actual)
335338
return baselineOK && actualOK && math.Abs(baselineDuration-actualDuration) <= float64(tolerance)
336339
}
337340

341+
func numericFloat64(value any) (float64, bool) {
342+
switch typed := value.(type) {
343+
case json.Number:
344+
parsed, err := typed.Float64()
345+
return parsed, err == nil
346+
case float64:
347+
return typed, true
348+
default:
349+
return 0, false
350+
}
351+
}
352+
338353
func isMemoryItemPath(path string) bool {
339354
if isRootCollectionItem(path, "memories") {
340355
return true

session/replaytest/compare_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,26 @@ func TestCompareSnapshotsReportsMissingCollectionItems(t *testing.T) {
108108
}
109109
}
110110

111+
func TestCompareSnapshotsReportsAdditionalCollectionItems(t *testing.T) {
112+
differences, err := CompareSnapshots(CompareInput{
113+
Case: "additional-memory",
114+
Backend: "sqlite",
115+
Baseline: Snapshot{},
116+
Actual: Snapshot{Memories: []MemorySnapshot{{
117+
ID: "memory-1", AppName: "app", UserID: "user", Content: "content",
118+
}},
119+
},
120+
})
121+
if err != nil {
122+
t.Fatalf("CompareSnapshots() error = %v", err)
123+
}
124+
differenceAt(t, differences, "$.memories.length")
125+
additional := differenceAt(t, differences, "$.memories[0]")
126+
if additional.Locator.MemoryID != "memory-1" || additional.Baseline != missingValue {
127+
t.Fatalf("additional memory difference = %#v", additional)
128+
}
129+
}
130+
111131
func TestCompareSnapshotsRejectsInvalidAllowedDiffRules(t *testing.T) {
112132
_, err := CompareSnapshots(CompareInput{
113133
Case: "case", Backend: "sqlite",
@@ -158,6 +178,26 @@ func TestCompareSnapshotsReturnsEncodingErrors(t *testing.T) {
158178
}
159179
}
160180

181+
func TestCompareSnapshotsPreservesLargeIntegerPrecision(t *testing.T) {
182+
baseline := Snapshot{Sessions: []SessionSnapshot{{State: map[string]StateValueSnapshot{
183+
"large": JSONStateValue(int64(9007199254740992)),
184+
}}}}
185+
actual := Snapshot{Sessions: []SessionSnapshot{{State: map[string]StateValueSnapshot{
186+
"large": JSONStateValue(int64(9007199254740993)),
187+
}}}}
188+
189+
differences, err := CompareSnapshots(CompareInput{
190+
Case: "large-integer", Backend: "sqlite", Baseline: baseline, Actual: actual,
191+
})
192+
if err != nil {
193+
t.Fatalf("CompareSnapshots() error = %v", err)
194+
}
195+
if len(differences) == 0 {
196+
t.Fatal("distinct integers above 2^53 compared equal")
197+
}
198+
differenceAt(t, differences, "$.sessions[0].state.large.value")
199+
}
200+
161201
func TestCompareSnapshotsUsesAbsoluteScoreTolerance(t *testing.T) {
162202
baseline := Snapshot{Memories: []MemorySnapshot{{Score: 0.5000009}}}
163203
actual := Snapshot{Memories: []MemorySnapshot{{Score: 0.5000011}}}
@@ -234,6 +274,37 @@ func TestCompareSnapshotsDoesNotApplyScoreToleranceToState(t *testing.T) {
234274
}
235275
}
236276

277+
func TestRuleMatchesRequiresExactScope(t *testing.T) {
278+
rule := AllowedDiffRule{Case: "case", Backend: "sqlite", Path: "$.sessions"}
279+
if ruleMatches(rule, "other", "sqlite", "$.sessions") ||
280+
ruleMatches(rule, "case", "other", "$.sessions") ||
281+
ruleMatches(rule, "case", "sqlite", "$.sessions[0]") {
282+
t.Fatal("exact rule matched outside its case, backend, or path")
283+
}
284+
if !ruleMatches(rule, "case", "sqlite", "$.sessions") {
285+
t.Fatal("exact rule did not match its configured path")
286+
}
287+
rule.PathPrefix = true
288+
if !ruleMatches(rule, "case", "sqlite", "$.sessions[0]") ||
289+
!ruleMatches(rule, "case", "sqlite", "$.sessions.length") ||
290+
ruleMatches(rule, "case", "sqlite", "$.session") {
291+
t.Fatal("path-prefix rule matched an incorrect set of paths")
292+
}
293+
}
294+
295+
func TestComparisonNumericAndMemoryHelpers(t *testing.T) {
296+
if got, ok := numericFloat64(float64(1.5)); !ok || got != 1.5 {
297+
t.Fatalf("numericFloat64(float64) = %v, %v", got, ok)
298+
}
299+
if _, ok := numericFloat64("1.5"); ok {
300+
t.Fatal("numericFloat64(string) unexpectedly succeeded")
301+
}
302+
appName, userID := memoryScope(map[string]any{"app_name": "app", "user_id": "user"})
303+
if appName != "app" || userID != "user" {
304+
t.Fatalf("memoryScope() = %q, %q", appName, userID)
305+
}
306+
}
307+
237308
func TestCompareSnapshotsLocatesMemoryScope(t *testing.T) {
238309
baseline := comparisonFixture()
239310
baseline.Memories[0].Scope = MemoryScope{AppName: "app", UserID: "user"}

session/replaytest/normalize_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,64 @@ func TestNormalizeJSONLikeHandlesRawRepresentations(t *testing.T) {
227227
}
228228
}
229229

230+
func TestNormalizeJSONLikeHandlesFallbackRepresentations(t *testing.T) {
231+
options := DefaultNormalizeOptions()
232+
if got := normalizeJSONLike(nil, options); got != nil {
233+
t.Fatalf("normalizeJSONLike(nil) = %#v", got)
234+
}
235+
for _, value := range []any{
236+
json.RawMessage(`{"invalid"`),
237+
[]byte(`{"invalid"`),
238+
} {
239+
if _, ok := normalizeJSONLike(value, options).(string); !ok {
240+
t.Fatalf("invalid JSON representation was not preserved as text: %#v", value)
241+
}
242+
}
243+
if _, ok := normalizeJSONLike(make(chan int), options).(string); !ok {
244+
t.Fatal("unencodable value was not converted to diagnostic text")
245+
}
246+
247+
scalars := []struct {
248+
value any
249+
want any
250+
}{
251+
{value: uint64(42), want: uint64(42)},
252+
{value: float32(1.5), want: float64(1.5)},
253+
}
254+
for _, scalar := range scalars {
255+
if got := normalizeJSONLike(scalar.value, options); !reflect.DeepEqual(got, scalar.want) {
256+
t.Fatalf("normalizeJSONLike(%T) = %#v, want %#v", scalar.value, got, scalar.want)
257+
}
258+
}
259+
}
260+
261+
func TestNormalizeJSONNumbersPreservesInvalidNumbers(t *testing.T) {
262+
tests := []json.Number{"1e+", "not-a-number"}
263+
for _, value := range tests {
264+
if got := normalizeJSONNumbers(value); got != value.String() {
265+
t.Fatalf("normalizeJSONNumbers(%q) = %#v", value, got)
266+
}
267+
}
268+
}
269+
270+
func TestCloneJSONLikeCopiesReferenceValues(t *testing.T) {
271+
raw := json.RawMessage(`{"value":1}`)
272+
bytesValue := []byte("bytes")
273+
input := []any{raw, bytesValue, []any{map[string]any{"key": "value"}}}
274+
cloned := cloneJSONLike(input).([]any)
275+
276+
cloned[0].(json.RawMessage)[0]++
277+
cloned[1].([]byte)[0]++
278+
cloned[2].([]any)[0].(map[string]any)["key"] = "changed"
279+
if reflect.DeepEqual(input, cloned) {
280+
t.Fatal("cloneJSONLike() retained aliases to reference values")
281+
}
282+
if string(raw) != `{"value":1}` || string(bytesValue) != "bytes" ||
283+
input[2].([]any)[0].(map[string]any)["key"] != "value" {
284+
t.Fatalf("cloneJSONLike() mutated input: %#v", input)
285+
}
286+
}
287+
230288
func TestNormalizeSnapshotFlagsInvalidSessionMetadataOrder(t *testing.T) {
231289
created := time.Unix(20, 0)
232290
snapshot := Snapshot{Sessions: []SessionSnapshot{{

0 commit comments

Comments
 (0)