diff --git a/internal/decision/decision_test.go b/internal/decision/decision_test.go new file mode 100644 index 0000000..2aff6a9 --- /dev/null +++ b/internal/decision/decision_test.go @@ -0,0 +1,98 @@ +package decision + +import ( + "context" + "sync" + "testing" + "time" +) + +func TestFinishNamesTheNeverAsked(t *testing.T) { + ctx, rec := With(context.Background(), "какая погода в риме?") + Expect(ctx, StageQuery, []string{"calendar", "weather", "search", "kiwix"}) + Note(ctx, Claim{Stage: StageQuery, Claimant: "calendar", Outcome: Declined}) + Note(ctx, Claim{Stage: StageQuery, Claimant: "weather", Outcome: Won}) + + rec.Finish(time.Now()) + + outcomes := map[string]string{} + for _, c := range rec.Claims { + outcomes[c.Claimant] = c.Outcome + } + if outcomes["weather"] != Won || rec.Winner != StageQuery+":weather" { + t.Errorf("winner = %q, weather = %q", rec.Winner, outcomes["weather"]) + } + if outcomes["calendar"] != Declined { + t.Errorf("calendar = %q, want a decline", outcomes["calendar"]) + } + // The two below the winner never looked, and saying so is the whole point. + for _, name := range []string{"search", "kiwix"} { + if outcomes[name] != NeverAsked { + t.Errorf("%s = %q, want %q", name, outcomes[name], NeverAsked) + } + } +} + +// A real 0.0 confidence must not read as "this claimant has no score". +func TestScoredKeepsAZeroScore(t *testing.T) { + c := Scored(StageRoute, "classifier", "chat", 0, LostOnScore, "") + if !c.HasScore || c.Score != 0 { + t.Errorf("claim = %+v", c) + } +} + +func TestNoteIfUnclaimedYieldsToARealWinner(t *testing.T) { + ctx, rec := With(context.Background(), "x") + Note(ctx, Claim{Stage: StageQuery, Claimant: "weather", Outcome: Won}) + rec.NoteIfUnclaimed(Claim{Stage: StageAction, Claimant: "action-handler"}) + if rec.Winner != StageQuery+":weather" { + t.Errorf("winner = %q, want the query source", rec.Winner) + } +} + +// A context with no record must cost nothing and crash nothing: that is what +// makes the claim sites safe to leave in every test and every fixture run. +func TestNoRecorderIsANoOp(t *testing.T) { + ctx := context.Background() + Note(ctx, Claim{Claimant: "x", Outcome: Won}) + Expect(ctx, StageQuery, []string{"y"}) + if From(ctx) != nil { + t.Error("bare context reported a record") + } + From(ctx).NoteIfUnclaimed(Claim{Claimant: "z"}) + if rec := From(ctx).Finish(time.Now()); rec != nil { + t.Error("finishing a nil record produced one") + } +} + +func TestRingIsBoundedAndNewestFirst(t *testing.T) { + r := NewRing() + for i := 0; i < ringSize+5; i++ { + r.Push(&Record{Utterance: string(rune('a' + i))}) + } + got := r.Recent(ringSize + 10) + if len(got) != ringSize { + t.Fatalf("kept %d records, want %d", len(got), ringSize) + } + if got[0].Utterance != string(rune('a'+ringSize+4)) { + t.Errorf("newest = %q", got[0].Utterance) + } +} + +// A query source may fan out to goroutines of its own, so two of them noting at +// once must not race. Run under -race, which is where this earns its keep. +func TestConcurrentNotes(t *testing.T) { + ctx, rec := With(context.Background(), "x") + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + Note(ctx, Claim{Stage: StageQuery, Claimant: "fanout", Outcome: Declined}) + }() + } + wg.Wait() + if len(rec.Claims) != 8 { + t.Errorf("recorded %d claims, want 8", len(rec.Claims)) + } +}