diff --git a/cmd/mavend/followup.go b/cmd/mavend/followup.go index cd94289..a8b0d75 100644 --- a/cmd/mavend/followup.go +++ b/cmd/mavend/followup.go @@ -12,13 +12,21 @@ import ( // which waits on voice-print attribution (see PROGRESS multi-user deferral). const voiceDialogueID = "voice" -// toDialogueSlots projects the router's slots onto the dialogue layer's subset -// (everything except the fact Value, which the dialogue layer doesn't carry). +// toDialogueSlots and applyDialogueSlots are the only bridge between +// 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 { return dialogue.Slots{ Time: s.Time, HasTime: s.HasTime, Key: s.Key, + Value: s.Value, HasKey: s.HasKey, Text: s.Text, Fn: s.Fn, @@ -27,11 +35,10 @@ func toDialogueSlots(s router.Slots) dialogue.Slots { } } -// applyDialogueSlots writes inherited dialogue slots back onto router slots, -// preserving router-only fields (Value) the dialogue layer never touched. +// applyDialogueSlots writes dialogue slots back onto router slots. func applyDialogueSlots(base router.Slots, d dialogue.Slots) router.Slots { 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.Fn, base.Args, base.HasFn = d.Fn, d.Args, d.HasFn return base diff --git a/cmd/mavend/slotsparity_test.go b/cmd/mavend/slotsparity_test.go new file mode 100644 index 0000000..1cfb5b0 --- /dev/null +++ b/cmd/mavend/slotsparity_test.go @@ -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) + } +}