package main import ( "context" "testing" "github.com/kami/maven/internal/decision" "github.com/kami/maven/internal/store" ) // A real turn leaves a persisted trace, not only a ring entry. This is the whole // of V-629: without one there is nothing to fit the routing heads from. func TestTurnPersistsTrace(t *testing.T) { ring := decision.NewRing() h := traceHandler(t, ring) h.traces = traceSink(h.dataStore) h.encoderID = "hash-1024" if reply := h.handleText(context.Background(), "web", "сколько сейчас времени"); reply == "" { t.Fatal("turn produced no reply") } got, err := h.dataStore.RecentRoutingTraces(context.Background(), 5) if err != nil { t.Fatal(err) } if len(got) != 1 { t.Fatalf("persisted %d traces, want 1", len(got)) } tr := got[0] if tr.Utterance != "сколько сейчас времени" { t.Errorf("utterance %q", tr.Utterance) } if tr.Source != string(sourceText) { t.Errorf("source %q, want %q", tr.Source, sourceText) } // A stage-0 clock rule answers this one, so the turn teaches the classifier // nothing and the trace has to say so. if !tr.ClaimedBeforeHead { t.Errorf("claimed_before_head false on winner %q", tr.Winner) } if tr.EncoderID != "hash-1024" { t.Errorf("encoder_id %q", tr.EncoderID) } if len(tr.Claims) < 3 { t.Errorf("claims %s: the losers and the never-asked are the point", tr.Claims) } } // No store, no trace, and no panic. A typed nil pointer in the interface would // pass the nil check and die on the first turn. func TestNoStoreNoTrace(t *testing.T) { ring := decision.NewRing() h := traceHandler(t, ring) h.traces = traceSink(nil) if reply := h.handleText(context.Background(), "web", "сколько сейчас времени"); reply == "" { t.Fatal("turn produced no reply") } if len(ring.Recent(5)) != 1 { t.Error("the ring is still the first sink and must still hold the turn") } } // An empty utterance writes nothing. A blank row carries no label and no // diagnosis, and it is his words the retention bound exists for. func TestEmptyUtteranceIsNotPersisted(t *testing.T) { h := traceHandler(t, decision.NewRing()) h.traces = traceSink(h.dataStore) h.persistDecision(context.Background(), &decision.Record{Utterance: " "}, sourceText) got, err := h.dataStore.RecentRoutingTraces(context.Background(), 5) if err != nil { t.Fatal(err) } if len(got) != 0 { t.Fatalf("persisted %d traces for a blank utterance", len(got)) } } var _ traceWriter = (*store.Store)(nil) // The trace id rides back to the caller, which is what makes a correction one // gesture: /chat already has the id, so saying "that was wrong" costs a button // and no lookup (V-630). func TestTurnHandsBackItsTraceID(t *testing.T) { h := traceHandler(t, decision.NewRing()) h.traces = traceSink(h.dataStore) ctx, sink := withTraceIDSink(context.Background()) if reply := h.handleText(ctx, "web", "сколько сейчас времени"); reply == "" { t.Fatal("turn produced no reply") } id := sink.ID() if id == 0 { t.Fatal("no trace id came back, so /chat can offer no correction") } // And it names the turn that just ran, so the correction lands on the right // utterance. if err := h.dataStore.CorrectTurn(context.Background(), id, "query", h.now()); err != nil { t.Fatal(err) } labels, err := h.dataStore.RoutingLabels(context.Background(), 5) if err != nil { t.Fatal(err) } if len(labels) != 1 || labels[0].Utterance != "сколько сейчас времени" { t.Fatalf("labels %+v, want the turn that just ran", labels) } } // A turn nobody asked the id of costs nothing, which is the mic path. func TestTurnWithNoSinkStillPersists(t *testing.T) { h := traceHandler(t, decision.NewRing()) h.traces = traceSink(h.dataStore) if reply := h.handleText(context.Background(), "web", "сколько сейчас времени"); reply == "" { t.Fatal("turn produced no reply") } got, err := h.dataStore.RecentRoutingTraces(context.Background(), 5) if err != nil { t.Fatal(err) } if len(got) != 1 { t.Fatalf("persisted %d traces, want 1", len(got)) } }