/trace grows a turn-decisions table under the rule trace (V-564)

Both tables answer the same question, who won and who lost, one about nudges
and the other about utterances, so they share a page rather than splitting the
nav. A turn is one collapsible row; never_asked is coloured like a block,
because it usually is one. A read failure is logged and the rule trace above it
still renders: a daemon too old to know the method is the ordinary case during
a rolling deploy.
This commit is contained in:
2026-08-06 00:52:58 +04:00
parent a5b245dbf5
commit eec3d9bed2
3 changed files with 80 additions and 3 deletions
+40
View File
@@ -65,6 +65,7 @@ type fakeCore struct {
// for handleTrace tests
tickTrace ipc.TickTrace
traceErr error
turns []ipc.TurnDecision
// for handleChatAPI tests
chatText string
@@ -173,6 +174,10 @@ func (f *fakeCore) RevertFact(_ context.Context, key string) (int64, error) {
return f.revertNewID, nil
}
func (f *fakeCore) TurnDecisions(_ context.Context, _ int) ([]ipc.TurnDecision, error) {
return f.turns, nil
}
func (f *fakeCore) TickTrace(_ context.Context) (ipc.TickTrace, error) {
if f.traceErr != nil {
return ipc.TickTrace{}, f.traceErr
@@ -825,6 +830,41 @@ func TestHandleTrace(t *testing.T) {
t.Error("rendered 'nothing fired' but a winner was set")
}
})
// The turn arbitration shares this page (V-564). A reader must see the
// winner, a loser and the claimants that were never asked, because the last
// of those is what the hardcoded ordering hides.
t.Run("renders the turn decision record", func(t *testing.T) {
core := &fakeCore{turns: []ipc.TurnDecision{{
Ts: time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC),
Utterance: "какая погода в риме",
Winner: "query:weather",
Claims: []ipc.TurnClaim{
{Stage: "query", Claimant: "weather", Intent: "query", Outcome: "won"},
{Stage: "query", Claimant: "calendar", Outcome: "declined", Reason: "no answer"},
{Stage: "query", Claimant: "kiwix", Outcome: "never_asked"},
},
}}}
rr := httptest.NewRecorder()
handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), core)
body := rr.Body.String()
for _, want := range []string{"какая погода в риме", "query:weather", "calendar", "kiwix", "never_asked"} {
if !strings.Contains(body, want) {
t.Errorf("rendered page is missing %q", want)
}
}
})
t.Run("no turns renders the empty note, not an error", func(t *testing.T) {
rr := httptest.NewRecorder()
handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), &fakeCore{})
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), "no turn has run") {
t.Error("empty ring did not render its note")
}
})
}
// --- handleRevert ---