7ab20334c459704eabddd8e2c4dbb2ec16a4f717

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

remove: drop all tests and test-only code

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)