4757ff6d7b
resolveQuietToggle runs inside runTurn, so mavweb /api/chat and telegram reach it as well as the microphone. Every toggle was written with Source "tap:voice" regardless, which left the facts table claiming a mic flipped a setting nobody spoke to. This is the one function whose own doc comment calls it a network-reachable way to change a daemon-wide setting, and provenance is the first column read when asking why quiet mode is on. runTurn now takes the channel it was entered from and the toggle writes it: "tap:voice" from HandlePushToTalk, "tap:text" from handleText. Found in review of #53. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
169 lines
5.5 KiB
Go
169 lines
5.5 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
)
|
|
|
|
// quietFakeAPI records the WriteFact the toggle performs.
|
|
type quietFakeAPI struct {
|
|
ipc.UnimplementedCoreAPI
|
|
got ipc.WriteFactReq
|
|
call int
|
|
}
|
|
|
|
func (a *quietFakeAPI) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, error) {
|
|
a.got, a.call = req, a.call+1
|
|
return 1, nil
|
|
}
|
|
|
|
// quietVerdict — what a phrase should do to the setting.
|
|
type quietVerdict int
|
|
|
|
const (
|
|
quietNone quietVerdict = iota
|
|
quietOn
|
|
quietOff
|
|
)
|
|
|
|
func TestResolveQuietToggle(t *testing.T) {
|
|
cases := []struct {
|
|
text string
|
|
want quietVerdict
|
|
}{
|
|
// ON vocabulary.
|
|
{"quiet on", quietOn},
|
|
{"quiet mode", quietOn},
|
|
{"тихий режим", quietOn},
|
|
{"тихий", quietOn},
|
|
{"не шуми", quietOn},
|
|
{"не беспокоить", quietOn},
|
|
{"тихо", quietOn},
|
|
// ON, inflected / embedded in a sentence.
|
|
{"включи тихий режим", quietOn},
|
|
{"побудь в тихом режиме", quietOn},
|
|
{"Тихий Режим!", quietOn},
|
|
{"тихая", quietOn},
|
|
|
|
// OFF vocabulary — all seven, incl. the three that used to say ON.
|
|
{"quiet off", quietOff},
|
|
{"quiet end", quietOff},
|
|
{"громкий режим", quietOff},
|
|
{"шумный режим", quietOff},
|
|
{"отмени тихий", quietOff},
|
|
{"выключи тихий", quietOff},
|
|
{"не тихо", quietOff},
|
|
// OFF wins over the ON words it contains.
|
|
{"выключи тихий режим", quietOff},
|
|
{"отмени тихий режим пожалуйста", quietOff},
|
|
{"верни громкий режим", quietOff},
|
|
|
|
// False positives: "тихо"/"тихий" as ordinary Russian.
|
|
{"очень тихий сегодня день", quietNone},
|
|
{"в комнате тихо", quietNone},
|
|
{"тихонько напомни", quietNone},
|
|
{"потихоньку", quietNone},
|
|
{"тихонько", quietNone},
|
|
{"он говорил тихим голосом весь вечер", quietNone},
|
|
|
|
// Unrelated.
|
|
{"напомни завтра позвонить маме", quietNone},
|
|
{"какая погода", quietNone},
|
|
{"", quietNone},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.text, func(t *testing.T) {
|
|
api := &quietFakeAPI{}
|
|
h := &reactiveHandler{api: api, now: func() time.Time { return time.Unix(0, 0).UTC() }}
|
|
reply, handled := h.resolveQuietToggle(context.Background(), tc.text, sourceVoice)
|
|
|
|
if tc.want == quietNone {
|
|
if handled || reply != "" {
|
|
t.Fatalf("%q: got (%q, %v), want no match", tc.text, reply, handled)
|
|
}
|
|
if api.call != 0 {
|
|
t.Fatalf("%q: wrote a fact on a non-match", tc.text)
|
|
}
|
|
return
|
|
}
|
|
if !handled {
|
|
t.Fatalf("%q: not handled, want %v", tc.text, tc.want)
|
|
}
|
|
wantReply, wantVal := "тихий режим выключен.", "false"
|
|
if tc.want == quietOn {
|
|
wantReply, wantVal = "тихий режим включён. буду реже напоминать.", "true"
|
|
}
|
|
if reply != wantReply {
|
|
t.Errorf("%q: reply = %q, want %q", tc.text, reply, wantReply)
|
|
}
|
|
if api.call != 1 {
|
|
t.Fatalf("%q: WriteFact called %d times, want 1", tc.text, api.call)
|
|
}
|
|
if api.got.Kind != "config" || api.got.Key != "quiet_hours" || api.got.Source != "tap:voice" || api.got.Confidence != 1.0 {
|
|
t.Errorf("%q: request shape = %+v", tc.text, api.got)
|
|
}
|
|
if api.got.Value != wantVal {
|
|
t.Errorf("%q: value = %q, want %q", tc.text, api.got.Value, wantVal)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestQuietToggleNegationIsNotAdjacency — negation used to be an adjacency
|
|
// pattern ({"не","тих"} in the OFF list), so any word between the negator and
|
|
// the quiet word made the ON pattern win and asking for quiet mode to STOP
|
|
// turned it on. Negation is scanned over the whole utterance now.
|
|
func TestQuietToggleNegationIsNotAdjacency(t *testing.T) {
|
|
off := []string{
|
|
"не надо тихий режим",
|
|
"не хочу тихий режим",
|
|
"тихий режим выключи",
|
|
"убери тихий режим",
|
|
"хватит тихого режима",
|
|
"прекрати тихий режим",
|
|
"тихий режим отмени пожалуйста",
|
|
}
|
|
for _, text := range off {
|
|
t.Run(text, func(t *testing.T) {
|
|
on, isOff := classifyQuietToggle(text)
|
|
if on || !isOff {
|
|
t.Fatalf("%q: want OFF, got on=%v off=%v", text, on, isOff)
|
|
}
|
|
})
|
|
}
|
|
|
|
// The two ON phrases that are themselves built on "не" must stay ON: they
|
|
// are requests FOR quiet, not negations of one.
|
|
for _, text := range []string{"не шуми", "не беспокоить"} {
|
|
t.Run(text, func(t *testing.T) {
|
|
on, isOff := classifyQuietToggle(text)
|
|
if !on || isOff {
|
|
t.Fatalf("%q: want ON, got on=%v off=%v", text, on, isOff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestQuietToggleRecordsTheChannelItArrivedOn — the toggle is reachable from
|
|
// mavweb /api/chat and telegram, not only the microphone. Every write used to
|
|
// be stamped "tap:voice", so a toggle typed into the web UI claimed a mic wrote
|
|
// it and the provenance column lied about a daemon-wide setting.
|
|
func TestQuietToggleRecordsTheChannelItArrivedOn(t *testing.T) {
|
|
for _, src := range []turnSource{sourceVoice, sourceText} {
|
|
t.Run(string(src), func(t *testing.T) {
|
|
api := &quietFakeAPI{}
|
|
h := &reactiveHandler{api: api, now: func() time.Time { return time.Unix(0, 0).UTC() }}
|
|
if _, handled := h.resolveQuietToggle(context.Background(), "тихий режим", src); !handled {
|
|
t.Fatal("expected the toggle to match")
|
|
}
|
|
if api.got.Source != string(src) {
|
|
t.Errorf("source = %q, want %q", api.got.Source, src)
|
|
}
|
|
})
|
|
}
|
|
}
|