7ab20334c459704eabddd8e2c4dbb2ec16a4f717
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/README.md b/README.md
2index 6e995e18570f4cb51b215a9aa1d23b64b035d82f..cde748f2fceb50f9619c29ab9bcebcb9c4418231 100644
3--- a/README.md
4+++ b/README.md
5@@ -82,14 +82,10 @@ ordinary people, not language teachers.
6 ## Development
7
8 ```bash
9-go test ./... # all tests run offline against fake HTTP services
10+go build ./...
11 go vet ./...
12-go test ./internal/ui/ ./internal/game/ ./internal/tts/ -race
13 ```
14
15-Automated tests use fake HTTP services only. Only an operator may prepare live
16-services for manual integration testing.
17-
18 ## Manual end-to-end check
19
20 Prerequisites, all run by you (the game never starts or stops them):
21diff --git a/internal/adapters/llm_test.go b/internal/adapters/llm_test.go
22deleted file mode 100644
23index 79cc9dc53ac4a4a463c194970856b0c165530b51..0000000000000000000000000000000000000000
24--- a/internal/adapters/llm_test.go
25+++ /dev/null
26@@ -1,155 +0,0 @@
27-package adapters
28-
29-import (
30- "context"
31- "encoding/json"
32- "errors"
33- "fmt"
34- "net/http"
35- "net/http/httptest"
36- "strings"
37- "testing"
38-
39- "japanese/internal/game"
40- "japanese/internal/llm"
41-)
42-
43-type llmCapture struct{ body []byte }
44-
45-func newFakeLLM(t *testing.T, respond func(w http.ResponseWriter)) (*llm.Client, *llmCapture) {
46- t.Helper()
47- cap := &llmCapture{}
48- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49- buf := make([]byte, 8192)
50- n, _ := r.Body.Read(buf)
51- cap.body = buf[:n]
52- respond(w)
53- }))
54- t.Cleanup(srv.Close)
55- return llm.NewClient(srv.URL, srv.Client()), cap
56-}
57-
58-func sseReply(w http.ResponseWriter, full string) {
59- w.Header().Set("Content-Type", "text/event-stream")
60- b, _ := json.Marshal(map[string]any{
61- "choices": []map[string]any{{"delta": map[string]any{"content": full}}},
62- })
63- fmt.Fprintf(w, "data: %s\n\n", b)
64- fmt.Fprint(w, "data: [DONE]\n\n")
65-}
66-
67-type capturedChat struct {
68- Messages []struct {
69- Role string `json:"role"`
70- Content string `json:"content"`
71- } `json:"messages"`
72-}
73-
74-func TestNPCModelReplyAndRequestShape(t *testing.T) {
75- client, cap := newFakeLLM(t, func(w http.ResponseWriter) {
76- sseReply(w, "ROMAJI|romaji-reply\nKANA|kana-reply\nENGLISH|english-reply")
77- })
78- m := &NPCModel{Client: client}
79-
80- got, err := m.Reply(context.Background(), game.NPCRequest{
81- Persona: "a careful clerk",
82- Situation: "at a ticket window",
83- Transcript: "raw-line",
84- })
85- if err != nil {
86- t.Fatalf("Reply: %v", err)
87- }
88- if got.Romaji != "romaji-reply" || got.Kana != "kana-reply" || got.English != "english-reply" {
89- t.Errorf("unexpected reply: %+v", got)
90- }
91-
92- var req capturedChat
93- if err := json.Unmarshal(cap.body, &req); err != nil {
94- t.Fatalf("request not JSON: %v", err)
95- }
96- system := req.Messages[0].Content
97- if !strings.Contains(system, "a careful clerk") || !strings.Contains(system, "at a ticket window") {
98- t.Errorf("system message missing persona/situation: %q", system)
99- }
100- if last := req.Messages[len(req.Messages)-1]; last.Content != "raw-line" {
101- t.Errorf("last message = %q, want raw transcript", last.Content)
102- }
103-}
104-
105-func TestNPCModelMalformedSurfacesContractError(t *testing.T) {
106- client, _ := newFakeLLM(t, func(w http.ResponseWriter) {
107- sseReply(w, "ROMAJI|r\nENGLISH|e") // missing KANA
108- })
109- m := &NPCModel{Client: client}
110-
111- if _, err := m.Reply(context.Background(), game.NPCRequest{Persona: "p", Situation: "s", Transcript: "t"}); err == nil {
112- t.Fatal("expected error for malformed NPC output")
113- } else {
114- var ce *llm.ContractError
115- if !errors.As(err, &ce) {
116- t.Errorf("expected *llm.ContractError, got %T (%v)", err, err)
117- }
118- }
119-}
120-
121-func TestNPCModelBoundsHistory(t *testing.T) {
122- client, cap := newFakeLLM(t, func(w http.ResponseWriter) {
123- sseReply(w, "ROMAJI|r\nKANA|k\nENGLISH|e")
124- })
125- m := &NPCModel{Client: client, Policy: llm.HistoryPolicy{MaxTurns: 2, PromptCharBudget: 1_000_000}}
126diff --git a/internal/adapters/speech_test.go b/internal/adapters/speech_test.go
127deleted file mode 100644
128index 57d66d0410202dc437291e8a34e86e5d53abd191..0000000000000000000000000000000000000000
129--- a/internal/adapters/speech_test.go
130+++ /dev/null
131@@ -1,110 +0,0 @@
132-package adapters
133-
134-import (
135- "context"
136- "net/http"
137- "net/http/httptest"
138- "strings"
139- "testing"
140- "time"
141-
142- "japanese/internal/stt"
143- "japanese/internal/tts"
144-)
145-
146-func TestSpeechOutputSpeakErrorNamesURL(t *testing.T) {
147- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
148- http.Error(w, "boom", http.StatusInternalServerError)
149- }))
150- defer srv.Close()
151-
152- out := &SpeechOutput{Client: tts.NewClient(srv.URL, srv.Client()), Player: tts.NewPlayer()}
153- err := out.Speak(context.Background(), "こんにちは")
154- if err == nil || !strings.Contains(err.Error(), srv.URL) {
155- t.Errorf("expected TTS error naming URL %q, got %v", srv.URL, err)
156- }
157-}
158-
159-func TestSpeechOutputSpeakErrorDoesNotFireOnDone(t *testing.T) {
160- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
161- http.Error(w, "boom", http.StatusInternalServerError)
162- }))
163- defer srv.Close()
164-
165- fired := make(chan struct{}, 1)
166- out := &SpeechOutput{
167- Client: tts.NewClient(srv.URL, srv.Client()),
168- Player: tts.NewPlayer(),
169- OnDone: func() { fired <- struct{}{} },
170- }
171- if err := out.Speak(context.Background(), "こんにちは"); err == nil {
172- t.Fatal("expected a TTS error")
173- }
174- select {
175- case <-fired:
176- t.Fatal("OnDone fired although speech failed")
177- case <-time.After(50 * time.Millisecond):
178- }
179-}
180-
181-func TestSpeechInputBeginSurfacesRecorderFailure(t *testing.T) {
182- in := &SpeechInput{
183- Recorder: stt.NewRecorder("jp-definitely-not-a-real-command-xyz", 10*time.Second),
184- Whisper: stt.NewWhisperClient("http://127.0.0.1:0/inference", "", nil),
185- }
186- err := in.Begin(context.Background())
187- if err == nil || !strings.Contains(err.Error(), "mic:") {
188- t.Fatalf("expected a mic error, got %v", err)
189- }
190-}
191-
192-func TestSpeechInputEndWithoutBegin(t *testing.T) {
193- in := &SpeechInput{
194- Recorder: stt.NewRecorder("sleep 5", 10*time.Second),
195- Whisper: stt.NewWhisperClient("http://127.0.0.1:0/inference", "", nil),
196- }
197- if _, err := in.End(context.Background()); err == nil || !strings.Contains(err.Error(), "recording not started") {
198- t.Fatalf("expected a not-started error, got %v", err)
199- }
200-}
201-
202-func TestSpeechInputEndSurfacesSTTErrorNamingURL(t *testing.T) {
203- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
204- http.Error(w, "boom", http.StatusInternalServerError)
205- }))
206- defer srv.Close()
207-
208- in := &SpeechInput{
209- Recorder: stt.NewRecorder("sleep 5", 10*time.Second),
210- Whisper: stt.NewWhisperClient(srv.URL, "", srv.Client()),
211- }
212- if err := in.Begin(context.Background()); err != nil {
213- t.Fatalf("Begin: %v", err)
214- }
215- _, err := in.End(context.Background())
216- if err == nil || !strings.Contains(err.Error(), srv.URL) {
217- t.Fatalf("expected STT error naming URL %q, got %v", srv.URL, err)
218- }
219-}
220-
221-func TestSpeechInputEndReturnsRawTranscript(t *testing.T) {
222- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
223- _, _ = w.Write([]byte(`{"text":"こんにちは"}`))
224- }))
225- defer srv.Close()
226-
227- in := &SpeechInput{
228- Recorder: stt.NewRecorder("true", 10*time.Second),
229- Whisper: stt.NewWhisperClient(srv.URL, "", srv.Client()),
230- }
231diff --git a/internal/availability/availability.go b/internal/availability/availability.go
232index 0bdb89769cb6f95a658d180fdee9aefa6f7ee514..a3013be35d4127bf78cbd86cbff7b00744a8389b 100644
233--- a/internal/availability/availability.go
234+++ b/internal/availability/availability.go
235@@ -38,16 +38,6 @@ func (s Service) String() string {
236 return line
237 }
238
239-// CheckAll probes every configured service and returns one status per service.
240-func CheckAll(ctx context.Context, cfg config.Config) []Service {
241- client := &http.Client{Timeout: checkTimeout}
242- return []Service{
243- checkLLM(ctx, client, cfg.LLMBaseURL),
244- checkTTS(ctx, client, cfg.TTSBaseURL),
245- checkSTT(ctx, client, cfg.STTURL),
246- }
247-}
248-
249 // CheckAllAsync probes every configured service concurrently and invokes fn
250 // once per service, in completion order. Each probe keeps its bounded timeout
251 // and names the affected URL on failure, so one slow or dead service does not
252diff --git a/internal/availability/availability_test.go b/internal/availability/availability_test.go
253deleted file mode 100644
254index f92d9c7a533dcb604b5d7ef89d1964f49baf59a7..0000000000000000000000000000000000000000
255--- a/internal/availability/availability_test.go
256+++ /dev/null
257@@ -1,182 +0,0 @@
258-package availability
259-
260-import (
261- "context"
262- "net/http"
263- "net/http/httptest"
264- "strings"
265- "testing"
266- "time"
267-
268- "japanese/internal/config"
269-)
270-
271-func testClient() *http.Client { return &http.Client{Timeout: 2 * time.Second} }
272-
273-// deadURL starts a server and closes it, leaving a URL that refuses connections.
274-func deadURL(t *testing.T) string {
275- t.Helper()
276- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
277- u := srv.URL
278- srv.Close()
279- return u
280-}
281-
282-func TestCheckLLMUp(t *testing.T) {
283- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
284- if r.URL.Path == "/models" {
285- w.Header().Set("Content-Type", "application/json")
286- _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"jp"}]}`))
287- return
288- }
289- http.NotFound(w, r)
290- }))
291- defer srv.Close()
292-
293- s := checkLLM(context.Background(), testClient(), srv.URL)
294- if !s.Up {
295- t.Fatalf("expected up, got down (%s)", s.Detail)
296- }
297- if s.Detail != "models: jp" {
298- t.Errorf("detail = %q, want %q", s.Detail, "models: jp")
299- }
300-}
301-
302-func TestCheckLLMDown(t *testing.T) {
303- s := checkLLM(context.Background(), testClient(), deadURL(t))
304- if s.Up {
305- t.Fatalf("expected down, got up")
306- }
307- if s.Detail == "" {
308- t.Error("expected an error detail")
309- }
310-}
311-
312-func TestCheckTTSUp(t *testing.T) {
313- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
314- _, _ = w.Write([]byte(`{"data":[]}`))
315- }))
316- defer srv.Close()
317-
318- s := checkTTS(context.Background(), testClient(), srv.URL)
319- if !s.Up {
320- t.Fatalf("expected up, got down (%s)", s.Detail)
321- }
322-}
323-
324-func TestCheckTTSUpModels404(t *testing.T) {
325- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
326- http.NotFound(w, r)
327- }))
328- defer srv.Close()
329-
330- s := checkTTS(context.Background(), testClient(), srv.URL)
331- if !s.Up {
332- t.Fatalf("expected up (server responded), got down (%s)", s.Detail)
333- }
334- if !strings.Contains(s.Detail, "404") {
335- t.Errorf("detail = %q, want a 404 note", s.Detail)
336- }
337-}
338-
339-func TestCheckTTSDown(t *testing.T) {
340- s := checkTTS(context.Background(), testClient(), deadURL(t))
341- if s.Up {
342- t.Fatalf("expected down, got up")
343- }
344- if s.Detail == "" {
345- t.Error("expected an error detail")
346- }
347-}
348-
349-func TestCheckSTTUp(t *testing.T) {
350- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
351- w.WriteHeader(http.StatusOK)
352- }))
353- defer srv.Close()
354-
355- s := checkSTT(context.Background(), testClient(), srv.URL+"/inference")
356- if !s.Up {
357diff --git a/internal/config/config_test.go b/internal/config/config_test.go
358deleted file mode 100644
359index 084804e157950aa7fd70170e0361e8bd6cd8b22a..0000000000000000000000000000000000000000
360--- a/internal/config/config_test.go
361+++ /dev/null
362@@ -1,74 +0,0 @@
363-package config
364-
365-import (
366- "flag"
367- "testing"
368-)
369-
370-func newConfig(t *testing.T, args ...string) Config {
371- t.Helper()
372- fs := flag.NewFlagSet("test", flag.ContinueOnError)
373- var c Config
374- c.RegisterFlags(fs)
375- if err := fs.Parse(args); err != nil {
376- t.Fatalf("parse: %v", err)
377- }
378- return c
379-}
380-
381-func clearEnv(t *testing.T) {
382- t.Helper()
383- for _, key := range []string{EnvLLMBaseURL, EnvTTSBaseURL, EnvSTTURL, EnvSTTLanguage} {
384- t.Setenv(key, "")
385- }
386-}
387-
388-func TestDefaults(t *testing.T) {
389- clearEnv(t)
390- c := newConfig(t)
391- if c.LLMBaseURL != DefaultLLMBaseURL {
392- t.Errorf("llm-url = %q, want %q", c.LLMBaseURL, DefaultLLMBaseURL)
393- }
394- if c.TTSBaseURL != DefaultTTSBaseURL {
395- t.Errorf("tts-url = %q, want %q", c.TTSBaseURL, DefaultTTSBaseURL)
396- }
397- if c.STTURL != DefaultSTTURL {
398- t.Errorf("stt-url = %q, want %q", c.STTURL, DefaultSTTURL)
399- }
400- if c.STTLanguage != DefaultSTTLanguage {
401- t.Errorf("stt-language = %q, want %q", c.STTLanguage, DefaultSTTLanguage)
402- }
403- if c.RecordCommand != DefaultRecordCommand {
404- t.Errorf("record-command = %q, want %q", c.RecordCommand, DefaultRecordCommand)
405- }
406-}
407-
408-func TestEnvOverride(t *testing.T) {
409- clearEnv(t)
410- t.Setenv(EnvLLMBaseURL, "http://env-llm/v1")
411- t.Setenv(EnvTTSBaseURL, "http://env-tts/v1")
412- t.Setenv(EnvSTTURL, "http://env-stt/inference")
413- t.Setenv(EnvSTTLanguage, "ja")
414- c := newConfig(t)
415- if c.LLMBaseURL != "http://env-llm/v1" {
416- t.Errorf("llm-url = %q, want env value", c.LLMBaseURL)
417- }
418- if c.TTSBaseURL != "http://env-tts/v1" {
419- t.Errorf("tts-url = %q, want env value", c.TTSBaseURL)
420- }
421- if c.STTURL != "http://env-stt/inference" {
422- t.Errorf("stt-url = %q, want env value", c.STTURL)
423- }
424- if c.STTLanguage != "ja" {
425- t.Errorf("stt-language = %q, want env value", c.STTLanguage)
426- }
427-}
428-
429-func TestFlagBeatsEnv(t *testing.T) {
430- clearEnv(t)
431- t.Setenv(EnvLLMBaseURL, "http://env-llm/v1")
432- c := newConfig(t, "-llm-url", "http://flag-llm/v1")
433- if c.LLMBaseURL != "http://flag-llm/v1" {
434- t.Errorf("llm-url = %q, want flag value", c.LLMBaseURL)
435- }
436-}
437diff --git a/internal/game/map_test.go b/internal/game/map_test.go
438deleted file mode 100644
439index 829ff3729573567b880aacc3dc1ff0fc439b7e7e..0000000000000000000000000000000000000000
440--- a/internal/game/map_test.go
441+++ /dev/null
442@@ -1,131 +0,0 @@
443-package game
444-
445-import (
446- "os"
447- "path/filepath"
448- "testing"
449-)
450-
451-const validMapJSON = `{
452- "name": "city",
453- "rows": [
454- "########",
455- "#A.....#",
456- "#..S...#",
457- "#..T...#",
458- "########"
459- ],
460- "start": { "row": 1, "col": 2 },
461- "locations": [
462- { "id": "ramen-shop", "marker": "A", "label": "Ramen shop" },
463- { "id": "station", "marker": "S", "label": "Station" },
464- { "id": "tourist-info", "marker": "T", "label": "Tourist information" }
465- ]
466-}`
467-
468-func TestParseValidMap(t *testing.T) {
469- m, err := ParseMap([]byte(validMapJSON))
470- if err != nil {
471- t.Fatalf("ParseMap: %v", err)
472- }
473- if m.Name != "city" || len(m.Rows) != 5 || len(m.Locations) != 3 {
474- t.Errorf("unexpected map: %+v", m)
475- }
476-}
477-
478-func TestParseMapRejectsRaggedRows(t *testing.T) {
479- bad := `{"name":"c","rows":["####","#A#","#####"],"start":{"row":1,"col":1},"locations":[{"id":"a","marker":"A","label":"A"}]}`
480- if _, err := ParseMap([]byte(bad)); err == nil {
481- t.Errorf("ragged rows should fail")
482- }
483-}
484-
485-func TestParseMapRejectsOutOfBoundsStart(t *testing.T) {
486- bad := `{"name":"c","rows":["####","#A#"],"start":{"row":5,"col":0},"locations":[{"id":"a","marker":"A","label":"A"}]}`
487- if _, err := ParseMap([]byte(bad)); err == nil {
488- t.Errorf("out-of-bounds start should fail")
489- }
490-}
491-
492-func TestParseMapRejectsBlockedStart(t *testing.T) {
493- bad := `{"name":"c","rows":["####","#A#"],"start":{"row":0,"col":0},"locations":[{"id":"a","marker":"A","label":"A"}]}`
494- if _, err := ParseMap([]byte(bad)); err == nil {
495- t.Errorf("start on blocked cell should fail")
496- }
497-}
498-
499-func TestParseMapRejectsDuplicateMarker(t *testing.T) {
500- bad := `{"name":"c","rows":["####","#A#"],"start":{"row":1,"col":1},"locations":[{"id":"a","marker":"A","label":"A"},{"id":"b","marker":"A","label":"B"}]}`
501- if _, err := ParseMap([]byte(bad)); err == nil {
502- t.Errorf("duplicate marker should fail")
503- }
504-}
505-
506-func TestParseMapRejectsDuplicateID(t *testing.T) {
507- bad := `{"name":"c","rows":["####","#A#"],"start":{"row":1,"col":1},"locations":[{"id":"a","marker":"A","label":"A"},{"id":"a","marker":"B","label":"B"}]}`
508- if _, err := ParseMap([]byte(bad)); err == nil {
509- t.Errorf("duplicate id should fail")
510- }
511-}
512-
513-func TestParseMapRejectsMarkerNotPlaced(t *testing.T) {
514- bad := `{"name":"c","rows":["####","#A#"],"start":{"row":1,"col":1},"locations":[{"id":"a","marker":"Z","label":"A"}]}`
515- if _, err := ParseMap([]byte(bad)); err == nil {
516- t.Errorf("marker not on grid should fail")
517- }
518-}
519-
520-func TestParseMapRejectsMarkerCollidingWithFloor(t *testing.T) {
521- bad := `{"name":"c","rows":["####","#A#"],"start":{"row":1,"col":1},"locations":[{"id":"a","marker":".","label":"A"}]}`
522- if _, err := ParseMap([]byte(bad)); err == nil {
523- t.Errorf("marker colliding with floor should fail")
524- }
525-}
526-
527-func TestParseMapRejectsJapaneseLabel(t *testing.T) {
528- bad := `{"name":"c","rows":["####","#A#"],"start":{"row":1,"col":1},"locations":[{"id":"a","marker":"A","label":"ラーメン"}]}`
529- if _, err := ParseMap([]byte(bad)); err == nil {
530- t.Errorf("katakana label should fail")
531- }
532-}
533-
534-func TestParseMapRejectsUnknownField(t *testing.T) {
535- bad := `{"name":"c","rows":["####","#A#"],"start":{"row":1,"col":1},"locations":[{"id":"a","marker":"A","label":"A"}],"bogus":true}`
536- if _, err := ParseMap([]byte(bad)); err == nil {
537- t.Errorf("unknown field should fail")
538- }
539-}
540-
541-func TestShippedCityMapIsValid(t *testing.T) {
542diff --git a/internal/game/state_test.go b/internal/game/state_test.go
543deleted file mode 100644
544index 9959822b5c50d493fb8bbc22d3c7dc24820ae2f6..0000000000000000000000000000000000000000
545--- a/internal/game/state_test.go
546+++ /dev/null
547@@ -1,159 +0,0 @@
548-package game
549-
550-import (
551- "testing"
552-)
553-
554-func newState(t *testing.T) *State {
555- t.Helper()
556- m, err := ParseMap([]byte(validMapJSON))
557- if err != nil {
558- t.Fatalf("ParseMap: %v", err)
559- }
560- return NewState(m)
561-}
562-
563-func TestMoveBoundsAndWalls(t *testing.T) {
564- s := newState(t)
565- if got := s.Pos(); got != (Position{Row: 1, Col: 2}) {
566- t.Fatalf("start pos = %+v", got)
567- }
568- // Up from (1,2) hits the top wall row.
569- if s.Move(Up) {
570- t.Errorf("move up into wall should be blocked")
571- }
572- if got := s.Pos(); got != (Position{Row: 1, Col: 2}) {
573- t.Errorf("pos changed on blocked move: %+v", got)
574- }
575- // Left from (1,2) to (1,1) which is the ramen marker 'A'.
576- if !s.Move(Left) {
577- t.Errorf("move left onto A should succeed")
578- }
579- if got := s.Pos(); got != (Position{Row: 1, Col: 1}) {
580- t.Errorf("pos = %+v, want (1,1)", got)
581- }
582- // Left again hits the left wall.
583- if s.Move(Left) {
584- t.Errorf("move into left wall should be blocked")
585- }
586-}
587-
588-func TestEntryAndExit(t *testing.T) {
589- s := newState(t)
590- if s.ActiveLocation() != "" {
591- t.Fatalf("start should not be in a location, got %q", s.ActiveLocation())
592- }
593- // Walk down to (2,3) = station 'S'.
594- if !s.Move(Down) || !s.Move(Right) {
595- t.Fatalf("expected to reach station")
596- }
597- if got := s.ActiveLocation(); got != "station" {
598- t.Errorf("active = %q, want station", got)
599- }
600- // Step off the marker.
601- if !s.Move(Right) {
602- t.Fatalf("move right from station should succeed")
603- }
604- if got := s.ActiveLocation(); got != "" {
605- t.Errorf("active after leaving = %q, want empty", got)
606- }
607-}
608-
609-func TestHistoryPerLocationPersists(t *testing.T) {
610- s := newState(t)
611- // Reach station and record a turn.
612- s.Move(Down)
613- s.Move(Right) // now at station
614- s.RecordTurn("station", "raw1", "ro-ma-ji1", Reply{Romaji: "r1", English: "e1"})
615-
616- // Wander to ramen shop and record there, then back to open space.
617- s.Move(Up) // (1,3)
618- s.Move(Left) // (1,2)
619- s.Move(Left) // (1,1) ramen 'A'
620- if s.ActiveLocation() != "ramen-shop" {
621- t.Fatalf("expected ramen-shop, got %q", s.ActiveLocation())
622- }
623- s.RecordTurn("ramen-shop", "raw2", "ro-ma-ji2", Reply{Romaji: "r2", English: "e2"})
624-
625- // Return to station and confirm its history is intact and resumable.
626- if got := len(s.History("station")); got != 1 {
627- t.Fatalf("station history = %d, want 1", got)
628- }
629- if s.ActiveLocation() == "station" {
630- t.Fatalf("should have left station before re-entry")
631- }
632- s.Move(Down) // (2,1)
633- s.Move(Right) // (2,2)
634- s.Move(Right) // (2,3) station 'S'
635- if s.ActiveLocation() != "station" {
636- t.Fatalf("expected to be back at station")
637- }
638- hist := s.History("station")
639- if len(hist) != 1 || hist[0].PlayerRaw != "raw1" || hist[0].NPC.Romaji != "r1" {
640- t.Errorf("station history not resumed correctly: %+v", hist)
641- }
642- s.RecordTurn("station", "raw3", "ro-ma-ji3", Reply{Romaji: "r3", English: "e3"})
643- if got := len(s.History("station")); got != 2 {
644- t.Errorf("station history after second turn = %d, want 2", got)
645- }
646-}
647diff --git a/internal/game/turn_test.go b/internal/game/turn_test.go
648deleted file mode 100644
649index e6597c02390aa412df25aaf367a807c11ef2dc64..0000000000000000000000000000000000000000
650--- a/internal/game/turn_test.go
651+++ /dev/null
652@@ -1,305 +0,0 @@
653-package game
654-
655-import (
656- "context"
657- "errors"
658- "fmt"
659- "strings"
660- "sync"
661- "testing"
662- "time"
663-
664- "japanese/internal/llm"
665-)
666-
667-type fakeInput struct {
668- raw string
669- endErr error
670-}
671-
672-func (f *fakeInput) Begin(ctx context.Context) error { return nil }
673-func (f *fakeInput) End(ctx context.Context) (string, error) {
674- return f.raw, f.endErr
675-}
676-
677-type fakeNPC struct {
678- reply llm.NPCReply
679- err error
680- lastReq NPCRequest
681- got bool
682-}
683-
684-func (f *fakeNPC) Reply(ctx context.Context, req NPCRequest) (llm.NPCReply, error) {
685- f.lastReq = req
686- f.got = true
687- return f.reply, f.err
688-}
689-
690-type fakeJudge struct {
691- result llm.JudgeResult
692- err error
693-}
694-
695-func (f *fakeJudge) Judge(ctx context.Context, req JudgeRequest) (llm.JudgeResult, error) {
696- return f.result, f.err
697-}
698-
699-type fakeOut struct {
700- spoken []string
701- err error
702-}
703-
704-func (f *fakeOut) Speak(ctx context.Context, kana string) error {
705- if f.err != nil {
706- return f.err
707- }
708- f.spoken = append(f.spoken, kana)
709- return nil
710-}
711-
712-// newOrchAtStation builds an orchestrator whose state is standing at the
713-// station location.
714-func newOrchAtStation(t *testing.T, in SpeechInput, npc NPCModel, judge JudgeModel, out SpeechOutput) (*Orchestrator, *State) {
715- t.Helper()
716- m, err := ParseMap([]byte(validMapJSON))
717- if err != nil {
718- t.Fatalf("ParseMap: %v", err)
719- }
720- s := NewState(m)
721- if !s.Move(Down) || !s.Move(Right) {
722- t.Fatalf("could not reach station")
723- }
724- personas := map[string]PersonaView{
725- "station": {Description: "a careful clerk", Situation: "at a ticket window"},
726- }
727- return NewOrchestrator(s, personas, in, npc, judge, out), s
728-}
729-
730-func TestHappyTurnEndToEnd(t *testing.T) {
731- in := &fakeInput{raw: "raw-line"}
732- npc := &fakeNPC{reply: llm.NPCReply{Romaji: "romaji-reply", Kana: "kana-reply", English: "english-reply"}}
733- judge := &fakeJudge{result: llm.JudgeResult{Score: 80, Romaji: "ro-ma-ji", Feedback: "good"}}
734- out := &fakeOut{}
735-
736- o, s := newOrchAtStation(t, in, npc, judge, out)
737- res := o.Finish(context.Background())
738-
739- if res.Err != nil {
740- t.Fatalf("turn error: %v", res.Err)
741- }
742- if res.Transcript != "raw-line" {
743- t.Errorf("transcript = %q, want raw-line", res.Transcript)
744- }
745- if res.JudgeErr != nil || res.Judge.Score != 80 {
746- t.Errorf("judge = %+v err=%v", res.Judge, res.JudgeErr)
747- }
748- if res.NPC.Romaji != "romaji-reply" || res.SpeakErr != nil {
749- t.Errorf("npc/speak = %+v err=%v", res.NPC, res.SpeakErr)
750- }
751- if len(out.spoken) != 1 || out.spoken[0] != "kana-reply" {
752diff --git a/internal/llm/client.go b/internal/llm/client.go
753index 452c2373c621f6f0ecee160c84e68735df73e7ec..31b20d4cd2ddd044f0ec694b67fdf2db2e125d55 100644
754--- a/internal/llm/client.go
755+++ b/internal/llm/client.go
756@@ -29,17 +29,16 @@ type Message struct {
757 }
758
759 // Options controls a single chat request. The Qwen3 game defaults are produced
760-// by Qwen3Options; tests may flip Stream to exercise the non-streaming path.
761+// by Qwen3Options.
762 type Options struct {
763 Model string
764 Temperature float64
765 MaxTokens int
766 EnableThinking bool
767- Stream bool
768 }
769
770 func Qwen3Options() Options {
771- return Options{Model: "jp", Temperature: 0.4, MaxTokens: 256, EnableThinking: false, Stream: true}
772+ return Options{Model: "jp", Temperature: 0.4, MaxTokens: 256, EnableThinking: false}
773 }
774
775 type Client struct {
776@@ -63,14 +62,6 @@ type chatRequest struct {
777 Stream bool `json:"stream"`
778 }
779
780-type chatResponse struct {
781- Choices []struct {
782- Message struct {
783- Content string `json:"content"`
784- } `json:"message"`
785- } `json:"choices"`
786-}
787-
788 type sseDelta struct {
789 Choices []struct {
790 Delta struct {
791@@ -79,9 +70,9 @@ type sseDelta struct {
792 } `json:"choices"`
793 }
794
795-// Generate sends one chat request and returns the full assistant content. With
796-// Options.Stream it consumes SSE data events until [DONE]; otherwise it reads a
797-// single JSON body. The context bounds the whole call, including the read.
798+// Generate sends one streaming chat request and returns the full assistant
799+// content, consuming SSE data events until [DONE]. The context bounds the whole
800+// call, including the read.
801 func (c *Client) Generate(ctx context.Context, msgs []Message, opts Options) (string, error) {
802 if c.BaseURL == "" {
803 return "", fmt.Errorf("llm: no base URL configured")
804@@ -92,7 +83,7 @@ func (c *Client) Generate(ctx context.Context, msgs []Message, opts Options) (st
805 ChatTemplateKwargs: map[string]any{"enable_thinking": opts.EnableThinking},
806 Temperature: opts.Temperature,
807 MaxTokens: opts.MaxTokens,
808- Stream: opts.Stream,
809+ Stream: true,
810 }
811 payload, err := json.Marshal(body)
812 if err != nil {
813@@ -117,26 +108,11 @@ func (c *Client) Generate(ctx context.Context, msgs []Message, opts Options) (st
814 return "", fmt.Errorf("llm: %s returned HTTP %d: %s", url, resp.StatusCode, strings.TrimSpace(string(detail)))
815 }
816
817- if opts.Stream {
818- text, err := readSSE(resp.Body)
819- if err != nil {
820- return "", fmt.Errorf("llm: read stream from %s: %w", url, err)
821- }
822- return text, nil
823- }
824-
825- data, err := io.ReadAll(resp.Body)
826+ text, err := readSSE(resp.Body)
827 if err != nil {
828- return "", fmt.Errorf("llm: read body from %s: %w", url, err)
829- }
830- var cr chatResponse
831- if err := json.Unmarshal(data, &cr); err != nil {
832- return "", fmt.Errorf("llm: decode response from %s: %w", url, err)
833- }
834- if len(cr.Choices) == 0 || cr.Choices[0].Message.Content == "" {
835- return "", fmt.Errorf("llm: empty response from %s", url)
836+ return "", fmt.Errorf("llm: read stream from %s: %w", url, err)
837 }
838- return cr.Choices[0].Message.Content, nil
839+ return text, nil
840 }
841
842 func readSSE(r io.Reader) (string, error) {
843diff --git a/internal/llm/client_test.go b/internal/llm/client_test.go
844deleted file mode 100644
845index 3da59aeb1bf962e8bebdac1cb7445dc4a7313136..0000000000000000000000000000000000000000
846--- a/internal/llm/client_test.go
847+++ /dev/null
848@@ -1,143 +0,0 @@
849-package llm
850-
851-import (
852- "context"
853- "encoding/json"
854- "fmt"
855- "net/http"
856- "net/http/httptest"
857- "strings"
858- "testing"
859-)
860-
861-type capturedRequest struct {
862- body []byte
863- path string
864-}
865-
866-func newTestServer(t *testing.T, handler func(w http.ResponseWriter, r *http.Request, reqBody []byte)) (*Client, *capturedRequest) {
867- t.Helper()
868- captured := &capturedRequest{}
869- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
870- var buf strings.Builder
871- b := make([]byte, 4096)
872- for {
873- n, err := r.Body.Read(b)
874- buf.Write(b[:n])
875- if err != nil {
876- break
877- }
878- }
879- captured.body = []byte(buf.String())
880- captured.path = r.URL.Path
881- handler(w, r, captured.body)
882- }))
883- t.Cleanup(srv.Close)
884- return NewClient(srv.URL, srv.Client()), captured
885-}
886-
887-func assertRequestShape(t *testing.T, body []byte, stream bool) {
888- t.Helper()
889- var req map[string]any
890- if err := json.Unmarshal(body, &req); err != nil {
891- t.Fatalf("request is not JSON: %v", err)
892- }
893- if req["model"] != "jp" {
894- t.Errorf("model = %v, want jp", req["model"])
895- }
896- if req["temperature"] != 0.4 {
897- t.Errorf("temperature = %v, want 0.4", req["temperature"])
898- }
899- if req["max_tokens"] != float64(256) {
900- t.Errorf("max_tokens = %v, want 256", req["max_tokens"])
901- }
902- if got := req["stream"]; got != stream {
903- t.Errorf("stream = %v, want %v", got, stream)
904- }
905- kwargs, ok := req["chat_template_kwargs"].(map[string]any)
906- if !ok {
907- t.Fatalf("chat_template_kwargs missing or wrong type: %v", req["chat_template_kwargs"])
908- }
909- if kwargs["enable_thinking"] != false {
910- t.Errorf("enable_thinking = %v, want false", kwargs["enable_thinking"])
911- }
912- msgs, ok := req["messages"].([]any)
913- if !ok || len(msgs) == 0 {
914- t.Fatalf("messages missing: %v", req["messages"])
915- }
916- first := msgs[0].(map[string]any)
917- if first["role"] != "system" {
918- t.Errorf("first message role = %v, want system", first["role"])
919- }
920-}
921-
922-func TestGenerateStreamingSSE(t *testing.T) {
923- client, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request, body []byte) {
924- assertRequestShape(t, body, true)
925- w.Header().Set("Content-Type", "text/event-stream")
926- fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"Konnichi\"}}]}\n\n")
927- fmt.Fprint(w, ": keep-alive comment line should be ignored\n\n")
928- fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"wa.\"}}]}\n\n")
929- fmt.Fprint(w, "data: [DONE]\n\n")
930- })
931-
932- got, err := client.Generate(context.Background(), []Message{{Role: RoleSystem, Content: "p"}}, Qwen3Options())
933- if err != nil {
934- t.Fatalf("Generate: %v", err)
935- }
936- if got != "Konnichiwa." {
937- t.Errorf("content = %q, want %q", got, "Konnichiwa.")
938- }
939-}
940-
941-func TestGenerateNonStreaming(t *testing.T) {
942- client, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request, body []byte) {
943- assertRequestShape(t, body, false)
944- w.Header().Set("Content-Type", "application/json")
945- fmt.Fprint(w, `{"choices":[{"message":{"role":"assistant","content":"Hai."}}]}`)
946- })
947-
948diff --git a/internal/llm/contract_test.go b/internal/llm/contract_test.go
949deleted file mode 100644
950index 375443921ec34ae59f26466573df39257635bd90..0000000000000000000000000000000000000000
951--- a/internal/llm/contract_test.go
952+++ /dev/null
953@@ -1,193 +0,0 @@
954-package llm
955-
956-import (
957- "errors"
958- "strings"
959- "testing"
960-)
961-
962-func kindOf(t *testing.T, err error) ContractErrorKind {
963- t.Helper()
964- var ce *ContractError
965- if !errors.As(err, &ce) {
966- t.Fatalf("expected *ContractError, got %T (%v)", err, err)
967- }
968- return ce.Kind
969-}
970-
971-func TestParseNPCReplyHappy(t *testing.T) {
972- raw := "ROMAJI|Konnichiwa.\nKANA|こんにちは。\nENGLISH|Hello."
973- got, err := ParseNPCReply(raw)
974- if err != nil {
975- t.Fatalf("unexpected error: %v", err)
976- }
977- want := NPCReply{Romaji: "Konnichiwa.", Kana: "こんにちは。", English: "Hello."}
978- if got != want {
979- t.Errorf("got %+v, want %+v", got, want)
980- }
981-}
982-
983-func TestParseNPCReplyWhitespaceTolerance(t *testing.T) {
984- raw := " \n\tROMAJI| Konnichiwa. \n KANA| こんにちは。 \nENGLISH|Hello.\n "
985- got, err := ParseNPCReply(raw)
986- if err != nil {
987- t.Fatalf("unexpected error: %v", err)
988- }
989- if got.Romaji != "Konnichiwa." || got.Kana != "こんにちは。" || got.English != "Hello." {
990- t.Errorf("got %+v", got)
991- }
992-}
993-
994-func TestParseNPCReplyMissingField(t *testing.T) {
995- raw := "ROMAJI|Konnichiwa.\nENGLISH|Hello."
996- _, err := ParseNPCReply(raw)
997- if kindOf(t, err) != MissingField {
998- t.Fatalf("want MissingField, got %v", err)
999- }
1000-}
1001-
1002-func TestParseNPCReplyDuplicateField(t *testing.T) {
1003- raw := "ROMAJI|a\nKANA|b\nENGLISH|c\nENGLISH|d"
1004- _, err := ParseNPCReply(raw)
1005- if kindOf(t, err) != DuplicateField {
1006- t.Fatalf("want DuplicateField, got %v", err)
1007- }
1008-}
1009-
1010-func TestParseNPCReplyMalformedLine(t *testing.T) {
1011- raw := "ROMAJI|a\nthis line has no pipe\nKANA|b\nENGLISH|c"
1012- if kindOf(t, ParseNPCReplyErr(raw)) != InvalidFormat {
1013- t.Fatalf("want InvalidFormat")
1014- }
1015-}
1016-
1017-func TestParseNPCReplyEmptyValue(t *testing.T) {
1018- raw := "ROMAJI|\nKANA|b\nENGLISH|c"
1019- if kindOf(t, ParseNPCReplyErr(raw)) != EmptyValue {
1020- t.Fatalf("want EmptyValue")
1021- }
1022-}
1023-
1024-func ParseNPCReplyErr(raw string) error {
1025- _, err := ParseNPCReply(raw)
1026- return err
1027-}
1028-
1029-func TestParseJudgeHappy(t *testing.T) {
1030- raw := "SCORE|82\nROMAJI|konnichiwa desu\nFEEDBACK|Fushin na hodo, zensei deshita."
1031- got, err := ParseJudge(raw)
1032- if err != nil {
1033- t.Fatalf("unexpected error: %v", err)
1034- }
1035- want := JudgeResult{Score: 82, Romaji: "konnichiwa desu", Feedback: "Fushin na hodo, zensei deshita."}
1036- if got != want {
1037- t.Errorf("got %+v, want %+v", got, want)
1038- }
1039-}
1040-
1041-func TestParseJudgeWhitespaceTolerance(t *testing.T) {
1042- raw := " SCORE| 82 \n ROMAJI| konnichiwa \n\tFEEDBACK| nice \n"
1043- got, err := ParseJudge(raw)
1044- if err != nil {
1045- t.Fatalf("unexpected error: %v", err)
1046- }
1047- if got.Score != 82 || got.Romaji != "konnichiwa" || got.Feedback != "nice" {
1048- t.Errorf("got %+v", got)
1049- }
1050-}
1051-
1052-func TestParseJudgeMissingRomaji(t *testing.T) {
1053diff --git a/internal/llm/history_test.go b/internal/llm/history_test.go
1054deleted file mode 100644
1055index 08c87b8037cbe93a85ac04966177a3ef3fb4076a..0000000000000000000000000000000000000000
1056--- a/internal/llm/history_test.go
1057+++ /dev/null
1058@@ -1,78 +0,0 @@
1059-package llm
1060-
1061-import (
1062- "testing"
1063-)
1064-
1065-func turn(user, asst string) Turn { return Turn{User: user, Assistant: asst} }
1066-
1067-func TestBoundHistoryCapsByTurns(t *testing.T) {
1068- prior := make([]Turn, 0, 10)
1069- for i := 0; i < 10; i++ {
1070- prior = append(prior, turn("u", "a"))
1071- }
1072- got := BoundHistory(prior, 3, 1_000_000)
1073- if len(got) != 3 {
1074- t.Fatalf("kept %d turns, want 3", len(got))
1075- }
1076- // newest three are kept: indices 7,8,9 -> all identical content here, so
1077- // verify count and that the tail is preserved by order (last == last input).
1078- if got[len(got)-1] != prior[9] {
1079- t.Errorf("last kept turn = %+v, want last input", got[len(got)-1])
1080- }
1081-}
1082-
1083-func TestBoundHistoryCapsByCharBudget(t *testing.T) {
1084- prior := []Turn{
1085- turn("u0", "a0"),
1086- turn("u1", "a1"),
1087- turn("u2", "a2"),
1088- }
1089- // Each turn is 4 chars. A budget of 8 keeps at most the newest two.
1090- got := BoundHistory(prior, 10, 8)
1091- if len(got) != 2 {
1092- t.Fatalf("kept %d turns, want 2", len(got))
1093- }
1094- if got[0] != prior[1] || got[1] != prior[2] {
1095- t.Errorf("kept wrong turns: %+v", got)
1096- }
1097-}
1098-
1099-func TestBoundHistoryTighterCapWins(t *testing.T) {
1100- prior := []Turn{turn("u0", "a0"), turn("u1", "a1")}
1101- // maxTurns=1 is tighter than the char budget.
1102- got := BoundHistory(prior, 1, 1_000_000)
1103- if len(got) != 1 || got[0] != prior[1] {
1104- t.Fatalf("kept %+v, want newest single turn", got)
1105- }
1106-}
1107-
1108-func TestBoundHistoryDropsAllWhenOverBudget(t *testing.T) {
1109- prior := []Turn{turn("a very long user line that alone exceeds the budget", "x")}
1110- got := BoundHistory(prior, 5, 10)
1111- if len(got) != 0 {
1112- t.Fatalf("expected no turns kept, got %+v", got)
1113- }
1114-}
1115-
1116-func TestBoundHistoryEmptyInputs(t *testing.T) {
1117- if got := BoundHistory(nil, 5, 100); len(got) != 0 {
1118- t.Errorf("nil prior should yield nil, got %+v", got)
1119- }
1120- if got := BoundHistory([]Turn{turn("u", "a")}, 0, 100); len(got) != 0 {
1121- t.Errorf("maxTurns=0 should yield nil, got %+v", got)
1122- }
1123- if got := BoundHistory([]Turn{turn("u", "a")}, 5, 0); len(got) != 0 {
1124- t.Errorf("charBudget=0 should yield nil, got %+v", got)
1125- }
1126-}
1127-
1128-func TestDefaultHistoryPolicy(t *testing.T) {
1129- p := DefaultHistoryPolicy()
1130- if p.MaxTurns <= 0 || p.PromptCharBudget <= 0 {
1131- t.Fatalf("policy not sane: %+v", p)
1132- }
1133- if p.PromptCharBudget != maxContextTokens*charsPerTokenEstimate {
1134- t.Errorf("PromptCharBudget = %d, want derived from context", p.PromptCharBudget)
1135- }
1136-}
1137diff --git a/internal/llm/prompt_test.go b/internal/llm/prompt_test.go
1138deleted file mode 100644
1139index 29935d6e4f291af32773386958bd3a1a583b4efe..0000000000000000000000000000000000000000
1140--- a/internal/llm/prompt_test.go
1141+++ /dev/null
1142@@ -1,77 +0,0 @@
1143-package llm
1144-
1145-import (
1146- "strings"
1147- "testing"
1148-)
1149-
1150-func TestBuildNPCMessagesStructure(t *testing.T) {
1151- policy := HistoryPolicy{MaxTurns: 2, PromptCharBudget: 1_000_000}
1152- prior := []Turn{turn("u0", "a0"), turn("u1", "a1")}
1153- msgs := BuildNPCMessages("a ramen chef", "ordering at a counter", "transcript line", prior, policy)
1154-
1155- if msgs[0].Role != RoleSystem {
1156- t.Fatalf("first message should be system, got %s", msgs[0].Role)
1157- }
1158- if !strings.Contains(msgs[0].Content, "a ramen chef") || !strings.Contains(msgs[0].Content, "ordering at a counter") {
1159- t.Errorf("system message missing persona/situation: %q", msgs[0].Content)
1160- }
1161- last := msgs[len(msgs)-1]
1162- if last.Role != RoleUser || last.Content != "transcript line" {
1163- t.Fatalf("last message should be the transcript, got %+v", last)
1164- }
1165- // system + 2 turns * 2 + transcript = 6
1166- if len(msgs) != 6 {
1167- t.Fatalf("got %d messages, want 6: %+v", len(msgs), msgs)
1168- }
1169-}
1170-
1171-func TestBuildNPCMessagesBoundedHistory(t *testing.T) {
1172- policy := HistoryPolicy{MaxTurns: 1, PromptCharBudget: 1_000_000}
1173- prior := []Turn{turn("u0", "a0"), turn("u1", "a1")}
1174- msgs := BuildNPCMessages("p", "s", "now", prior, policy)
1175- // system + 1 turn * 2 + transcript = 4
1176- if len(msgs) != 4 {
1177- t.Fatalf("got %d messages, want 4 (bounded to 1 turn)", len(msgs))
1178- }
1179-}
1180-
1181-func TestBuildNPCMessagesNoJudgeLeak(t *testing.T) {
1182- msgs := BuildNPCMessages("p", "s", "spoken line", nil, DefaultHistoryPolicy())
1183- joined := ""
1184- for _, m := range msgs {
1185- joined += m.Content + "\n"
1186- }
1187- if strings.Contains(joined, "SCORE") || strings.Contains(joined, "FEEDBACK") {
1188- t.Errorf("judge fields leaked into NPC prompt: %q", joined)
1189- }
1190-}
1191-
1192-func TestBuildJudgeMessagesStructure(t *testing.T) {
1193- msgs := BuildJudgeMessages("at a station ticket window", "raw japanese line")
1194- if len(msgs) != 2 {
1195- t.Fatalf("got %d messages, want 2", len(msgs))
1196- }
1197- if msgs[0].Role != RoleSystem || msgs[1].Role != RoleUser {
1198- t.Fatalf("roles should be system then user: %+v", msgs)
1199- }
1200- if !strings.Contains(msgs[1].Content, "raw japanese line") {
1201- t.Errorf("judge user message missing transcript: %q", msgs[1].Content)
1202- }
1203- if strings.Contains(msgs[0].Content, "persona") || strings.Contains(strings.ToLower(msgs[0].Content), "npc") {
1204- t.Errorf("judge prompt should be persona-free: %q", msgs[0].Content)
1205- }
1206-}
1207-
1208-func TestBuildJudgeMessagesInstructsRomajiTranscription(t *testing.T) {
1209- msgs := BuildJudgeMessages("at a station ticket window", "raw japanese line")
1210- sys := msgs[0].Content
1211- for _, field := range []string{"SCORE|", "ROMAJI|", "FEEDBACK|"} {
1212- if !strings.Contains(sys, field) {
1213- t.Errorf("judge system should fix the %s field: %q", field, sys)
1214- }
1215- }
1216- if !strings.Contains(strings.ToLower(sys), "hepburn romaji") {
1217- t.Errorf("judge system should instruct Hepburn romaji transcription: %q", sys)
1218- }
1219-}
1220diff --git a/internal/persona/draft_test.go b/internal/persona/draft_test.go
1221deleted file mode 100644
1222index 2c86a608582f64fcb44042939903c02bcb92485d..0000000000000000000000000000000000000000
1223--- a/internal/persona/draft_test.go
1224+++ /dev/null
1225@@ -1,120 +0,0 @@
1226-package persona
1227-
1228-import (
1229- "context"
1230- "encoding/json"
1231- "errors"
1232- "fmt"
1233- "net/http"
1234- "net/http/httptest"
1235- "strings"
1236- "testing"
1237-
1238- "japanese/internal/llm"
1239-)
1240-
1241-func TestParseDraftSuccess(t *testing.T) {
1242- raw := "NAME|Kenji\nPERSONALITY|calm, careful\nSPEECH_STYLE|short plain sentences"
1243- got, err := ParseDraft(raw)
1244- if err != nil {
1245- t.Fatalf("ParseDraft: %v", err)
1246- }
1247- if got.Name != "Kenji" || got.Personality != "calm, careful" || got.SpeechStyle != "short plain sentences" {
1248- t.Errorf("unexpected draft: %+v", got)
1249- }
1250-}
1251-
1252-func TestParseDraftFailure(t *testing.T) {
1253- cases := map[string]string{
1254- "missing field": "NAME|Kenji\nSPEECH_STYLE|x",
1255- "empty field": "NAME|\nPERSONALITY|p\nSPEECH_STYLE|x",
1256- "malformed line": "NAME Kenji\nPERSONALITY|p\nSPEECH_STYLE|x",
1257- "duplicate field": "NAME|a\nNAME|b\nPERSONALITY|p\nSPEECH_STYLE|x",
1258- }
1259- for name, raw := range cases {
1260- if _, err := ParseDraft(raw); err == nil {
1261- t.Errorf("%s: expected error", name)
1262- } else {
1263- var ce *llm.ContractError
1264- if !errors.As(err, &ce) {
1265- t.Errorf("%s: expected *llm.ContractError, got %T (%v)", name, err, err)
1266- }
1267- }
1268- }
1269-}
1270-
1271-func TestDraftPromptHasNoGameContext(t *testing.T) {
1272- for _, s := range []string{
1273- DraftSystemPrompt(),
1274- DraftUserPrompt("a ramen shop cook"),
1275- DraftUserPrompt("a train station ticket clerk"),
1276- } {
1277- if gameContextWord.MatchString(s) {
1278- t.Errorf("prompt mentions game context: %q", s)
1279- }
1280- }
1281-}
1282-
1283-func TestGenerateDraftRoundTrip(t *testing.T) {
1284- var gotBody []byte
1285- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1286- buf := make([]byte, 4096)
1287- n, _ := r.Body.Read(buf)
1288- gotBody = buf[:n]
1289- w.Header().Set("Content-Type", "text/event-stream")
1290- fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"NAME|Kenji\\n\"}}]}\n\n")
1291- fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"PERSONALITY|calm, careful\\n\"}}]}\n\n")
1292- fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"SPEECH_STYLE|short plain sentences\"}}]}\n\n")
1293- fmt.Fprint(w, "data: [DONE]\n\n")
1294- }))
1295- defer srv.Close()
1296-
1297- client := llm.NewClient(srv.URL, srv.Client())
1298- got, err := GenerateDraft(context.Background(), client, "a ramen shop cook")
1299- if err != nil {
1300- t.Fatalf("GenerateDraft: %v", err)
1301- }
1302- if got.Name != "Kenji" || got.Personality != "calm, careful" || got.SpeechStyle != "short plain sentences" {
1303- t.Errorf("unexpected draft: %+v", got)
1304- }
1305-
1306- var req struct {
1307- Model string `json:"model"`
1308- ChatTemplateKwargs map[string]any `json:"chat_template_kwargs"`
1309- Messages []struct {
1310- Role string `json:"role"`
1311- Content string `json:"content"`
1312- } `json:"messages"`
1313- }
1314- if err := json.Unmarshal(gotBody, &req); err != nil {
1315- t.Fatalf("request not JSON: %v", err)
1316- }
1317- if req.Model != "jp" {
1318- t.Errorf("model = %q, want jp", req.Model)
1319- }
1320- if req.ChatTemplateKwargs["enable_thinking"] != false {
1321- t.Errorf("enable_thinking should be false")
1322- }
1323- var joined strings.Builder
1324- for _, m := range req.Messages {
1325diff --git a/internal/persona/persona_test.go b/internal/persona/persona_test.go
1326deleted file mode 100644
1327index 46e4ce6d9030b21f54fd4aca1b2ea977299c38bf..0000000000000000000000000000000000000000
1328--- a/internal/persona/persona_test.go
1329+++ /dev/null
1330@@ -1,142 +0,0 @@
1331-package persona
1332-
1333-import (
1334- "os"
1335- "path/filepath"
1336- "strings"
1337- "testing"
1338-)
1339-
1340-func validPersonaJSON() string {
1341- return `{
1342- "name": "Kenji",
1343- "location": "ramen-shop",
1344- "personality": "tired, blunt, focused on the pot",
1345- "speech_style": "short, plain sentences",
1346- "situation": "You are a cook at a small ramen counter in the evening.",
1347- "ambiguity": { "mode": "carry-on", "note": "Carries on after minor mistakes." }
1348- }`
1349-}
1350-
1351-func TestParseValidPersona(t *testing.T) {
1352- p, err := Parse([]byte(validPersonaJSON()))
1353- if err != nil {
1354- t.Fatalf("Parse: %v", err)
1355- }
1356- if p.Name != "Kenji" || p.Location != "ramen-shop" {
1357- t.Errorf("unexpected persona: %+v", p)
1358- }
1359- if p.Ambiguity.Mode != CarryOn {
1360- t.Errorf("mode = %q, want carry-on", p.Ambiguity.Mode)
1361- }
1362-}
1363-
1364-func TestParseRejectsEmptyRequired(t *testing.T) {
1365- // empty name
1366- if _, err := Parse([]byte(`{"name":"","location":"x","personality":"p","speech_style":"s","situation":"z","ambiguity":{"mode":"carry-on","note":"n"}}`)); err == nil {
1367- t.Errorf("empty name should fail")
1368- }
1369- if _, err := Parse([]byte(`{"name":"K","location":"","personality":"p","speech_style":"s","situation":"z","ambiguity":{"mode":"carry-on","note":"n"}}`)); err == nil {
1370- t.Errorf("empty location should fail")
1371- }
1372- if _, err := Parse([]byte(`{"name":"K","location":"x","personality":"","speech_style":"s","situation":"z","ambiguity":{"mode":"carry-on","note":"n"}}`)); err == nil {
1373- t.Errorf("empty personality should fail")
1374- }
1375- if _, err := Parse([]byte(`{"name":"K","location":"x","personality":"p","speech_style":"","situation":"z","ambiguity":{"mode":"carry-on","note":"n"}}`)); err == nil {
1376- t.Errorf("empty speech_style should fail")
1377- }
1378- if _, err := Parse([]byte(`{"name":"K","location":"x","personality":"p","speech_style":"s","situation":"","ambiguity":{"mode":"carry-on","note":"n"}}`)); err == nil {
1379- t.Errorf("empty situation should fail")
1380- }
1381- if _, err := Parse([]byte(`{"name":"K","location":"x","personality":"p","speech_style":"s","situation":"z","ambiguity":{"mode":"carry-on","note":""}}`)); err == nil {
1382- t.Errorf("empty ambiguity.note should fail")
1383- }
1384-}
1385-
1386-func TestParseRejectsJapaneseName(t *testing.T) {
1387- if _, err := Parse([]byte(`{"name":"健司","location":"x","personality":"p","speech_style":"s","situation":"z","ambiguity":{"mode":"carry-on","note":"n"}}`)); err == nil {
1388- t.Errorf("kanji name should fail")
1389- }
1390- if _, err := Parse([]byte(`{"name":"ケンジ","location":"x","personality":"p","speech_style":"s","situation":"z","ambiguity":{"mode":"carry-on","note":"n"}}`)); err == nil {
1391- t.Errorf("katakana name should fail")
1392- }
1393-}
1394-
1395-func TestParseRejectsUnknownField(t *testing.T) {
1396- if _, err := Parse([]byte(`{"name":"K","location":"x","personality":"p","speech_style":"s","situation":"z","ambiguity":{"mode":"carry-on","note":"n"},"extra":1}`)); err == nil {
1397- t.Errorf("unknown field should fail")
1398- }
1399-}
1400-
1401-func TestParseRejectsBadAmbiguityMode(t *testing.T) {
1402- if _, err := Parse([]byte(`{"name":"K","location":"x","personality":"p","speech_style":"s","situation":"z","ambiguity":{"mode":"teach","note":"n"}}`)); err == nil {
1403- t.Errorf("unknown ambiguity mode should fail")
1404- }
1405-}
1406-
1407-func TestParseRejectsGameContext(t *testing.T) {
1408- bad := `{"name":"K","location":"x","personality":"p","speech_style":"s","situation":"You are an NPC in a game, on a quest.","ambiguity":{"mode":"carry-on","note":"n"}}`
1409- if _, err := Parse([]byte(bad)); err == nil {
1410- t.Errorf("game-context situation should fail")
1411- }
1412- // "question" must not trip the "quest" check
1413- ok := `{"name":"K","location":"x","personality":"p","speech_style":"s","situation":"A visitor asks a question about the town.","ambiguity":{"mode":"carry-on","note":"n"}}`
1414- if _, err := Parse([]byte(ok)); err != nil {
1415- t.Errorf("word 'question' should not be flagged, got: %v", err)
1416- }
1417-}
1418-
1419-func TestLoadDir(t *testing.T) {
1420- dir := t.TempDir()
1421- mustWrite := func(name, body string) {
1422- if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
1423- t.Fatalf("write fixture: %v", err)
1424- }
1425- }
1426- mustWrite("a.json", `{"name":"K","location":"ramen-shop","personality":"p","speech_style":"s","situation":"z","ambiguity":{"mode":"carry-on","note":"n"}}`)
1427- mustWrite("b.json", `{"name":"S","location":"station","personality":"p","speech_style":"s","situation":"z","ambiguity":{"mode":"confirm","note":"n"}}`)
1428-
1429- got, err := LoadDir(dir)
1430diff --git a/internal/stt/recorder_test.go b/internal/stt/recorder_test.go
1431deleted file mode 100644
1432index ad80c9ea4e67ecd6229698a0a949b114f1f66e96..0000000000000000000000000000000000000000
1433--- a/internal/stt/recorder_test.go
1434+++ /dev/null
1435@@ -1,122 +0,0 @@
1436-package stt
1437-
1438-import (
1439- "context"
1440- "os"
1441- "path/filepath"
1442- "strings"
1443- "testing"
1444- "time"
1445-)
1446-
1447-func writeScript(t *testing.T, body string) string {
1448- t.Helper()
1449- dir := t.TempDir()
1450- p := filepath.Join(dir, "rec.sh")
1451- if err := os.WriteFile(p, []byte(body), 0o755); err != nil {
1452- t.Fatalf("write script: %v", err)
1453- }
1454- return p
1455-}
1456-
1457-func TestRecorderAppendsTempPath(t *testing.T) {
1458- dir := t.TempDir()
1459- argsOut := filepath.Join(dir, "args.txt")
1460- // The script records its first argument (the temp WAV path) and then stays
1461- // alive briefly so Stop exercises a graceful stop of a live process.
1462- script := writeScript(t, "#!/bin/sh\necho \"$1\" > \""+argsOut+"\"\nsleep 2\n")
1463-
1464- rec := NewRecorder(script, 10*time.Second)
1465- r, err := rec.Start(context.Background())
1466- if err != nil {
1467- t.Fatalf("Start: %v", err)
1468- }
1469-
1470- // Wait for the script to record its argument before we stop it.
1471- var gotArgs []byte
1472- for i := 0; i < 150; i++ {
1473- if gotArgs, err = os.ReadFile(argsOut); err == nil && len(strings.TrimSpace(string(gotArgs))) > 0 {
1474- break
1475- }
1476- time.Sleep(20 * time.Millisecond)
1477- }
1478- path, err := r.Stop()
1479- if err != nil {
1480- t.Fatalf("Stop: %v", err)
1481- }
1482- defer os.Remove(path)
1483-
1484- if strings.TrimSpace(string(gotArgs)) != path {
1485- t.Errorf("command received arg %q, want temp wav path %q", strings.TrimSpace(string(gotArgs)), path)
1486- }
1487- if _, err := os.Stat(path); err != nil {
1488- t.Fatalf("temp wav missing: %v", err)
1489- }
1490-}
1491-
1492-func TestRecorderGracefulStop(t *testing.T) {
1493- rec := NewRecorder("sleep 5", 10*time.Second)
1494- r, err := rec.Start(context.Background())
1495- if err != nil {
1496- t.Fatalf("Start: %v", err)
1497- }
1498- start := time.Now()
1499- path, err := r.Stop()
1500- elapsed := time.Since(start)
1501- if err != nil {
1502- t.Fatalf("Stop: %v", err)
1503- }
1504- defer os.Remove(path)
1505- if elapsed > 2*time.Second {
1506- t.Fatalf("Stop took %v; expected a quick graceful stop", elapsed)
1507- }
1508- if _, err := os.Stat(path); err != nil {
1509- t.Fatalf("temp wav missing: %v", err)
1510- }
1511-}
1512-
1513-func TestRecorderMaxDuration(t *testing.T) {
1514- rec := NewRecorder("sleep 5", 100*time.Millisecond)
1515- r, err := rec.Start(context.Background())
1516- if err != nil {
1517- t.Fatalf("Start: %v", err)
1518- }
1519- select {
1520- case <-r.Done():
1521- case <-time.After(3 * time.Second):
1522- t.Fatal("recording was not stopped by the max duration")
1523- }
1524- path, err := r.Stop()
1525- if err != nil {
1526- t.Fatalf("Stop: %v", err)
1527- }
1528- defer os.Remove(path)
1529- if _, err := os.Stat(path); err != nil {
1530- t.Fatalf("temp wav missing: %v", err)
1531- }
1532-}
1533-
1534-func TestRecorderContextCancelStops(t *testing.T) {
1535diff --git a/internal/stt/whisper_test.go b/internal/stt/whisper_test.go
1536deleted file mode 100644
1537index e672b0b72873ec8a3a9a6c53df7fb13ce65794a3..0000000000000000000000000000000000000000
1538--- a/internal/stt/whisper_test.go
1539+++ /dev/null
1540@@ -1,113 +0,0 @@
1541-package stt
1542-
1543-import (
1544- "context"
1545- "io"
1546- "net/http"
1547- "net/http/httptest"
1548- "os"
1549- "path/filepath"
1550- "strings"
1551- "testing"
1552-)
1553-
1554-func TestWhisperTranscribeMultipart(t *testing.T) {
1555- var gotFilename string
1556- var gotFileBytes []byte
1557- var gotLanguage string
1558-
1559- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1560- if err := r.ParseMultipartForm(1 << 20); err != nil {
1561- t.Errorf("parse multipart: %v", err)
1562- http.Error(w, "bad request", http.StatusBadRequest)
1563- return
1564- }
1565- fh, hdr, err := r.FormFile("file")
1566- if err != nil {
1567- t.Errorf("get file field: %v", err)
1568- http.Error(w, "no file", http.StatusBadRequest)
1569- return
1570- }
1571- gotFilename = hdr.Filename
1572- gotFileBytes, _ = io.ReadAll(fh)
1573- fh.Close()
1574- gotLanguage = r.FormValue("language")
1575- w.Header().Set("Content-Type", "application/json")
1576- io.WriteString(w, `{"text":"hello"}`)
1577- }))
1578- defer srv.Close()
1579-
1580- dir := t.TempDir()
1581- wav := filepath.Join(dir, "rec.wav")
1582- content := []byte("RIFF-fake-wav-bytes")
1583- if err := os.WriteFile(wav, content, 0o644); err != nil {
1584- t.Fatalf("write wav: %v", err)
1585- }
1586-
1587- c := NewWhisperClient(srv.URL, "ja", srv.Client())
1588- text, err := c.Transcribe(context.Background(), wav)
1589- if err != nil {
1590- t.Fatalf("Transcribe: %v", err)
1591- }
1592- if text != "hello" {
1593- t.Errorf("text = %q, want hello", text)
1594- }
1595- if gotFilename != "recording.wav" {
1596- t.Errorf("file filename = %q, want recording.wav", gotFilename)
1597- }
1598- if string(gotFileBytes) != string(content) {
1599- t.Errorf("uploaded file bytes = %q, want %q", gotFileBytes, content)
1600- }
1601- if gotLanguage != "ja" {
1602- t.Errorf("language field = %q, want ja", gotLanguage)
1603- }
1604- if _, err := os.Stat(wav); !os.IsNotExist(err) {
1605- t.Errorf("temp wav should be removed after upload; stat err=%v", err)
1606- }
1607-}
1608-
1609-func TestWhisperTranscribeOmitsEmptyLanguage(t *testing.T) {
1610- var gotLanguage string
1611- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1612- _ = r.ParseMultipartForm(1 << 20)
1613- gotLanguage = r.FormValue("language")
1614- w.Header().Set("Content-Type", "application/json")
1615- io.WriteString(w, `{"text":"ok"}`)
1616- }))
1617- defer srv.Close()
1618-
1619- dir := t.TempDir()
1620- wav := filepath.Join(dir, "rec.wav")
1621- if err := os.WriteFile(wav, []byte("x"), 0o644); err != nil {
1622- t.Fatalf("write wav: %v", err)
1623- }
1624-
1625- c := NewWhisperClient(srv.URL, "", srv.Client())
1626- if _, err := c.Transcribe(context.Background(), wav); err != nil {
1627- t.Fatalf("Transcribe: %v", err)
1628- }
1629- if gotLanguage != "" {
1630- t.Errorf("language field = %q, want absent/empty", gotLanguage)
1631- }
1632-}
1633-
1634-func TestWhisperTranscribeHTTPError(t *testing.T) {
1635- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1636- http.Error(w, "boom", http.StatusInternalServerError)
1637- }))
1638- defer srv.Close()
1639-
1640diff --git a/internal/tts/client_test.go b/internal/tts/client_test.go
1641deleted file mode 100644
1642index 3b664a86a85fae2527cfa1aa614eee0547d18dd7..0000000000000000000000000000000000000000
1643--- a/internal/tts/client_test.go
1644+++ /dev/null
1645@@ -1,89 +0,0 @@
1646-package tts
1647-
1648-import (
1649- "context"
1650- "encoding/json"
1651- "io"
1652- "net/http"
1653- "net/http/httptest"
1654- "strings"
1655- "testing"
1656-)
1657-
1658-func TestSpeechRequestAndResponse(t *testing.T) {
1659- respWAV := makeWAV(t, 24000, 1, 16, []byte{9, 8, 7})
1660-
1661- var gotPath, gotContentType string
1662- var gotReq speechRequest
1663-
1664- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1665- gotPath = r.URL.Path
1666- gotContentType = r.Header.Get("Content-Type")
1667- body, _ := io.ReadAll(r.Body)
1668- _ = json.Unmarshal(body, &gotReq)
1669- w.Write(respWAV)
1670- }))
1671- defer srv.Close()
1672-
1673- c := NewClient(srv.URL, srv.Client())
1674- got, err := c.Speech(context.Background(), "こんにちは")
1675- if err != nil {
1676- t.Fatalf("Speech: %v", err)
1677- }
1678-
1679- if string(got) != string(respWAV) {
1680- t.Errorf("response bytes = %d bytes, want the WAV (%d bytes)", len(got), len(respWAV))
1681- }
1682- if gotPath != "/audio/speech" {
1683- t.Errorf("path = %q, want /audio/speech", gotPath)
1684- }
1685- if !strings.Contains(gotContentType, "application/json") {
1686- t.Errorf("content-type = %q, want application/json", gotContentType)
1687- }
1688- if gotReq.Model != modelName {
1689- t.Errorf("model = %q, want %q", gotReq.Model, modelName)
1690- }
1691- if gotReq.Input != "こんにちは" {
1692- t.Errorf("input = %q, want こんにちは", gotReq.Input)
1693- }
1694- if gotReq.Voice != defaultVoice {
1695- t.Errorf("voice = %q, want %q", gotReq.Voice, defaultVoice)
1696- }
1697- if gotReq.Language != ttsLanguage {
1698- t.Errorf("language = %q, want %q", gotReq.Language, ttsLanguage)
1699- }
1700- if gotReq.Seed != speechSeed {
1701- t.Errorf("seed = %d, want %d", gotReq.Seed, speechSeed)
1702- }
1703- if gotReq.ResponseFormat != "wav" {
1704- t.Errorf("response_format = %q, want wav", gotReq.ResponseFormat)
1705- }
1706- if !strings.Contains(gotReq.Instructions, "natural") {
1707- t.Errorf("instructions = %q, want it to mention natural pace", gotReq.Instructions)
1708- }
1709-}
1710-
1711-func TestSpeechHTTPError(t *testing.T) {
1712- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1713- http.Error(w, "boom", http.StatusInternalServerError)
1714- }))
1715- defer srv.Close()
1716-
1717- c := NewClient(srv.URL, srv.Client())
1718- if _, err := c.Speech(context.Background(), "あ"); err == nil || !strings.Contains(err.Error(), "500") {
1719- t.Errorf("expected HTTP 500 error, got %v", err)
1720- }
1721-}
1722-
1723-func TestSpeechRejectsNonWAV(t *testing.T) {
1724- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1725- w.Write([]byte("definitely not a wav file"))
1726- }))
1727- defer srv.Close()
1728-
1729- c := NewClient(srv.URL, srv.Client())
1730- _, err := c.Speech(context.Background(), "あ")
1731- if err == nil || !strings.Contains(err.Error(), srv.URL) {
1732- t.Errorf("expected a non-WAV error naming the URL %q, got %v", srv.URL, err)
1733- }
1734-}
1735diff --git a/internal/tts/player.go b/internal/tts/player.go
1736index c48ffaa47566cf05fe203bd3dd6cdb3861820b50..54eb85cd2b51c1001615245763e16f8b0d080e3c 100644
1737--- a/internal/tts/player.go
1738+++ b/internal/tts/player.go
1739@@ -4,34 +4,26 @@ import (
1740 "bytes"
1741 "context"
1742 "sync"
1743- "sync/atomic"
1744 "time"
1745
1746 "github.com/ebitengine/oto/v3"
1747 )
1748
1749-// nullPlayDuration is how long a null-output playback runs. It is short so tests
1750-// finish quickly while still being interruptible mid-flight.
1751-const nullPlayDuration = 40 * time.Millisecond
1752-
1753 // Player plays synthesized WAV bytes with oto/v3. The audio context is created
1754 // once, lazily, at the first clip's sample rate, channel count, and format; all
1755 // later clips are played through that same context (oto supports a single
1756 // context). Play validates the WAV synchronously and returns any error from
1757 // that check; device work happens off the calling goroutine so the TUI event
1758 // loop is never blocked. A new Play interrupts an in-flight playback, and Stop
1759-// interrupts one explicitly. With nullOutput set, playback runs without opening
1760-// an audio device.
1761+// interrupts one explicitly.
1762 //
1763 // Each Play takes an onDone callback that fires exactly once when that clip
1764 // finishes playing normally. An interrupted clip (a newer Play, or Stop) never
1765 // fires onDone, so callers can distinguish "played to the end" from "cut off".
1766 type Player struct {
1767- mu sync.Mutex
1768- ctx *oto.Context
1769- stopCur context.CancelFunc
1770- active int32 // atomic count of in-flight playbacks
1771- nullOutput bool
1772+ mu sync.Mutex
1773+ ctx *oto.Context
1774+ stopCur context.CancelFunc
1775 }
1776
1777 func NewPlayer() *Player { return &Player{} }
1778@@ -54,7 +46,6 @@ func (p *Player) Play(wav []byte, onDone func()) error {
1779 if prev != nil {
1780 prev() // interrupt any in-flight playback
1781 }
1782- atomic.AddInt32(&p.active, 1)
1783 go p.play(ctx, spec, onDone)
1784 return nil
1785 }
1786@@ -71,8 +62,6 @@ func (p *Player) Stop() {
1787 }
1788
1789 func (p *Player) play(ctx context.Context, spec *WAVSpec, onDone func()) {
1790- defer atomic.AddInt32(&p.active, -1)
1791-
1792 data := make([]byte, len(spec.Data))
1793 copy(data, spec.Data)
1794
1795@@ -82,16 +71,6 @@ func (p *Player) play(ctx context.Context, spec *WAVSpec, onDone func()) {
1796 }
1797 }
1798
1799- if p.nullOutput {
1800- select {
1801- case <-ctx.Done():
1802- return
1803- case <-time.After(nullPlayDuration):
1804- }
1805- finish()
1806- return
1807- }
1808-
1809 p.mu.Lock()
1810 if p.ctx == nil {
1811 opts := &oto.NewContextOptions{
1812@@ -125,8 +104,6 @@ func (p *Player) play(ctx context.Context, spec *WAVSpec, onDone func()) {
1813 finish()
1814 }
1815
1816-func (p *Player) isPlaying() bool { return atomic.LoadInt32(&p.active) > 0 }
1817-
1818 func formatForBits(bits int) oto.Format {
1819 if bits == 8 {
1820 return oto.FormatUnsignedInt8
1821diff --git a/internal/tts/player_test.go b/internal/tts/player_test.go
1822deleted file mode 100644
1823index 140a5fb5078adc0d2e9d5f328c984a2789f74a6c..0000000000000000000000000000000000000000
1824--- a/internal/tts/player_test.go
1825+++ /dev/null
1826@@ -1,87 +0,0 @@
1827-package tts
1828-
1829-import (
1830- "testing"
1831- "time"
1832-)
1833-
1834-func TestPlayRejectsInvalidWAV(t *testing.T) {
1835- p := NewPlayer()
1836- if err := p.Play([]byte("not a wav"), nil); err == nil {
1837- t.Error("expected an error for an invalid WAV, got nil")
1838- }
1839-}
1840-
1841-func TestPlayNullOutputNoDevice(t *testing.T) {
1842- p := &Player{nullOutput: true}
1843- wav := makeWAV(t, 24000, 1, 16, []byte{0, 1, 2, 3})
1844- if err := p.Play(wav, nil); err != nil {
1845- t.Fatalf("Play: %v", err)
1846- }
1847- waitForIdle(t, p)
1848-}
1849-
1850-func TestStopInterruptsPlayback(t *testing.T) {
1851- p := &Player{nullOutput: true}
1852- wav := makeWAV(t, 24000, 1, 16, []byte{0, 1, 2, 3})
1853- if err := p.Play(wav, nil); err != nil {
1854- t.Fatalf("Play: %v", err)
1855- }
1856- p.Stop()
1857- waitForIdle(t, p)
1858-}
1859-
1860-func TestNewPlaybackInterruptsPrevious(t *testing.T) {
1861- p := &Player{nullOutput: true}
1862- wav := makeWAV(t, 24000, 1, 16, []byte{0, 1, 2, 3})
1863- if err := p.Play(wav, nil); err != nil {
1864- t.Fatalf("Play first: %v", err)
1865- }
1866- if err := p.Play(wav, nil); err != nil {
1867- t.Fatalf("Play second: %v", err)
1868- }
1869- waitForIdle(t, p)
1870-}
1871-
1872-func TestPlayCompletionFiresOnNullOutput(t *testing.T) {
1873- p := &Player{nullOutput: true}
1874- wav := makeWAV(t, 24000, 1, 16, []byte{0, 1, 2, 3})
1875- done := make(chan struct{}, 1)
1876- if err := p.Play(wav, func() { done <- struct{}{} }); err != nil {
1877- t.Fatalf("Play: %v", err)
1878- }
1879- select {
1880- case <-done:
1881- case <-time.After(2 * time.Second):
1882- t.Fatal("completion did not fire for a finished clip")
1883- }
1884- waitForIdle(t, p)
1885-}
1886-
1887-func TestInterruptedPlayDoesNotFireCompletion(t *testing.T) {
1888- p := &Player{nullOutput: true}
1889- wav := makeWAV(t, 24000, 1, 16, []byte{0, 1, 2, 3})
1890- done := make(chan struct{}, 1)
1891- if err := p.Play(wav, func() { done <- struct{}{} }); err != nil {
1892- t.Fatalf("Play: %v", err)
1893- }
1894- p.Stop()
1895- time.Sleep(50 * time.Millisecond) // let the play goroutine observe the cancel
1896- select {
1897- case <-done:
1898- t.Fatal("interrupted playback fired a normal completion")
1899- default:
1900- }
1901- waitForIdle(t, p)
1902-}
1903-
1904-func waitForIdle(t *testing.T, p *Player) {
1905- t.Helper()
1906- deadline := time.Now().Add(2 * time.Second)
1907- for p.isPlaying() {
1908- if time.Now().After(deadline) {
1909- t.Fatal("timed out waiting for playback to finish")
1910- }
1911- time.Sleep(5 * time.Millisecond)
1912- }
1913-}
1914diff --git a/internal/tts/wav.go b/internal/tts/wav.go
1915index bc30347cbbfa5253b6c5315674f04f0c45048db8..f81f4202bbbec905cdf9eb4bf1eca4114754417e 100644
1916--- a/internal/tts/wav.go
1917+++ b/internal/tts/wav.go
1918@@ -93,9 +93,3 @@ func ParseWAV(b []byte) (*WAVSpec, error) {
1919 Data: b[dataOffset : dataOffset+dataSize],
1920 }, nil
1921 }
1922-
1923-// ValidateWAV reports whether b is a playable PCM WAV file.
1924-func ValidateWAV(b []byte) error {
1925- _, err := ParseWAV(b)
1926- return err
1927-}
1928diff --git a/internal/tts/wav_test.go b/internal/tts/wav_test.go
1929deleted file mode 100644
1930index a187058028edb21743e0b70c81d3b6f5e7bc98c4..0000000000000000000000000000000000000000
1931--- a/internal/tts/wav_test.go
1932+++ /dev/null
1933@@ -1,79 +0,0 @@
1934-package tts
1935-
1936-import (
1937- "bytes"
1938- "encoding/binary"
1939- "testing"
1940-)
1941-
1942-// makeWAV builds a canonical 44-byte-header PCM WAV file.
1943-func makeWAV(t *testing.T, sampleRate, channels, bits int, payload []byte) []byte {
1944- t.Helper()
1945- blockAlign := channels * (bits / 8)
1946- byteRate := sampleRate * blockAlign
1947-
1948- var fmtBody [16]byte
1949- binary.LittleEndian.PutUint16(fmtBody[0:2], 1) // PCM
1950- binary.LittleEndian.PutUint16(fmtBody[2:4], uint16(channels))
1951- binary.LittleEndian.PutUint32(fmtBody[4:8], uint32(sampleRate))
1952- binary.LittleEndian.PutUint32(fmtBody[8:12], uint32(byteRate))
1953- binary.LittleEndian.PutUint16(fmtBody[12:14], uint16(blockAlign))
1954- binary.LittleEndian.PutUint16(fmtBody[14:16], uint16(bits))
1955-
1956- var out bytes.Buffer
1957- out.WriteString("RIFF")
1958- var riffSize [4]byte
1959- binary.LittleEndian.PutUint32(riffSize[:], uint32(36+len(payload)))
1960- out.Write(riffSize[:])
1961- out.WriteString("WAVE")
1962- out.WriteString("fmt ")
1963- var fmtSize [4]byte
1964- binary.LittleEndian.PutUint32(fmtSize[:], 16)
1965- out.Write(fmtSize[:])
1966- out.Write(fmtBody[:])
1967- out.WriteString("data")
1968- var dataSize [4]byte
1969- binary.LittleEndian.PutUint32(dataSize[:], uint32(len(payload)))
1970- out.Write(dataSize[:])
1971- out.Write(payload)
1972- return out.Bytes()
1973-}
1974-
1975-func TestParseWAVValid(t *testing.T) {
1976- payload := []byte{0x00, 0x64, 0x9C, 0xFF, 0x32, 0x00, 0xFE, 0xCA}
1977- wav := makeWAV(t, 24000, 1, 16, payload)
1978-
1979- spec, err := ParseWAV(wav)
1980- if err != nil {
1981- t.Fatalf("ParseWAV: %v", err)
1982- }
1983- if spec.SampleRate != 24000 || spec.Channels != 1 || spec.BitsPerSample != 16 {
1984- t.Errorf("spec = rate=%d ch=%d bits=%d, want 24000/1/16", spec.SampleRate, spec.Channels, spec.BitsPerSample)
1985- }
1986- if !bytes.Equal(spec.Data, payload) {
1987- t.Errorf("Data = %v, want %v", spec.Data, payload)
1988- }
1989-}
1990-
1991-func TestValidateWAVInvalid(t *testing.T) {
1992- notWaveForm := makeWAV(t, 24000, 1, 16, []byte{1, 2, 3, 4})
1993- notWaveForm[8] = 'X'
1994-
1995- cases := map[string][]byte{
1996- "empty": {},
1997- "too_short": []byte("RIFFWAVE1234"),
1998- "bad_magic": append([]byte("NOTARIFF"), make([]byte, 40)...),
1999- "not_wave_form": notWaveForm,
2000- }
2001-
2002- for name, b := range cases {
2003- if err := ValidateWAV(b); err == nil {
2004- t.Errorf("%s: expected error, got nil", name)
2005- }
2006- }
2007-
2008- // unsupported bit depth (24-bit) must be rejected
2009- if err := ValidateWAV(makeWAV(t, 24000, 1, 24, []byte{1})); err == nil {
2010- t.Error("24-bit: expected error, got nil")
2011- }
2012-}
2013diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go
2014deleted file mode 100644
2015index 306b4547c6c506ace93bae0d596728ef9b3ba87a..0000000000000000000000000000000000000000
2016--- a/internal/ui/app_test.go
2017+++ /dev/null
2018@@ -1,269 +0,0 @@
2019-package ui
2020-
2021-import (
2022- "context"
2023- "errors"
2024- "strings"
2025- "testing"
2026-
2027- "github.com/charmbracelet/bubbletea"
2028-
2029- "japanese/internal/availability"
2030- "japanese/internal/game"
2031- "japanese/internal/llm"
2032-)
2033-
2034-const testMapJSON = `{"name":"t","rows":["###","#A.","###"],"start":{"row":1,"col":1},"locations":[{"id":"ramen","marker":"A","label":"Ramen"}]}`
2035-
2036-func testState(t *testing.T) *game.State {
2037- t.Helper()
2038- m, err := game.ParseMap([]byte(testMapJSON))
2039- if err != nil {
2040- t.Fatalf("parse map: %v", err)
2041- }
2042- return game.NewState(m)
2043-}
2044-
2045-type fakeSpeech struct{}
2046-
2047-func (fakeSpeech) Begin(ctx context.Context) error { return nil }
2048-func (fakeSpeech) End(ctx context.Context) (string, error) {
2049- return "ra", nil
2050-}
2051-
2052-type fakeNPC struct{}
2053-
2054-func (fakeNPC) Reply(ctx context.Context, req game.NPCRequest) (llm.NPCReply, error) {
2055- return llm.NPCReply{Romaji: "romaji-reply", Kana: "kana", English: "english-reply"}, nil
2056-}
2057-
2058-type fakeJudge struct{}
2059-
2060-func (fakeJudge) Judge(ctx context.Context, req game.JudgeRequest) (llm.JudgeResult, error) {
2061- return llm.JudgeResult{Score: 82, Romaji: "ra", Feedback: "fine romaji feedback"}, nil
2062-}
2063-
2064-type fakeOut struct{}
2065-
2066-func (fakeOut) Speak(ctx context.Context, kana string) error { return nil }
2067-
2068-func buildModel(t *testing.T) (*model, *game.State) {
2069- t.Helper()
2070- st := testState(t)
2071- orch := game.NewOrchestrator(
2072- st,
2073- map[string]game.PersonaView{"ramen": {Description: "d", Situation: "s"}},
2074- fakeSpeech{}, fakeNPC{}, fakeJudge{}, fakeOut{},
2075- )
2076- m := NewModel(st, orch, nil, nil, 10)
2077- return m, st
2078-}
2079-
2080-func spaceKey() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} }
2081-
2082-func TestToggleStateMachine(t *testing.T) {
2083- m, _ := buildModel(t)
2084-
2085- if m.phase != phaseIdle {
2086- t.Fatalf("initial phase = %v, want idle", m.phase)
2087- }
2088-
2089- // idle -> recording
2090- if _, cmd := m.Update(spaceKey()); m.phase != phaseRecording || cmd == nil {
2091- t.Fatalf("after space: phase=%v cmd=%v; want recording + worker cmd", m.phase, cmd == nil)
2092- }
2093-
2094- // begin succeeds: stay recording
2095- if _, _ = m.Update(beginResultMsg{err: nil}); m.phase != phaseRecording {
2096- t.Fatalf("after begin ok: phase=%v; want recording", m.phase)
2097- }
2098-
2099- // recording -> processing
2100- if _, cmd := m.Update(spaceKey()); m.phase != phaseProcessing || cmd == nil {
2101- t.Fatalf("after 2nd space: phase=%v cmd=%v; want processing + worker cmd", m.phase, cmd == nil)
2102- }
2103-
2104- // finish result -> idle
2105- res := game.TurnResult{Transcript: "raw", Judge: llm.JudgeResult{Score: 80, Romaji: "ra", Feedback: "ok"}, NPC: llm.NPCReply{English: "english-reply"}}
2106- if _, _ = m.Update(finishResultMsg{res: res}); m.phase != phaseIdle {
2107- t.Fatalf("after finish: phase=%v; want idle", m.phase)
2108- }
2109- if m.transcriptRomaji != "ra" {
2110- t.Fatalf("transcript = %q; want ra", m.transcriptRomaji)
2111- }
2112-}
2113-
2114-func TestToggleBeginFailureResetsToIdle(t *testing.T) {
2115- m, _ := buildModel(t)
2116- if _, _ = m.Update(spaceKey()); m.phase != phaseRecording {
2117- t.Fatalf("phase=%v; want recording", m.phase)