Diff
1diff --git a/internal/adapters/speech_test.go b/internal/adapters/speech_test.go
2index e3a33901ad09f0e27b2f98b11188c46ddb45c246..cc87cc739c4c402f292249cecc929169123bc1c3 100644
3--- a/internal/adapters/speech_test.go
4+++ b/internal/adapters/speech_test.go
5@@ -8,6 +8,7 @@ import (
6 "testing"
7 "time"
8
9+ "japanese/internal/stt"
10 "japanese/internal/tts"
11 )
12
13@@ -45,3 +46,55 @@ func TestSpeechOutputSpeakErrorDoesNotFireOnDone(t *testing.T) {
14 case <-time.After(50 * time.Millisecond):
15 }
16 }
17+
18+func mustRomanizer(t *testing.T) *stt.Romanizer {
19+ t.Helper()
20+ r, err := stt.NewRomanizer()
21+ if err != nil {
22+ t.Fatalf("NewRomanizer: %v", err)
23+ }
24+ return r
25+}
26+
27+func TestSpeechInputBeginSurfacesRecorderFailure(t *testing.T) {
28+ in := &SpeechInput{
29+ Recorder: stt.NewRecorder("jp-definitely-not-a-real-command-xyz", 10*time.Second),
30+ Whisper: stt.NewWhisperClient("http://127.0.0.1:0/inference", "", nil),
31+ Romanize: mustRomanizer(t),
32+ }
33+ err := in.Begin(context.Background())
34+ if err == nil || !strings.Contains(err.Error(), "mic:") {
35+ t.Fatalf("expected a mic error, got %v", err)
36+ }
37+}
38+
39+func TestSpeechInputEndWithoutBegin(t *testing.T) {
40+ in := &SpeechInput{
41+ Recorder: stt.NewRecorder("sleep 5", 10*time.Second),
42+ Whisper: stt.NewWhisperClient("http://127.0.0.1:0/inference", "", nil),
43+ Romanize: mustRomanizer(t),
44+ }
45+ if _, err := in.End(context.Background()); err == nil || !strings.Contains(err.Error(), "recording not started") {
46+ t.Fatalf("expected a not-started error, got %v", err)
47+ }
48+}
49+
50+func TestSpeechInputEndSurfacesSTTErrorNamingURL(t *testing.T) {
51+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
52+ http.Error(w, "boom", http.StatusInternalServerError)
53+ }))
54+ defer srv.Close()
55+
56+ in := &SpeechInput{
57+ Recorder: stt.NewRecorder("sleep 5", 10*time.Second),
58+ Whisper: stt.NewWhisperClient(srv.URL, "", srv.Client()),
59+ Romanize: mustRomanizer(t),
60+ }
61+ if err := in.Begin(context.Background()); err != nil {
62+ t.Fatalf("Begin: %v", err)
63+ }
64+ _, err := in.End(context.Background())
65+ if err == nil || !strings.Contains(err.Error(), srv.URL) {
66+ t.Fatalf("expected STT error naming URL %q, got %v", srv.URL, err)
67+ }
68+}
69diff --git a/internal/llm/contract_test.go b/internal/llm/contract_test.go
70index 427ebc665037c4d9f6deffb2e86bec69d825dae2..90c7b0c393e61c70bc9ef0c6600fa1e660786a55 100644
71--- a/internal/llm/contract_test.go
72+++ b/internal/llm/contract_test.go
73@@ -141,3 +141,24 @@ func TestPromptTemplatesHaveNoGameOrGradingWords(t *testing.T) {
74 }
75 }
76 }
77+
78+func TestContractErrorMessageNamesFieldAndKind(t *testing.T) {
79+ cases := []struct {
80+ kind ContractErrorKind
81+ field string
82+ want string
83+ }{
84+ {MissingField, "KANA", "missing"},
85+ {DuplicateField, "ENGLISH", "duplicate"},
86+ {EmptyValue, "ROMAJI", "empty value"},
87+ {InvalidFormat, "SCORE", "invalid format"},
88+ {OutOfRange, "SCORE", "out of range"},
89+ }
90+ for _, c := range cases {
91+ e := &ContractError{Kind: c.kind, Field: c.field}
92+ got := e.Error()
93+ if !strings.Contains(got, c.field) || !strings.Contains(got, c.want) {
94+ t.Errorf("Error(%v, %s) = %q; want it to name the field and %q", c.kind, c.field, got, c.want)
95+ }
96+ }
97+}