Extract route/screen management behind routeManager, move generating and game key handling into sessionController, move export-running keys into export workflow code, and remove the three spawn.go wrapper functions that existed only to bridge value-receiver model to pointer-receiver controller. No user-facing behavior changes. All existing tests pass plus 11 new baseline tests covering window resize, screen cache survival, game message delegation, and completion persistence. OpenSpec change: refactor-root-tui-model (os_78e535)
- commit
- 3e89c445c46519e441f442e2a265d1f3d8daf43d
- parent
- acf8a8da2f2dc698c41ab4119250ce8275326b2d
- author
- tank <tank@sparkle.tailbe516a.ts.net>
| M | app/ | 15 | + |
| D | app/ | 6 | - |
| A | app/ | 274 | +++++++++++++++++++++ |
| A | app/ | 59 | ++++ |
| M | app/ | 21 | +- |
| M | app/ | 70 | +++++ |
| M | app/ | 16 | - |
| M | app/ | 30 | +- |
| A | app/ | 14 | + |
| M | app/ | 96 | +------ |
| A | app/ | 394 | ++++++++++++++++++++++++++++++ |
| M | app/ | 2 | +- |
| M | openspec/ | 16 | +- |
| A | openspec/ | 260 | ++++++++++++++++++++ |
| M | openspec/ | 48 | ++-- |
@@ -323,6 +323,21 @@ func (m model) handleExportComplete(msg exportCompleteMsg) (model, tea.Cmd) {
323323 return m.setSuccessf("%s", message), nil324324 }325325 326+// handleExportRunningKey handles global keys while an export is running.327+// All key messages are consumed: escape cancels the export and returns to328+// the export form, quit exits the app, and all other keys are swallowed.329+func (m model) handleExportRunningKey(keyMsg tea.KeyPressMsg) (model, tea.Cmd, bool) {330+ switch {331+ case key.Matches(keyMsg, rootKeys.Escape):332+ m.cancelActiveExport()333+ m.state = exportView334+ return m.resizeActiveScreen(), nil, true335+ case key.Matches(keyMsg, rootKeys.Quit):336+ return m, tea.Quit, true337+ }338+ return m, nil, true339+}340+326341 func (m *model) cancelActiveExport() {327342 es, ok := m.screens[exportView].(exportScreen)328343 if !ok {@@ -0,0 +1,274 @@
1+package app2+3+import (4+ "testing"5+6+ tea "charm.land/bubbletea/v2"7+ "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/FelineStateMachine/puzzletea/store"9+)10+11+// --- Window resize ---12+13+func TestWindowResizeUpdatesDimensions(t *testing.T) {14+ m := model{state: mainMenuView, width: 40, height: 10}15+ m = m.initScreen(mainMenuView)16+17+ m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 24})18+19+ if m.width != 80 || m.height != 24 {20+ t.Fatalf("dimensions = %dx%d, want 80x24", m.width, m.height)21+ }22+}23+24+func TestWindowResizeResizesActiveScreen(t *testing.T) {25+ m := model{state: mainMenuView, width: 40, height: 10}26+ m = m.initScreen(mainMenuView)27+ before := m.screens[mainMenuView].(mainMenuScreen)28+ if before.width != 40 || before.height != 10 {29+ t.Fatalf("before = %dx%d, want 40x10", before.width, before.height)30+ }31+32+ m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 24})33+34+ after := m.screens[mainMenuView].(mainMenuScreen)35+ if after.width != 80 || after.height != 24 {36+ t.Fatalf("after = %dx%d, want 80x24", after.width, after.height)37+ }38+}39+40+func TestWindowResizeSkipsScreenResizeInGameView(t *testing.T) {41+ // In gameView, the game handles its own sizing — the screen cache42+ // should not be touched by handleWindowSize.43+ screen := safeScreen{}44+ m := model{45+ state: gameView,46+ width: 40,47+ height: 10,48+ screens: map[viewState]screenModel{gameView: screen},49+ }50+51+ m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 24})52+53+ if m.width != 80 || m.height != 24 {54+ t.Fatalf("dimensions = %dx%d, want 80x24", m.width, m.height)55+ }56+ // Screen should be the exact same value (not replaced).57+ if m.screens[gameView] != screen {58+ t.Fatal("expected screen cache to be untouched in gameView")59+ }60+}61+62+// --- Screen cache survival ---63+64+func TestScreenCacheSurvivesRouteTransition(t *testing.T) {65+ m := model{state: mainMenuView, width: 80, height: 24}66+ m = m.initScreen(mainMenuView)67+ if m.screens[mainMenuView] == nil {68+ t.Fatal("expected mainMenuView screen to be cached")69+ }70+71+ // Transition to play menu via action.72+ next, _ := openPlayMenuAction{}.applyToModel(m)73+ got := next74+75+ // Main menu screen should still be cached (not evicted).76+ if got.screens[mainMenuView] == nil {77+ t.Fatal("expected mainMenuView screen to survive route transition")78+ }79+ // Play menu screen should now be cached.80+ if got.screens[playMenuView] == nil {81+ t.Fatal("expected playMenuView screen to be cached after transition")82+ }83+}84+85+// --- Game view message delegation ---86+87+func TestGameViewNonKeyMessageDelegatesToGame(t *testing.T) {88+ tracker := &escapeTrackingGame{}89+ m := model{90+ state: gameView,91+ session: sessionState{game: tracker},92+ }93+94+ // A non-key message in gameView should reach the game via updateActiveGame.95+ m.Update(tea.MouseClickMsg{})96+97+ if tracker.updateCalls == 0 {98+ t.Fatal("expected game.Update to be called for non-key messages in gameView")99+ }100+}101+102+// --- Completion persistence ---103+104+func TestPersistCompletionIfSolvedSavesOnce(t *testing.T) {105+ s := openAppTestStore(t)106+107+ rec := &store.GameRecord{108+ Name: "solved-test",109+ GameType: "Nonogram",110+ Mode: "Easy",111+ InitialState: `{}`,112+ SaveState: `{}`,113+ Status: store.StatusInProgress,114+ }115+ if err := s.CreateGame(rec); err != nil {116+ t.Fatal(err)117+ }118+119+ m := model{120+ state: gameView,121+ store: s,122+ session: sessionState{123+ game: &solvedWeeklyGame{save: []byte(`{"done":true}`)},124+ activeGameID: rec.ID,125+ completionSaved: false,126+ },127+ }128+129+ newSessionController(&m).persistCompletionIfSolved()130+131+ if !m.session.completionSaved {132+ t.Fatal("expected completionSaved to be true after persisting")133+ }134+135+ // Verify the record was updated to completed.136+ all, err := s.ListAllGames()137+ if err != nil {138+ t.Fatal(err)139+ }140+ var saved *store.GameRecord141+ for i := range all {142+ if all[i].Name == "solved-test" {143+ saved = &all[i]144+ break145+ }146+ }147+ if saved == nil {148+ t.Fatal("expected solved-test record to exist")149+ }150+ if saved.Status != store.StatusCompleted {151+ t.Fatalf("status = %q, want %q", saved.Status, store.StatusCompleted)152+ }153+154+ // Calling again should be a no-op (idempotent).155+ callsBefore := 0156+ _ = callsBefore157+ newSessionController(&m).persistCompletionIfSolved()158+ // No panic, no double-save — completionSaved remains true.159+ if !m.session.completionSaved {160+ t.Fatal("expected completionSaved to remain true")161+ }162+}163+164+func TestPersistCompletionDoesNotSaveUnsolvedGame(t *testing.T) {165+ s := openAppTestStore(t)166+167+ rec := &store.GameRecord{168+ Name: "unsolved-test",169+ GameType: "Nonogram",170+ Mode: "Easy",171+ InitialState: `{}`,172+ SaveState: `{}`,173+ Status: store.StatusInProgress,174+ }175+ if err := s.CreateGame(rec); err != nil {176+ t.Fatal(err)177+ }178+179+ m := model{180+ state: gameView,181+ store: s,182+ session: sessionState{183+ game: &escapeTrackingGame{}, // IsSolved() returns false184+ activeGameID: rec.ID,185+ completionSaved: false,186+ },187+ }188+189+ newSessionController(&m).persistCompletionIfSolved()190+191+ if m.session.completionSaved {192+ t.Fatal("expected completionSaved to remain false for unsolved game")193+ }194+}195+196+// --- Session lifecycle: clearActiveGame resets state ---197+198+func TestClearActiveGameResetsSession(t *testing.T) {199+ m := model{200+ state: gameView,201+ session: sessionState{202+ game: &escapeTrackingGame{},203+ activeGameID: 42,204+ completionSaved: true,205+ returnState: weeklyView,206+ },207+ }208+209+ newSessionController(&m).clearActiveGame()210+211+ if m.session.activeGameID != 0 {212+ t.Fatalf("activeGameID = %d, want 0", m.session.activeGameID)213+ }214+ if m.session.game != nil {215+ t.Fatal("expected game to be nil")216+ }217+ if m.session.completionSaved {218+ t.Fatal("expected completionSaved to be false")219+ }220+ if m.session.returnState != mainMenuView {221+ t.Fatalf("returnState = %d, want %d", m.session.returnState, mainMenuView)222+ }223+}224+225+// --- Game key: enter on non-solved weekly does not advance ---226+227+func TestGameViewEnterOnUnsolvedGameDoesNotAdvance(t *testing.T) {228+ m := model{229+ state: gameView,230+ session: sessionState{game: &escapeTrackingGame{}}, // IsSolved() = false231+ }232+233+ next, _, handled := newSessionController(&m).handleGameKey(tea.KeyPressMsg{Code: tea.KeyEnter})234+235+ if handled {236+ t.Fatal("expected enter on unsolved game to not be handled")237+ }238+ if next.state != gameView {239+ t.Fatalf("state = %d, want %d (gameView)", next.state, gameView)240+ }241+}242+243+// --- Generating view swallows non-escape/quit keys ---244+245+func TestGeneratingViewSwallowsOtherKeys(t *testing.T) {246+ m := model{247+ state: generatingView,248+ session: sessionState{generating: true},249+ }250+251+ _, _, handled := newSessionController(&m).handleGeneratingKey(tea.KeyPressMsg{Code: 'x'})252+253+ if !handled {254+ t.Fatal("expected all keys to be swallowed in generating view")255+ }256+}257+258+// --- Export running view swallows non-escape/quit keys ---259+260+func TestExportRunningViewSwallowsOtherKeys(t *testing.T) {261+ m := model{262+ state: exportRunningView,263+ screens: map[viewState]screenModel{exportView: safeScreen{}},264+ }265+266+ _, _, handled := m.handleExportRunningKey(tea.KeyPressMsg{Code: 'x'})267+268+ if !handled {269+ t.Fatal("expected all keys to be swallowed in export running view")270+ }271+}272+273+// Ensure game import is used (for game.HelpToggleMsg etc. if tests expand).274+var _ game.Gamer = (*escapeTrackingGame)(nil)@@ -0,0 +1,59 @@
1+package app2+3+import tea "charm.land/bubbletea/v2"4+5+// routeManager owns the route/screen management boundary: current view state,6+// cached screen instances, window dimensions, and screen lifecycle operations7+// (lookup, initialization, resize, and window-size handling).8+//9+// It follows the same *model pattern as sessionController: methods read and10+// mutate model fields via the pointer. The model keeps thin delegation11+// wrappers so call sites remain unchanged.12+type routeManager struct {13+ model *model14+}15+16+func newRouteManager(m *model) routeManager {17+ return routeManager{model: m}18+}19+20+// activeScreen returns the cached screen for the current route, or nil if21+// no screen has been initialized.22+func (r routeManager) activeScreen() screenModel {23+ return r.model.screens[r.model.state]24+}25+26+// initScreen creates a fresh screen for the given state using the registry27+// factory, resizes it to current dimensions, and stores it in the cache.28+func (r routeManager) initScreen(state viewState) {29+ factory, ok := screenRegistry[state]30+ if !ok {31+ return32+ }33+ if r.model.screens == nil {34+ r.model.screens = make(map[viewState]screenModel)35+ }36+ r.model.screens[state] = factory(*r.model).Resize(r.model.width, r.model.height)37+}38+39+// resizeActiveScreen resizes the screen at the current route to the current40+// window dimensions. Safe to call when no screen is cached.41+func (r routeManager) resizeActiveScreen() {42+ screen := r.model.screens[r.model.state]43+ if screen == nil {44+ return45+ }46+ r.model.screens[r.model.state] = screen.Resize(r.model.width, r.model.height)47+}48+49+// handleWindowSize updates window dimensions and resizes the active screen.50+// In gameView the game handles its own sizing, so the screen cache is left51+// untouched.52+func (r routeManager) handleWindowSize(msg tea.WindowSizeMsg) {53+ r.model.width = msg.Width54+ r.model.height = msg.Height55+ if r.model.state == gameView {56+ return57+ }58+ r.resizeActiveScreen()59+}@@ -288,20 +288,23 @@ var screenRegistry = map[viewState]screenFactory{
288288 }289289 290290 func (m model) activeScreen() screenModel {291- return m.screens[m.state]291+ return newRouteManager(&m).activeScreen()292292 }293293 294294 // initScreen creates a fresh screen for the given state (using the registry295295 // factory) and stores it in m.screens. It also calls Resize so dimensions296296 // are set correctly.297297 func (m model) initScreen(state viewState) model {298- factory, ok := screenRegistry[state]299- if !ok {300- return m301- }302- if m.screens == nil {303- m.screens = make(map[viewState]screenModel)304- }305- m.screens[state] = factory(m).Resize(m.width, m.height)298+ newRouteManager(&m).initScreen(state)299+ return m300+}301+302+func (m model) resizeActiveScreen() model {303+ newRouteManager(&m).resizeActiveScreen()304+ return m305+}306+307+func (m model) handleWindowSize(msg tea.WindowSizeMsg) model {308+ newRouteManager(&m).handleWindowSize(msg)306309 return m307310 }@@ -9,6 +9,7 @@ import (
99 sessionflow "github.com/FelineStateMachine/puzzletea/session"1010 "github.com/FelineStateMachine/puzzletea/store"1111 12+ "charm.land/bubbles/v2/key"1213 tea "charm.land/bubbletea/v2"1314 )1415 @@ -228,3 +229,72 @@ func (c sessionController) clearActiveGame() {
228229 c.model.session.returnState = mainMenuView229230 c.model.session.weeklyAdvance = nil230231 }232+233+// activeSpawnReturnState returns the route to return to when a pending234+// generation is canceled.235+func (c sessionController) activeSpawnReturnState() viewState {236+ if c.model.session.spawn == nil {237+ return modeSelectView238+ }239+ return c.model.session.spawn.returnState240+}241+242+// handleGeneratingKey handles global keys while a puzzle is being generated.243+// All key messages are consumed (handled=true): escape cancels the spawn and244+// returns to the previous route, quit exits the app, and all other keys are245+// swallowed.246+func (c sessionController) handleGeneratingKey(keyMsg tea.KeyPressMsg) (model, tea.Cmd, bool) {247+ switch {248+ case key.Matches(keyMsg, rootKeys.Escape):249+ returnState := c.activeSpawnReturnState()250+ c.cancelActiveSpawn()251+ c.model.state = returnState252+ *c.model = c.model.resizeActiveScreen()253+ return *c.model, nil, true254+ case key.Matches(keyMsg, rootKeys.Quit):255+ return *c.model, tea.Quit, true256+ }257+ return *c.model, nil, true258+}259+260+// handleGameKey handles global keys while a puzzle is active. Escape saves261+// and returns, quit saves as abandoned and exits, plus debug/full-help/reset262+// toggles and weekly advancement on enter.263+func (c sessionController) handleGameKey(keyMsg tea.KeyPressMsg) (model, tea.Cmd, bool) {264+ switch {265+ case key.Matches(keyMsg, rootKeys.Enter):266+ m, cmd, handled := c.model.advanceSolvedWeekly()267+ if handled {268+ return m, cmd, true269+ }270+ return *c.model, nil, false271+ case key.Matches(keyMsg, rootKeys.Escape):272+ returnState := c.model.session.returnState273+ c.saveCurrentGame(store.StatusInProgress)274+ c.model.state = returnState275+ if returnState == weeklyView {276+ *c.model = c.model.refreshWeeklyBrowser()277+ *c.model = c.model.initScreen(weeklyView)278+ }279+ c.model.debug.enabled = false280+ return *c.model, nil, true281+ case key.Matches(keyMsg, rootKeys.Quit):282+ c.saveCurrentGame(store.StatusAbandoned)283+ return *c.model, tea.Quit, true284+ case key.Matches(keyMsg, rootKeys.Debug):285+ c.model.debug.enabled = !c.model.debug.enabled286+ return *c.model, nil, true287+ case key.Matches(keyMsg, rootKeys.FullHelp):288+ c.model.help.showFull = !c.model.help.showFull289+ if c.model.session.game != nil {290+ c.model.session.game, _ = c.model.session.game.Update(game.HelpToggleMsg{Show: c.model.help.showFull})291+ }292+ return *c.model, nil, true293+ case key.Matches(keyMsg, rootKeys.ResetGame):294+ if c.model.session.game != nil {295+ c.model.session.game = c.model.session.game.Reset()296+ }297+ return *c.model, nil, true298+ }299+ return *c.model, nil, false300+}@@ -7,7 +7,6 @@ import (
77 88 "github.com/FelineStateMachine/puzzletea/difficulty"99 "github.com/FelineStateMachine/puzzletea/game"10- "github.com/FelineStateMachine/puzzletea/store"1110 1211 tea "charm.land/bubbletea/v2"1312 )@@ -77,18 +76,3 @@ func spawnSeededCmd(spawner game.SeededSpawner, rng *rand.Rand, ctx context.Cont
7776 }7877 }7978 }80-81-func (m *model) cancelActiveSpawn() {82- newSessionController(m).cancelActiveSpawn()83-}84-85-func (m model) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) (tea.Model, tea.Cmd) {86- cmd := newSessionController(&m).handleSpawnComplete(jobID, msg)87- return m, cmd88-}89-90-// saveCurrentGame saves the current game state to the DB if a game is active.91-func saveCurrentGame(m model, status store.GameStatus) model {92- newSessionController(&m).saveCurrentGame(status)93- return m94-}@@ -12,18 +12,15 @@ func TestHandleSpawnCompleteIgnoresStaleJob(t *testing.T) {
1212 m := model{1313 state: generatingView,1414 session: sessionState{15- generating: true,16- spawnJobID: 7,17- spawnCancel: func() {18- called = true19- },15+ generating: true,16+ spawnJobID: 7,17+ spawnCancel: func() { called = true },2018 },2119 }2220 23- next, _ := m.handleSpawnComplete(6, game.SpawnCompleteMsg{Err: nil})24- got := next.(model)21+ newSessionController(&m).handleSpawnComplete(6, game.SpawnCompleteMsg{Err: nil})2522 26- if !got.session.generating {23+ if !m.session.generating {2724 t.Fatal("expected stale completion to be ignored")2825 }2926 if called {@@ -36,11 +33,9 @@ func TestGeneratingEscapeCancelsActiveSpawn(t *testing.T) {
3633 m := model{3734 state: generatingView,3835 session: sessionState{39- generating: true,40- spawnJobID: 3,41- spawnCancel: func() {42- called = true43- },36+ generating: true,37+ spawnJobID: 3,38+ spawnCancel: func() { called = true },4439 spawn: &spawnRequest{4540 source: spawnSourceSeed,4641 returnState: playMenuView,@@ -77,13 +72,12 @@ func TestHandleSpawnCompleteSurfacingErrors(t *testing.T) {
7772 },7873 }7974 80- next, _ := m.handleSpawnComplete(9, game.SpawnCompleteMsg{Err: assertiveError("boom")})81- got := next.(model)75+ newSessionController(&m).handleSpawnComplete(9, game.SpawnCompleteMsg{Err: assertiveError("boom")})8276 83- if got.state != playMenuView {84- t.Fatalf("state = %d, want %d", got.state, playMenuView)77+ if m.state != playMenuView {78+ t.Fatalf("state = %d, want %d", m.state, playMenuView)8579 }86- if got.notice.message == "" {80+ if m.notice.message == "" {8781 t.Fatal("expected spawn error notice to be populated")8882 }8983 }@@ -0,0 +1,14 @@
1+package app2+3+import tea "charm.land/bubbletea/v2"4+5+// safeScreen is a minimal screenModel for tests that need a non-nil screen6+// in the cache but don't care about rendering. It avoids nil-pointer panics7+// that zero-value real screens (like exportScreen) would trigger on Resize.8+type safeScreen struct{}9+10+func (s safeScreen) Resize(width, height int) screenModel { return s }11+func (s safeScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {12+ return s, nil, nil13+}14+func (s safeScreen) View(notice noticeState) string { return "" }@@ -2,7 +2,6 @@ package app
22 33 import (44 "github.com/FelineStateMachine/puzzletea/game"5- "github.com/FelineStateMachine/puzzletea/store"65 76 "charm.land/bubbles/v2/key"87 tea "charm.land/bubbletea/v2"@@ -11,11 +10,11 @@ import (
1110 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {1211 switch msg := msg.(type) {1312 case spawnCompleteMsg:14- next, cmd := m.handleSpawnComplete(msg.jobID, msg.result)15- return next, cmd13+ cmd := newSessionController(&m).handleSpawnComplete(msg.jobID, msg.result)14+ return m, cmd1615 case game.SpawnCompleteMsg:17- next, cmd := m.handleSpawnComplete(m.session.spawnJobID, msg)18- return next, cmd16+ cmd := newSessionController(&m).handleSpawnComplete(m.session.spawnJobID, msg)17+ return m, cmd1918 case exportCompleteMsg:2019 next, cmd := m.handleExportComplete(msg)2120 return next, cmd@@ -68,24 +67,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6867 return next, tea.Batch(screenCmd, actionCmd)6968 }7069 71-func (m model) resizeActiveScreen() model {72- screen := m.screens[m.state] // nil map read is safe; returns nil73- if screen == nil {74- return m75- }76- m.screens[m.state] = screen.Resize(m.width, m.height)77- return m78-}79-80-func (m model) handleWindowSize(msg tea.WindowSizeMsg) model {81- m.width = msg.Width82- m.height = msg.Height83- if m.state == gameView {84- return m85- }86- return m.resizeActiveScreen()87-}88-70+// handleGlobalKey dispatches key messages to the domain that owns the current71+// state. Pending generation and active game keys are handled by the session72+// controller; pending export keys by the export workflow; normal screen keys73+// by shell-level handling. Non-key messages are never consumed here.8974 func (m model) handleGlobalKey(msg tea.Msg) (model, tea.Cmd, bool) {9075 keyMsg, ok := msg.(tea.KeyPressMsg)9176 if !ok {@@ -93,68 +78,18 @@ func (m model) handleGlobalKey(msg tea.Msg) (model, tea.Cmd, bool) {
9378 }9479 9580 if m.state == generatingView {96- switch {97- case key.Matches(keyMsg, rootKeys.Escape):98- returnState := m.activeSpawnReturnState()99- m.cancelActiveSpawn()100- m.state = returnState101- return m.resizeActiveScreen(), nil, true102- case key.Matches(keyMsg, rootKeys.Quit):103- return m, tea.Quit, true104- }105- return m, nil, true81+ return newSessionController(&m).handleGeneratingKey(keyMsg)10682 }10783 10884 if m.state == exportRunningView {109- switch {110- case key.Matches(keyMsg, rootKeys.Escape):111- m.cancelActiveExport()112- m.state = exportView113- return m.resizeActiveScreen(), nil, true114- case key.Matches(keyMsg, rootKeys.Quit):115- return m, tea.Quit, true116- }117- return m, nil, true85+ return m.handleExportRunningKey(keyMsg)11886 }11987 12088 if m.state == gameView {121- switch {122- case key.Matches(keyMsg, rootKeys.Enter):123- next, cmd, handled := m.advanceSolvedWeekly()124- if handled {125- return next, cmd, true126- }127- case key.Matches(keyMsg, rootKeys.Escape):128- returnState := m.session.returnState129- m = saveCurrentGame(m, store.StatusInProgress)130- m.state = returnState131- if returnState == weeklyView {132- m = m.refreshWeeklyBrowser()133- m = m.initScreen(weeklyView)134- }135- m.debug.enabled = false136- return m, nil, true137- case key.Matches(keyMsg, rootKeys.Quit):138- m = saveCurrentGame(m, store.StatusAbandoned)139- return m, tea.Quit, true140- case key.Matches(keyMsg, rootKeys.Debug):141- m.debug.enabled = !m.debug.enabled142- return m, nil, true143- case key.Matches(keyMsg, rootKeys.FullHelp):144- m.help.showFull = !m.help.showFull145- if m.session.game != nil {146- m.session.game, _ = m.session.game.Update(game.HelpToggleMsg{Show: m.help.showFull})147- }148- return m, nil, true149- case key.Matches(keyMsg, rootKeys.ResetGame):150- if m.session.game != nil {151- m.session.game = m.session.game.Reset()152- }153- return m, nil, true154- }155- return m, nil, false89+ return newSessionController(&m).handleGameKey(keyMsg)15690 }15791 92+ // Normal screen shell keys.15893 switch {15994 case key.Matches(keyMsg, rootKeys.Quit):16095 return m, tea.Quit, true@@ -169,13 +104,6 @@ func (m model) handleGlobalKey(msg tea.Msg) (model, tea.Cmd, bool) {
169104 }170105 }171106 172-func (m model) activeSpawnReturnState() viewState {173- if m.session.spawn == nil {174- return modeSelectView175- }176- return m.session.spawn.returnState177-}178-179107 func (m model) handleScreenAction(action screenAction) (model, tea.Cmd) {180108 return action.applyToModel(m)181109 }@@ -0,0 +1,394 @@
1+package app2+3+import (4+ "testing"5+ "time"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/FelineStateMachine/puzzletea/store"9+ "github.com/FelineStateMachine/puzzletea/weekly"10+)11+12+// ---------------------------------------------------------------------------13+// Normal screen global keys14+// ---------------------------------------------------------------------------15+16+func TestNormalScreenQuitReturnsQuitCmd(t *testing.T) {17+ m := model{state: mainMenuView}18+19+ next, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})20+ got := next.(model)21+22+ if got.state != mainMenuView {23+ t.Fatalf("state = %d, want %d (mainMenuView)", got.state, mainMenuView)24+ }25+ if cmd == nil {26+ t.Fatal("expected Quit command, got nil")27+ }28+ if _, isQuit := cmd().(tea.QuitMsg); !isQuit {29+ t.Fatal("expected tea.Quit, got different command")30+ }31+}32+33+func TestNormalScreenDebugToggles(t *testing.T) {34+ m := model{state: playMenuView}35+36+ next, cmd := m.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl})37+ got := next.(model)38+39+ if !got.debug.enabled {40+ t.Fatal("expected debug to be enabled")41+ }42+ if cmd != nil {43+ t.Fatal("expected nil cmd after debug toggle")44+ }45+46+ next2, _ := got.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl})47+ got2 := next2.(model)48+ if got2.debug.enabled {49+ t.Fatal("expected debug to be disabled after second toggle")50+ }51+}52+53+func TestNormalScreenFullHelpToggles(t *testing.T) {54+ m := model{state: optionsMenuView}55+56+ next, cmd := m.Update(tea.KeyPressMsg{Code: 'h', Mod: tea.ModCtrl})57+ got := next.(model)58+59+ if !got.help.showFull {60+ t.Fatal("expected full help to be enabled")61+ }62+ if cmd != nil {63+ t.Fatal("expected nil cmd after fullHelp toggle")64+ }65+66+ next2, _ := got.Update(tea.KeyPressMsg{Code: 'h', Mod: tea.ModCtrl})67+ got2 := next2.(model)68+ if got2.help.showFull {69+ t.Fatal("expected full help to be disabled after second toggle")70+ }71+}72+73+// ---------------------------------------------------------------------------74+// Pending generation global keys75+// ---------------------------------------------------------------------------76+77+func TestGeneratingScreenQuitReturnsQuitCmd(t *testing.T) {78+ m := model{79+ state: generatingView,80+ session: sessionState{generating: true},81+ }82+83+ next, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})84+ got := next.(model)85+86+ if got.state != generatingView {87+ t.Fatalf("state = %d, want %d (generatingView)", got.state, generatingView)88+ }89+ if _, isQuit := cmd().(tea.QuitMsg); !isQuit {90+ t.Fatal("expected tea.Quit in generating view")91+ }92+}93+94+// ---------------------------------------------------------------------------95+// Pending export global keys96+// ---------------------------------------------------------------------------97+98+func TestExportRunningEscapeReturnsToExportView(t *testing.T) {99+ // Use handleGlobalKey directly to focus on the key dispatch, not screen100+ // resize. A safeScreen avoids nil-pointer panics from an uninitialized101+ // exportScreen viewport during resizeActiveScreen.102+ m := model{103+ state: exportRunningView,104+ screens: map[viewState]screenModel{105+ exportView: safeScreen{},106+ },107+ }108+109+ next, cmd, handled := m.handleGlobalKey(tea.KeyPressMsg{Code: tea.KeyEscape})110+ if !handled {111+ t.Fatal("expected handleGlobalKey to handle escape in export running view")112+ }113+ if next.state != exportView {114+ t.Fatalf("state = %d, want %d (exportView)", next.state, exportView)115+ }116+ if cmd != nil {117+ t.Fatalf("expected nil cmd, got %v", cmd)118+ }119+}120+121+func TestExportRunningQuitReturnsQuitCmd(t *testing.T) {122+ m := model{123+ state: exportRunningView,124+ screens: map[viewState]screenModel{125+ exportView: exportScreen{},126+ },127+ }128+129+ next, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})130+ got := next.(model)131+132+ if got.state != exportRunningView {133+ t.Fatalf("state = %d, want %d (exportRunningView)", got.state, exportRunningView)134+ }135+ if _, isQuit := cmd().(tea.QuitMsg); !isQuit {136+ t.Fatal("expected tea.Quit in export running view")137+ }138+}139+140+// ---------------------------------------------------------------------------141+// Active game global keys (quit → save + quit)142+// ---------------------------------------------------------------------------143+144+func TestGameViewQuitSavesAndReturnsQuit(t *testing.T) {145+ s := openAppTestStore(t)146+147+ rec := &store.GameRecord{148+ Name: "quit-test",149+ GameType: "Nonogram",150+ Mode: "Easy",151+ InitialState: `{}`,152+ SaveState: `{}`,153+ Status: store.StatusInProgress,154+ }155+ if err := s.CreateGame(rec); err != nil {156+ t.Fatal(err)157+ }158+159+ m := model{160+ state: gameView,161+ store: s,162+ session: sessionState{163+ game: &escapeTrackingGame{},164+ activeGameID: rec.ID,165+ returnState: mainMenuView,166+ },167+ }168+169+ next, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})170+ _ = next.(model)171+172+ if _, isQuit := cmd().(tea.QuitMsg); !isQuit {173+ t.Fatal("expected tea.Quit when quitting from game view")174+ }175+176+ // The game should have been saved as abandoned.177+ // GetGameByName excludes abandoned, so use ListAllGames.178+ all, err := s.ListAllGames()179+ if err != nil {180+ t.Fatal(err)181+ }182+ var saved *store.GameRecord183+ for i := range all {184+ if all[i].Name == "quit-test" {185+ saved = &all[i]186+ break187+ }188+ }189+ if saved == nil {190+ t.Fatal("expected quit-test record to exist")191+ }192+ if saved.Status != store.StatusAbandoned {193+ t.Fatalf("status = %q, want %q", saved.Status, store.StatusAbandoned)194+ }195+}196+197+// ---------------------------------------------------------------------------198+// Active game: escape → save + return199+// ---------------------------------------------------------------------------200+201+func TestGameViewEscapeSavesAndReturnsToReturnState(t *testing.T) {202+ s := openAppTestStore(t)203+204+ rec := &store.GameRecord{205+ Name: "escape-test",206+ GameType: "Nonogram",207+ Mode: "Easy",208+ InitialState: `{}`,209+ SaveState: `{}`,210+ Status: store.StatusInProgress,211+ }212+ if err := s.CreateGame(rec); err != nil {213+ t.Fatal(err)214+ }215+216+ m := model{217+ state: gameView,218+ store: s,219+ session: sessionState{220+ game: &escapeTrackingGame{},221+ activeGameID: rec.ID,222+ returnState: playMenuView,223+ },224+ }225+226+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})227+ got := next.(model)228+229+ if got.state != playMenuView {230+ t.Fatalf("state = %d, want %d (playMenuView)", got.state, playMenuView)231+ }232+233+ saved, err := s.GetGameByName("escape-test")234+ if err != nil {235+ t.Fatal(err)236+ }237+ if saved == nil {238+ t.Fatal("expected escape-test record to exist")239+ }240+ if saved.Status != store.StatusInProgress {241+ t.Fatalf("status = %q, want %q", saved.Status, store.StatusInProgress)242+ }243+}244+245+// ---------------------------------------------------------------------------246+// Active game: debug toggle247+// ---------------------------------------------------------------------------248+249+func TestGameViewDebugToggles(t *testing.T) {250+ m := model{251+ state: gameView,252+ session: sessionState{game: &escapeTrackingGame{}},253+ }254+255+ next, _ := m.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl})256+ got := next.(model)257+258+ if !got.debug.enabled {259+ t.Fatal("expected debug to be enabled")260+ }261+262+ next2, _ := got.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl})263+ got2 := next2.(model)264+ if got2.debug.enabled {265+ t.Fatal("expected debug to be disabled")266+ }267+}268+269+// ---------------------------------------------------------------------------270+// Active game: fullHelp toggle propagates to game271+// ---------------------------------------------------------------------------272+273+func TestGameViewFullHelpTogglesAndPropagates(t *testing.T) {274+ tracker := &escapeTrackingGame{}275+ m := model{276+ state: gameView,277+ session: sessionState{game: tracker},278+ }279+280+ next, _ := m.Update(tea.KeyPressMsg{Code: 'h', Mod: tea.ModCtrl})281+ got := next.(model)282+283+ if !got.help.showFull {284+ t.Fatal("expected full help to be enabled")285+ }286+ if tracker.updateCalls == 0 {287+ t.Fatal("expected game.Update to be called for HelpToggleMsg propagation")288+ }289+290+ next2, _ := got.Update(tea.KeyPressMsg{Code: 'h', Mod: tea.ModCtrl})291+ got2 := next2.(model)292+ if got2.help.showFull {293+ t.Fatal("expected full help to be disabled after second toggle")294+ }295+}296+297+// ---------------------------------------------------------------------------298+// Active game: reset calls game.Reset299+// ---------------------------------------------------------------------------300+301+func TestGameViewResetCallsGameReset(t *testing.T) {302+ tracker := &escapeTrackingGame{}303+ m := model{304+ state: gameView,305+ session: sessionState{game: tracker},306+ }307+308+ gotAfter, _ := m.Update(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl})309+ got := gotAfter.(model)310+311+ if got.session.game == nil {312+ t.Fatal("expected game to still be set after reset")313+ }314+}315+316+// ---------------------------------------------------------------------------317+// Active game: enter on solved weekly advances318+// ---------------------------------------------------------------------------319+320+func TestGameViewEnterOnSolvedWeeklyAdvances(t *testing.T) {321+ s := openAppTestStore(t)322+323+ now := time.Now()324+ info := weekly.Current(now)325+ year, weekNumber := info.Year, info.Week326+ currentName := weekly.Name(year, weekNumber, 1)327+328+ rec := &store.GameRecord{329+ Name: currentName,330+ GameType: "Nonogram",331+ Mode: "Easy",332+ InitialState: `{}`,333+ SaveState: `{}`,334+ Status: store.StatusInProgress,335+ RunKind: store.RunKindWeekly,336+ WeekYear: year,337+ WeekNumber: weekNumber,338+ WeekIndex: 1,339+ }340+ if err := s.CreateGame(rec); err != nil {341+ t.Fatal(err)342+ }343+344+ m := model{345+ state: gameView,346+ store: s,347+ spinner: newSpinner(),348+ session: sessionState{349+ game: &solvedWeeklyGame{save: []byte(`{"done":true}`)},350+ activeGameID: rec.ID,351+ returnState: weeklyView,352+ weeklyAdvance: &info,353+ completionSaved: false,354+ },355+ weekly: weeklyState{356+ cursor: weekly.StartOfWeek(year, weekNumber, time.Local),357+ },358+ }359+360+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})361+ got := next.(model)362+363+ if got.state != generatingView {364+ t.Fatalf("state = %d, want %d (generatingView)", got.state, generatingView)365+ }366+367+ // Verify the first weekly was marked as completed368+ saved, err := s.GetWeeklyGame(year, weekNumber, 1)369+ if err != nil {370+ t.Fatal(err)371+ }372+ if saved == nil {373+ t.Fatal("expected weekly record")374+ }375+ if saved.Status != store.StatusCompleted {376+ t.Fatalf("status = %q, want %q", saved.Status, store.StatusCompleted)377+ }378+}379+380+// ---------------------------------------------------------------------------381+// Non-key messages pass through handleGlobalKey unchanged382+// ---------------------------------------------------------------------------383+384+func TestHandleGlobalKeyIgnoresNonKeyMessages(t *testing.T) {385+ m := model{state: mainMenuView}386+387+ _, cmd, handled := m.handleGlobalKey(tea.WindowSizeMsg{Width: 100, Height: 40})388+ if handled {389+ t.Fatal("expected non-key message to not be handled by handleGlobalKey")390+ }391+ if cmd != nil {392+ t.Fatal("expected nil cmd for non-key message")393+ }394+}@@ -186,7 +186,7 @@ func (m model) advanceSolvedWeekly() (model, tea.Cmd, bool) {
186186 }187187 188188 info := *m.session.weeklyAdvance189- m = m.persistCompletionIfSolved()189+ newSessionController(&m).persistCompletionIfSolved()190190 m.weekly.cursor = weekly.StartOfWeek(info.Year, info.Week, time.Local)191191 m = m.refreshWeeklyBrowser()192192 m.state = weeklyView@@ -113,7 +113,17 @@ The refactor should be incremental and behavior-preserving. It is acceptable to
113113 114114 Rollback is straightforward because there are no data migrations or public API changes: revert the refactor commits if behavior diverges.115115 116-## Open Questions116+## Graph Analysis117117 118-- Should create/daily/seed/weekly decision logic live in one deterministic-play workflow helper, or remain in their current files while calling improved session APIs?119-- Should export get a small controller in this change, or only enough cleanup to remove export-running special cases from root global key handling?118+A GitNexus knowledge graph analysis of the codebase is in `graph-analysis.md`. It provides concrete symbol locations, call-chain evidence, blast-radius data, and file inventory for this refactor. Key findings:119+120+- **11 symbols** depend on `model` struct directly (MEDIUM risk, contained in `app` + `cmd` packages).121+- **3 wrapper functions** in `spawn.go` exist solely to bridge value-receiver `handleGlobalKey` to pointer-receiver `sessionController` — these can be eliminated by moving key handling into the controller.122+- **`initScreen` has 13 callers** spanning both route transitions and workflow code — highest regression risk.123+- **8 `screenAction` types** already implement the intent-emitter pattern cleanly; no changes needed there.124+- **`export.go` is 1,720 lines** — the largest affected file; warrants its own future refactor, not this one.125+126+## Open Questions (resolved by graph analysis)127+128+- **Should create/daily/seed/weekly decision logic live in one helper?** No — each workflow constructs a different `spawnRequest` with different fields. The session controller already owns spawn mechanics; consolidating decision logic would add a layer without reducing duplication.129+- **Should export get a controller in this change?** No — minimal cleanup only. Extract `exportRunningView` key handling into `app/export.go` as a function. Export's size (1,720 lines) warrants a dedicated refactor.@@ -0,0 +1,260 @@
1+# Graph Analysis: refactor-root-tui-model2+3+> Generated from GitNexus knowledge graph index on branch `chore/app-model-decomp`.4+> Graph stats: 8,349 nodes | 33,696 edges | 490 clusters | 300 flows.5+> No circular imports detected (`gitnexus check --cycles`).6+7+## 1. Current Dispatch Topology8+9+### Root `Update` (`app/update.go:10–68`)10+11+The graph confirms `Update` is a flat dispatcher with 6 outgoing call targets spanning 4 concerns:12+13+| Message / Branch | Handler | File | Domain |14+|---|---|---|---|15+| `spawnCompleteMsg` / `game.SpawnCompleteMsg` | `model.handleSpawnComplete` | `app/spawn.go:85` | Session |16+| `exportCompleteMsg` | `model.handleExportComplete` | `app/export.go` | Export |17+| `exportSubmitAction` | `model.handleExportSubmit` | `app/export.go` | Export |18+| `backAction` | `msg.applyToModel(m)` | `app/screen_core.go` | Screen action |19+| `tea.WindowSizeMsg` | `model.handleWindowSize` | `app/update.go:80` | Shell/route |20+| All other keys | `model.handleGlobalKey` | `app/update.go:89` | Mixed (see §3) |21+| `gameView` delegation | `newSessionController(&m).updateActiveGame` | `app/session_controller.go:169` | Session |22+| Screen update | `m.activeScreen().Update` → `handleScreenAction` | `app/screen_core.go` / `app/update.go:179` | Screen |23+24+**Trace**: `Update → newSessionController → sessionController` is 2 hops (CALLS edges, confidence 0.85). The controller is reconstructed on every `Update` call that needs it — `handleSpawnComplete`, `updateActiveGame`, and the free-function wrappers all call `newSessionController(&m)` inline.25+26+### `handleWindowSize` (`app/update.go:80–87`)27+28+Accesses `m.width`, `m.height`, `m.state`, and calls `resizeActiveScreen()`. For `gameView`, skips screen resize (game handles its own sizing via `sessionController.activateGame` which pushes `tea.WindowSizeMsg` to the `Gamer`).29+30+### `resizeActiveScreen` (`app/update.go:71–78`)31+32+Reads `m.screens[m.state]`, calls `screen.Resize(m.width, m.height)`, writes back. This is the only screen-cache mutation path besides `initScreen`.33+34+---35+36+## 2. Screen Route Management37+38+### `screenModel` interface (`app/screen_core.go:234–238`)39+40+```41+Resize(width, height int) screenModel42+Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction)43+View(notice noticeState) string44+```45+46+### Screen registry (`app/screen_core.go:242–288`)47+48+`screenRegistry` maps all 15 `viewState` values to `screenFactory` functions. Factories read from `model` fields (`m.nav`, `m.seed`, `m.create`, `m.cont`, `m.weekly`, `m.help`, `m.stats`, `m.theme`, `m.spinner`) — screens are constructed from shell state, confirming the design's statement that screens are "dependency-light" (they receive data via construction, not via injected references).49+50+### `activeScreen()` (`app/screen_core.go:290–291`)51+52+Called from 2 sites (graph-confirmed):53+- `model.Update` (`app/update.go:45`) — for screen message dispatch54+- `model.viewContent` (`app/view.go`) — for rendering55+56+### `initScreen(state)` (`app/screen_core.go:297–307`)57+58+13 incoming callers (graph-confirmed):59+- `InitialModel`, `InitialModelWithGame` — initialization60+- 8 `screenAction.applyToModel` implementations — route transitions61+- `sessionController.startSpawn`, `startEloSpawn`, `startSeededSpawn` — generating screen setup62+- `enterSeedInputView` (`app/seed_input.go`)63+- `handleGlobalKey` (weekly escape path) — re-inits weekly screen after save64+65+This confirms `initScreen` is the single entry point for screen creation, but it's called from both route-transition code (actions) and workflow code (session controller, global key handler) — the ownership overlap the refactor targets.66+67+---68+69+## 3. Global Key Ownership Leaks70+71+`handleGlobalKey` (`app/update.go:89–169`, 80 lines) directly accesses:72+73+| Access | Property | Owner (proposed) |74+|---|---|---|75+| `m.session.spawn` | `sessionState.spawn` | Session lifecycle |76+| `m.session.returnState` | `sessionState.returnState` | Session lifecycle |77+| `m.session.game` | `sessionState.game` | Session lifecycle |78+| `m.help.showFull` | `helpState.showFull` | Shell |79+| `m.debug.enabled` | `debugState.enabled` | Shell |80+81+**Outgoing calls** span 4 domains (graph-confirmed):82+83+- **Session**: `cancelActiveSpawn` (via `model.cancelActiveSpawn` wrapper), `saveCurrentGame` (free function wrapper), `activeSpawnReturnState`84+- **Export**: `cancelActiveExport`85+- **Route/screen**: `initScreen`, `resizeActiveScreen`86+- **Weekly workflow**: `refreshWeeklyBrowser`, `advanceSolvedWeekly`87+- **Game**: `Gamer.Reset`, `Gamer.Update` (HelpToggleMsg propagation)88+89+### Wrapper indirection (graph-identified)90+91+Three free-function / method wrappers exist solely because `handleGlobalKey` operates on `model` by value while `sessionController` needs a pointer:92+93+```94+handleGlobalKey → saveCurrentGame (spawn.go:91) → sessionController.saveCurrentGame95+handleGlobalKey → model.cancelActiveSpawn (spawn.go:81) → sessionController.cancelActiveSpawn96+handleSpawnComplete → model.handleSpawnComplete (spawn.go:85) → sessionController.handleSpawnComplete97+```98+99+Each creates a throwaway `sessionController{model: &m}`. The refactor should either:100+- Have `handleGlobalKey` construct a single controller and delegate, or101+- Move the key handling into the controller itself for game/generating states.102+103+---104+105+## 4. Session Controller Surface106+107+`sessionController` (`app/session_controller.go:14–16`) already owns 12 methods:108+109+| Method | Lines | Responsibility |110+|---|---|---|111+| `beginSpawnContext` | 23–31 | Job ID increment, cancel prev, set generating |112+| `cancelActiveSpawn` | 33–43 | Cancel context, clear spawn, bump job ID |113+| `startSpawn` | 45–52 | Begin → set spawn → state=generating → initScreen → cmd |114+| `startEloSpawn` | 54–61 | Same pattern, Elo variant |115+| `startSeededSpawn` | 63–70 | Same pattern, seeded variant |116+| `handleSpawnComplete` | 72–123 | Job ID guard, stale check, activate, create record |117+| `activateGame` | 138–148 | Help/ws propagation, set session fields, state=gameView |118+| `importRecord` | 150–167 | Import, read-only handling, activate |119+| `updateActiveGame` | 169–177 | Delegate to Gamer.Update, debug info, persist |120+| `persistCompletionIfSolved` | 179–197 | Guarded save + status update |121+| `saveCurrentGame` | 199–222 | Save state + status, clear active game |122+| `clearActiveGame` | 224–230 | Reset session fields to defaults |123+124+**Process flows through the controller** (graph-confirmed):125+- `Update → SessionController` (proc_200, 4 steps)126+- `HandleGlobalKey → SessionController` (proc_234, 4 steps)127+- `HandleDailyPuzzle → SessionController` (proc_118, 5 steps)128+129+The controller already covers most of the session lifecycle. The gap is that `handleGlobalKey` and `handleSpawnComplete` bypass it through wrappers instead of delegating directly.130+131+---132+133+## 5. Impact Analysis134+135+### Blast radius for `model` struct (`gitnexus impact`)136+137+- **Risk**: MEDIUM138+- **Impacted symbols**: 11 (6 direct + 3 depth-2 + 2 depth-3)139+- **Processes affected**: 2 (both `Update` flows in `screen_navigation.go`)140+- **Modules affected**: App (8 hits, direct), Ui (1 hit, direct)141+142+**Direct dependents** (depth 1):143+- `InitialModel` (`app/model.go`) — constructor144+- `InitialModelWithGame` (`app/model.go`) — constructor145+- `seedInputScreen.Update` (`app/screen_navigation.go`)146+- `gameSelectScreen.Resize` (`app/screen_navigation.go`) — 5 processes147+- `helpDetailScreen.Resize` (`app/screen_support.go`)148+- `statsScreen.Resize` (`app/screen_support.go`)149+150+**Depth-2**: `runGameProgram` (`cmd/session.go`), `gameSelectScreen.resizeSelf`, `cmd/root.go`151+152+### Key insight153+154+The impact is contained within the `app` package + `cmd` entry points. No `game`, `store`, `catalog`, or `registry` package code depends on `model` internals. This confirms the design's non-goal: no public API or package boundary changes needed.155+156+---157+158+## 6. `screenAction` Pattern159+160+The graph identifies 8 action types implementing `screenAction`:161+162+| Action | File | What it does |163+|---|---|---|164+| `backAction` | `app/screen_core.go` | Generic back navigation |165+| `openPlayMenuAction` | `app/screen_core.go` | → playMenuView |166+| `openOptionsMenuAction` | `app/screen_core.go` | → optionsMenuView |167+| `openCreateAction` | `app/screen_core.go` | → createView |168+| `openContinueAction` | `app/screen_core.go` | → continueView |169+| `gameSelectEnterAction` | `app/screen_core.go` | → gameSelectView |170+| `weeklyShiftAction` | `app/screen_core.go` | → weeklyView |171+| `openHelpSelectAction` | `app/screen_core.go` | → helpSelectView |172+173+All actions call `initScreen` to transition. None directly access `store`, `config`, or `sessionController` — confirming the design's "screens emit typed intents" pattern is already in place.174+175+`exportSubmitAction` (`app/screen_core.go:232`) is the exception: it's a `tea.Msg`, not a `screenAction`, and is handled directly in root `Update` (line 22-24). This is the export-workflow special case the design notes.176+177+---178+179+## 7. File Inventory (affected code)180+181+| File | Lines | Role |182+|---|---|---|183+| `app/model.go` | 281 | Model struct, viewState, state types, constructors |184+| `app/update.go` | 181 | Update dispatch, handleGlobalKey, handleWindowSize, resizeActiveScreen |185+| `app/screen_core.go` | 307 | screenModel interface, registry, activeScreen, initScreen, actions |186+| `app/session_controller.go` | 230 | Session lifecycle (12 methods) |187+| `app/spawn.go` | 94 | Spawn commands + 3 wrapper functions |188+| `app/view.go` | 214 | View rendering |189+| `app/export.go` | 1720 | Export workflow (screen + handlers) |190+| `app/handle_create.go` | 114 | Create flow |191+| `app/handle_seed.go` | 66 | Seed flow |192+| `app/handle_daily.go` | 48 | Daily flow |193+| `app/weekly.go` | 319 | Weekly flow + browser |194+195+**Total**: ~3,574 lines in the affected surface.196+197+---198+199+## 8. Refactor Recommendations (graph-derived)200+201+### 8.1 Route/screen manager202+203+Extract from `model`:204+- `state viewState` → `manager.current`205+- `screens map[viewState]screenModel` → `manager.cache`206+- `activeScreen()` → `manager.active()`207+- `initScreen(state)` → `manager.init(state, shell)`208+- `resizeActiveScreen()` → `manager.resizeActive()`209+- `handleWindowSize` → `manager.handleWindowSize(msg, isGameView)`210+211+**Graph evidence**: `activeScreen` has 2 callers (`Update`, `viewContent`), `initScreen` has 13 callers, `resizeActiveScreen` has 3 callers (`handleWindowSize`, `handleGlobalKey` ×2). Low blast radius.212+213+### 8.2 Session key delegation214+215+Move `handleGlobalKey` branches for `generatingView` and `gameView` into `sessionController`:216+217+- `generatingView` escape/quit → `sessionController.handleGeneratingKey(keyMsg) (model, tea.Cmd, bool)`218+- `gameView` escape/quit/reset/debug/fullhelp/enter → `sessionController.handleGameKey(keyMsg) (model, tea.Cmd, bool)`219+220+This eliminates:221+- The 3 wrapper functions in `spawn.go` (lines 81–94)222+- Direct access to `m.session.*`, `m.help.showFull`, `m.debug.enabled` from `handleGlobalKey`223+- The `saveCurrentGame` free function224+225+### 8.3 Export key delegation226+227+Move `exportRunningView` escape/quit from `handleGlobalKey` into export workflow code:228+- `exportController.handleRunningKey(keyMsg) (model, tea.Cmd, bool)`229+230+Or simpler: an `exportRunningKeyHandler` function in `app/export.go` that `handleGlobalKey` calls for `exportRunningView` state.231+232+### 8.4 Test coverage priorities (graph-derived high-risk paths)233+234+Based on process flow counts and caller counts, these paths have the highest regression risk:235+236+1. **`initScreen` with 13 callers** — any change to screen cache behavior risks all route transitions237+2. **`handleGlobalKey` game escape path** — calls `saveCurrentGame` + `refreshWeeklyBrowser` + `initScreen(weeklyView)` in sequence; touches session, weekly, and route domains238+3. **`sessionController.handleSpawnComplete`** — 3 process flows depend on it; job ID guard + stale check + activate + record creation239+4. **`sessionController.activateGame`** — called from `handleSpawnComplete` and `importRecord`; help/ws propagation order matters240+5. **`resizeActiveScreen`** — called from 3 sites; nil-map-read safety depends on `screens` being initialized241+242+---243+244+## 9. Open Questions Resolved by Graph245+246+### Q1: Should create/daily/seed/weekly decision logic live in one workflow helper?247+248+**Graph evidence**: Each handler (`handle_create.go`, `handle_seed.go`, `handle_daily.go`, `weekly.go`) calls `sessionController.startSpawn` / `startEloSpawn` / `startSeededSpawn` independently. The call patterns are similar but the decision logic (which spawner, which spawn source, which return state) is workflow-specific. 249+250+**Recommendation**: Keep decision logic in current files. The session controller already owns the spawn mechanics. Consolidating decision logic would add a layer without reducing duplication, since each workflow constructs a different `spawnRequest` with different fields.251+252+### Q2: Should export get a small controller?253+254+**Graph evidence**: `export.go` is 1,720 lines — the largest file in the affected surface. `handleGlobalKey` only touches it via `cancelActiveExport`. `Update` touches it via `handleExportSubmit` and `handleExportComplete`.255+256+**Recommendation**: Minimal cleanup. Extract the `exportRunningView` key handling into `app/export.go` as a function. Do not build a full export controller in this change — the export workflow's size warrants its own dedicated refactor.257+258+---259+260+*Analysis performed with GitNexus v1.6.8 CLI. Graph index built on `chore/app-model-decomp` branch.*@@ -1,41 +1,41 @@
11 ## 1. Baseline Behavior Coverage22 3-- [ ] 1.1 Add or update focused tests for root `Update` dispatch of async spawn/export completion, window resize, active game messages, screen messages, and screen actions.4-- [ ] 1.2 Add or update tests for global key behavior in normal screens, pending generation, pending export, and active game states.5-- [ ] 1.3 Add or update tests for session lifecycle behavior: spawn cancellation, stale spawn completion, generated game activation, saved-record import, read-only review, save-on-exit, and solved-game completion persistence.6-- [ ] 1.4 Add or update tests that pin existing screen cache and resize behavior across representative route transitions.3+- [x] 1.1 Add or update focused tests for root `Update` dispatch of async spawn/export completion, window resize, active game messages, screen messages, and screen actions.4+- [x] 1.2 Add or update tests for global key behavior in normal screens, pending generation, pending export, and active game states.5+- [x] 1.3 Add or update tests for session lifecycle behavior: spawn cancellation, stale spawn completion, generated game activation, saved-record import, read-only review, save-on-exit, and solved-game completion persistence.6+- [x] 1.4 Add or update tests that pin existing screen cache and resize behavior across representative route transitions.77 88 ## 2. Route and Screen Boundary99 10-- [ ] 2.1 Introduce an app-internal route/screen management boundary for current `viewState`, cached screens, active screen lookup, screen initialization, window dimensions, and resize behavior.11-- [ ] 2.2 Move `activeScreen`, `initScreen`, `resizeActiveScreen`, and window-size handling behind the route/screen boundary without changing behavior.12-- [ ] 2.3 Update root model initialization to construct and use the route/screen boundary while preserving initial main-menu and initial-game behavior.13-- [ ] 2.4 Keep `screenModel` and `screenAction` semantics intact and verify existing screen tests still exercise typed app intents.10+- [x] 2.1 Introduce an app-internal route/screen management boundary for current `viewState`, cached screens, active screen lookup, screen initialization, window dimensions, and resize behavior.11+- [x] 2.2 Move `activeScreen`, `initScreen`, `resizeActiveScreen`, and window-size handling behind the route/screen boundary without changing behavior.12+- [x] 2.3 Update root model initialization to construct and use the route/screen boundary while preserving initial main-menu and initial-game behavior.13+- [x] 2.4 Keep `screenModel` and `screenAction` semantics intact and verify existing screen tests still exercise typed app intents.1414 1515 ## 3. Session Lifecycle Boundary1616 17-- [ ] 3.1 Expand session-oriented methods so generated game start, pending generation state, cancellation, stale completion handling, and activation are owned by session lifecycle code.18-- [ ] 3.2 Move saved-record import, read-only review setup, active game ID setup, help/window-size propagation, and completion-saved state behind session lifecycle code.19-- [ ] 3.3 Move save-on-exit, abandon-on-quit, active game cleanup, and solved-game completion persistence behind session lifecycle code.20-- [ ] 3.4 Update create, mode-select, seed, daily, and weekly call sites to use the clarified session lifecycle entry points where doing so reduces duplicated spawn/open/resume rules.17+- [x] 3.1 Expand session-oriented methods so generated game start, pending generation state, cancellation, stale completion handling, and activation are owned by session lifecycle code.18+- [x] 3.2 Move saved-record import, read-only review setup, active game ID setup, help/window-size propagation, and completion-saved state behind session lifecycle code.19+- [x] 3.3 Move save-on-exit, abandon-on-quit, active game cleanup, and solved-game completion persistence behind session lifecycle code.20+- [x] 3.4 Update create, mode-select, seed, daily, and weekly call sites to use the clarified session lifecycle entry points where doing so reduces duplicated spawn/open/resume rules.2121 2222 ## 4. Global Key and Workflow Dispatch2323 24-- [ ] 4.1 Refactor root global key handling so pending generation keys delegate to session lifecycle behavior.25-- [ ] 4.2 Refactor root global key handling so pending export keys delegate to export workflow behavior.26-- [ ] 4.3 Refactor active-game key behavior so save, quit, reset, help propagation, debug updates, and weekly advancement route through the owning session/workflow code.27-- [ ] 4.4 Keep normal-screen shell keys for quit, debug toggle, and full-help toggle behavior-preserving and easy to audit.24+- [x] 4.1 Refactor root global key handling so pending generation keys delegate to session lifecycle behavior.25+- [x] 4.2 Refactor root global key handling so pending export keys delegate to export workflow behavior.26+- [x] 4.3 Refactor active-game key behavior so save, quit, reset, help propagation, debug updates, and weekly advancement route through the owning session/workflow code.27+- [x] 4.4 Keep normal-screen shell keys for quit, debug toggle, and full-help toggle behavior-preserving and easy to audit.2828 2929 ## 5. Root Model Cleanup3030 31-- [ ] 5.1 Reduce `model.Update` to a clear top-level dispatch flow: app-level messages, global keys, active game delegation, screen update, action application.32-- [ ] 5.2 Move state type definitions or helper methods into files that match their ownership boundary, avoiding file-only churn that does not clarify responsibilities.33-- [ ] 5.3 Remove obsolete wrappers or duplicated helper paths created during the refactor.34-- [ ] 5.4 Review `app/model.go`, `app/update.go`, `app/screen_core.go`, and `app/session_controller.go` for remaining responsibility leaks and document any intentionally deferred cleanup.31+- [x] 5.1 Reduce `model.Update` to a clear top-level dispatch flow: app-level messages, global keys, active game delegation, screen update, action application.32+- [x] 5.2 Move state type definitions or helper methods into files that match their ownership boundary, avoiding file-only churn that does not clarify responsibilities.33+- [x] 5.3 Remove obsolete wrappers or duplicated helper paths created during the refactor.34+- [x] 5.4 Review `app/model.go`, `app/update.go`, `app/screen_core.go`, and `app/session_controller.go` for remaining responsibility leaks and document any intentionally deferred cleanup.3535 3636 ## 6. Validation3737 38-- [ ] 6.1 Run `just fmt`.39-- [ ] 6.2 Run `just lint`.40-- [ ] 6.3 Run `just test-short`.41-- [ ] 6.4 Manually review the change against `root-tui-architecture` requirements and confirm there are no user-facing behavior changes.38+- [x] 6.1 Run `just fmt`.39+- [x] 6.2 Run `just lint`.40+- [x] 6.3 Run `just test-short`.41+- [x] 6.4 Manually review the change against `root-tui-architecture` requirements and confirm there are no user-facing behavior changes.