fix: the slots converter was still dropping the fact Value (V-365)
dialogue.Slots gained Value in 925ce22, but toDialogueSlots never copied
it, so a clarifying answer carrying a fact payload still landed nowhere:
clarify.go:202 sends the answer through the converter, and the SlotValue
arm reads answer.Value.
Both converters now carry every field. TestSlotsParity compares the two
field sets by name and type; TestSlotsRoundTrip populates every router
field and checks the round trip, and fails the fixture itself when a new
field is left zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QChoBS5qJSrCV98oNUnHNU
This commit is contained in:
+12
-5
@@ -12,13 +12,21 @@ import (
|
|||||||
// which waits on voice-print attribution (see PROGRESS multi-user deferral).
|
// which waits on voice-print attribution (see PROGRESS multi-user deferral).
|
||||||
const voiceDialogueID = "voice"
|
const voiceDialogueID = "voice"
|
||||||
|
|
||||||
// toDialogueSlots projects the router's slots onto the dialogue layer's subset
|
// toDialogueSlots and applyDialogueSlots are the only bridge between
|
||||||
// (everything except the fact Value, which the dialogue layer doesn't carry).
|
// router.Slots and dialogue.Slots. dialogue must not import router (import
|
||||||
|
// cycle), so the two structs are hand-kept copies and every field has to be
|
||||||
|
// carried by hand here. Adding a field to either struct without adding it to
|
||||||
|
// BOTH functions loses a slot silently — nothing fails to build. The tests in
|
||||||
|
// slotsparity_test.go fail when the field sets or the converters stop matching;
|
||||||
|
// when they do, fix these two functions, not the tests.
|
||||||
|
|
||||||
|
// toDialogueSlots projects the router's slots onto the dialogue layer's copy.
|
||||||
func toDialogueSlots(s router.Slots) dialogue.Slots {
|
func toDialogueSlots(s router.Slots) dialogue.Slots {
|
||||||
return dialogue.Slots{
|
return dialogue.Slots{
|
||||||
Time: s.Time,
|
Time: s.Time,
|
||||||
HasTime: s.HasTime,
|
HasTime: s.HasTime,
|
||||||
Key: s.Key,
|
Key: s.Key,
|
||||||
|
Value: s.Value,
|
||||||
HasKey: s.HasKey,
|
HasKey: s.HasKey,
|
||||||
Text: s.Text,
|
Text: s.Text,
|
||||||
Fn: s.Fn,
|
Fn: s.Fn,
|
||||||
@@ -27,11 +35,10 @@ func toDialogueSlots(s router.Slots) dialogue.Slots {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyDialogueSlots writes inherited dialogue slots back onto router slots,
|
// applyDialogueSlots writes dialogue slots back onto router slots.
|
||||||
// preserving router-only fields (Value) the dialogue layer never touched.
|
|
||||||
func applyDialogueSlots(base router.Slots, d dialogue.Slots) router.Slots {
|
func applyDialogueSlots(base router.Slots, d dialogue.Slots) router.Slots {
|
||||||
base.Time, base.HasTime = d.Time, d.HasTime
|
base.Time, base.HasTime = d.Time, d.HasTime
|
||||||
base.Key, base.HasKey = d.Key, d.HasKey
|
base.Key, base.Value, base.HasKey = d.Key, d.Value, d.HasKey
|
||||||
base.Text = d.Text
|
base.Text = d.Text
|
||||||
base.Fn, base.Args, base.HasFn = d.Fn, d.Args, d.HasFn
|
base.Fn, base.Args, base.HasFn = d.Fn, d.Args, d.HasFn
|
||||||
return base
|
return base
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSlotsParity — dialogue.Slots is a hand-kept copy of router.Slots
|
||||||
|
// (dialogue must not import router: import cycle). Drift is silent, so this
|
||||||
|
// test compares the two field sets by name and type. If it fails, add the new
|
||||||
|
// field to both structs AND to toDialogueSlots/applyDialogueSlots in
|
||||||
|
// followup.go — do not relax the test.
|
||||||
|
func TestSlotsParity(t *testing.T) {
|
||||||
|
fields := func(v any) map[string]string {
|
||||||
|
rt := reflect.TypeOf(v)
|
||||||
|
out := make(map[string]string, rt.NumField())
|
||||||
|
for i := 0; i < rt.NumField(); i++ {
|
||||||
|
f := rt.Field(i)
|
||||||
|
out[f.Name] = f.Type.String()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
rf, df := fields(router.Slots{}), fields(dialogue.Slots{})
|
||||||
|
for name, typ := range rf {
|
||||||
|
dt, ok := df[name]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("router.Slots.%s (%s) missing from dialogue.Slots", name, typ)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if dt != typ {
|
||||||
|
t.Errorf("field %s: router has %s, dialogue has %s", name, typ, dt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, typ := range df {
|
||||||
|
if _, ok := rf[name]; !ok {
|
||||||
|
t.Errorf("dialogue.Slots.%s (%s) missing from router.Slots", name, typ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSlotsRoundTrip — the converters carry every field. A field the parity
|
||||||
|
// test accepts can still be dropped in transit, so round-trip a fully
|
||||||
|
// populated value and compare.
|
||||||
|
func TestSlotsRoundTrip(t *testing.T) {
|
||||||
|
full := router.Slots{
|
||||||
|
Time: time.Date(2026, 8, 2, 11, 0, 0, 0, time.UTC),
|
||||||
|
HasTime: true,
|
||||||
|
Fn: "restart",
|
||||||
|
Args: []string{"nginx"},
|
||||||
|
HasFn: true,
|
||||||
|
Key: "water",
|
||||||
|
Value: `"drank"`,
|
||||||
|
HasKey: true,
|
||||||
|
Text: "выпил воды",
|
||||||
|
}
|
||||||
|
// Every field must be non-zero, or the round-trip proves nothing.
|
||||||
|
rv := reflect.ValueOf(full)
|
||||||
|
for i := 0; i < rv.NumField(); i++ {
|
||||||
|
if rv.Field(i).IsZero() {
|
||||||
|
t.Fatalf("field %s is zero: extend this fixture so the round-trip covers it",
|
||||||
|
rv.Type().Field(i).Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := applyDialogueSlots(router.Slots{}, toDialogueSlots(full)); !reflect.DeepEqual(got, full) {
|
||||||
|
t.Errorf("round-trip lost a slot:\n got %+v\nwant %+v", got, full)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user