1package app23import (4 "path/filepath"5 "strings"6 "testing"78 tea "charm.land/bubbletea/v2"9 "charm.land/lipgloss/v2"10 "github.com/FelineStateMachine/puzzletea/config"11 "github.com/FelineStateMachine/puzzletea/export/pack"12 "github.com/FelineStateMachine/puzzletea/puzzle"13)1415func TestHandleExportEnterLoadsPersistedDefaults(t *testing.T) {16 tmp := t.TempDir()17 t.Chdir(tmp)1819 m := model{20 cfg: &config.Config{21 Export: config.ExportConfig{22 Title: "Saved Sampler",23 Volume: 3,24 SheetLayout: "duplex-booklet",25 PDFOutputPath: filepath.Join(tmp, "saved.pdf"),26 Counts: map[puzzle.GameID]map[puzzle.ModeID]int{27 puzzle.CanonicalGameID("Sudoku"): {28 puzzle.CanonicalModeID("Sudoku"): 4,29 },30 },31 },32 },33 width: 100,34 height: 30,35 }3637 got, _ := m.handleExportEnter()3839 es, ok := got.screens[exportView].(exportScreen)40 if !ok {41 t.Fatal("expected export screen to be initialized")42 }43 if got.state != exportView {44 t.Fatalf("state = %d, want %d", got.state, exportView)45 }46 if es.export.values.Title != "Saved Sampler" {47 t.Fatalf("title = %q, want %q", es.export.values.Title, "Saved Sampler")48 }49 if es.export.values.Volume != "3" {50 t.Fatalf("volume = %q, want %q", es.export.values.Volume, "3")51 }52 if es.export.cards[findExportCardIndex(es.export.cards, puzzle.CanonicalGameID("Sudoku"))].Buckets[0] != 4 {53 t.Fatal("expected persisted count to populate the form")54 }55}5657func TestHandleExportCompleteSuccessShowsSuccessNotice(t *testing.T) {58 tmp := t.TempDir()59 t.Chdir(tmp)6061 m := model{62 state: exportRunningView,63 width: 100,64 height: 30,65 screens: map[viewState]screenModel{66 exportView: exportScreen{67 jobID: 7,68 export: exportModel{values: exportValuesFromSpec(packexport.DefaultSpec(tmp)), cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp))},69 },70 },71 }7273 got, _ := m.handleExportComplete(exportCompleteMsg{74 jobID: 7,75 result: packexport.Result{76 TotalCount: 10,77 PDFOutputPath: filepath.Join(tmp, "sample.pdf"),78 },79 })8081 if got.state != exportView {82 t.Fatalf("state = %d, want %d", got.state, exportView)83 }84 if got.notice.level != noticeLevelSuccess {85 t.Fatalf("notice level = %q, want %q", got.notice.level, noticeLevelSuccess)86 }87 if got.notice.message == "" {88 t.Fatal("expected success notice")89 }90 if strings.Contains(got.notice.message, ".jsonl") {91 t.Fatalf("notice message = %q, did not expect jsonl path when jsonl export is disabled", got.notice.message)92 }93}9495func TestHandleExportCompleteSuccessIncludesJSONLWhenPresent(t *testing.T) {96 tmp := t.TempDir()97 t.Chdir(tmp)9899 jsonlPath := filepath.Join(tmp, "sample.jsonl")100 m := model{101 state: exportRunningView,102 width: 100,103 height: 30,104 screens: map[viewState]screenModel{105 exportView: exportScreen{106 jobID: 8,107 export: exportModel{values: exportValuesFromSpec(packexport.DefaultSpec(tmp)), cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp))},108 },109 },110 }111112 got, _ := m.handleExportComplete(exportCompleteMsg{113 jobID: 8,114 result: packexport.Result{115 TotalCount: 10,116 PDFOutputPath: filepath.Join(tmp, "sample.pdf"),117 JSONLOutputPath: jsonlPath,118 },119 })120121 if !strings.Contains(got.notice.message, jsonlPath) {122 t.Fatalf("notice message = %q, want jsonl output path", got.notice.message)123 }124}125126func TestHandleExportCompleteFailureShowsErrorNotice(t *testing.T) {127 tmp := t.TempDir()128 t.Chdir(tmp)129130 m := model{131 state: exportRunningView,132 width: 100,133 height: 30,134 screens: map[viewState]screenModel{135 exportView: exportScreen{136 jobID: 9,137 export: exportModel{values: exportValuesFromSpec(packexport.DefaultSpec(tmp)), cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp))},138 },139 },140 }141142 got, _ := m.handleExportComplete(exportCompleteMsg{143 jobID: 9,144 err: assertiveError("boom"),145 })146147 if got.notice.level != noticeLevelError {148 t.Fatalf("notice level = %q, want %q", got.notice.level, noticeLevelError)149 }150 if got.state != exportView {151 t.Fatalf("state = %d, want %d", got.state, exportView)152 }153}154155func TestHandleExportSubmitRejectsInvalidVolumeInput(t *testing.T) {156 tmp := t.TempDir()157 spec := packexport.DefaultSpec(tmp)158 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 160)159 state.volumeInput.SetValue("abc")160161 m := model{162 state: exportView,163 width: 100,164 height: 30,165 screens: map[viewState]screenModel{166 exportView: exportScreen{export: state, width: 100, height: 30},167 },168 }169170 got, _ := m.handleExportSubmit()171172 if got.state != exportView {173 t.Fatalf("state = %d, want %d", got.state, exportView)174 }175 if got.notice.level != noticeLevelError {176 t.Fatalf("notice level = %q, want %q", got.notice.level, noticeLevelError)177 }178 if !strings.Contains(got.notice.message, "volume") {179 t.Fatalf("notice message = %q, want volume validation error", got.notice.message)180 }181}182183func TestExportHeaderInputUsesThreeLineTextarea(t *testing.T) {184 tmp := t.TempDir()185 spec := packexport.DefaultSpec(tmp)186 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 160)187188 if got := state.headerInput.Height(); got != 3 {189 t.Fatalf("headerInput.Height() = %d, want 3", got)190 }191}192193func TestExportCardLayoutTargetsDenseDesktop(t *testing.T) {194 tmp := t.TempDir()195 spec := packexport.DefaultSpec(tmp)196 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)197198 if got := state.cardColumns(190); got != 5 {199 t.Fatalf("cardColumns(190) = %d, want 5", got)200 }201 if got := state.visibleCardRows(190, 34); got < 3 {202 t.Fatalf("visibleCardRows(190, 34) = %d, want at least 3", got)203 }204}205206func TestRenderCardUsesCompactDifficultyPairs(t *testing.T) {207 tmp := t.TempDir()208 spec := packexport.DefaultSpec(tmp)209 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)210 state.focus = exportFocusCards211 state.cardIndex = findExportCardIndex(state.cards, puzzle.CanonicalGameID("Sudoku"))212 state.bucketIndex = 0213214 rendered := state.renderCard(state.cardIndex, exportCardWidth(190, state.cardColumns(190)))215216 if !strings.Contains(rendered, "▸") || !strings.Contains(rendered, "◂") {217 t.Fatal("expected selected difficulty game cursor markers in card")218 }219 if !strings.Contains(rendered, "E2") {220 t.Fatal("expected compact difficulty pair in card")221 }222}223224func TestExportCardArrowNavigationTraversesBucketsAcrossGames(t *testing.T) {225 tmp := t.TempDir()226 spec := packexport.DefaultSpec(tmp)227 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)228 state.focus = exportFocusCards229 state.cardIndex = 0230 state.bucketIndex = 2231232 if handled := state.handleNavigationKey(tea.KeyPressMsg{Code: tea.KeyRight}, 190, 30); !handled {233 t.Fatal("expected right arrow to be handled")234 }235 if state.cardIndex != 1 || state.bucketIndex != 0 {236 t.Fatalf("selection = (%d,%d), want (1,0)", state.cardIndex, state.bucketIndex)237 }238239 if handled := state.handleNavigationKey(tea.KeyPressMsg{Code: tea.KeyLeft}, 190, 30); !handled {240 t.Fatal("expected left arrow to be handled")241 }242 if state.cardIndex != 0 || state.bucketIndex != 2 {243 t.Fatalf("selection = (%d,%d), want (0,2)", state.cardIndex, state.bucketIndex)244 }245}246247func TestExportCardDigitsEditSelectedBucket(t *testing.T) {248 tmp := t.TempDir()249 spec := packexport.DefaultSpec(tmp)250 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)251 state.focus = exportFocusCards252 state.cardIndex = findExportCardIndex(state.cards, puzzle.CanonicalGameID("Nurikabe"))253 state.bucketIndex = 1254255 state.cards[state.cardIndex].Buckets[state.bucketIndex] = 0256 if handled := state.handleNavigationKey(tea.KeyPressMsg{Code: '4', Text: "4"}, 190, 30); !handled {257 t.Fatal("expected digit key to be handled")258 }259 if got := state.cards[state.cardIndex].Buckets[state.bucketIndex]; got != 4 {260 t.Fatalf("bucket count = %d, want 4", got)261 }262263 if handled := state.handleNavigationKey(tea.KeyPressMsg{Code: tea.KeyBackspace}, 190, 30); !handled {264 t.Fatal("expected backspace to be handled")265 }266 if got := state.cards[state.cardIndex].Buckets[state.bucketIndex]; got != 0 {267 t.Fatalf("bucket count after backspace = %d, want 0", got)268 }269}270271func TestRenderSettingsShowsFullJSONLOffLabel(t *testing.T) {272 tmp := t.TempDir()273 spec := packexport.DefaultSpec(tmp)274 spec.JSONLOutputPath = ""275 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)276277 rendered := state.renderSettings(190)278 if !strings.Contains(rendered, "JSONL") || !strings.Contains(rendered, "Off") {279 t.Fatal("expected JSONL toggle to render the full Off label in settings")280 }281}282283func TestRenderSummaryIncludesSelectedValue(t *testing.T) {284 tmp := t.TempDir()285 spec := packexport.DefaultSpec(tmp)286 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)287 state.focus = exportFocusCards288 state.cardIndex = findExportCardIndex(state.cards, puzzle.CanonicalGameID("Sudoku"))289 state.bucketIndex = 0290291 rendered := state.renderSummary(190)292 if !strings.Contains(rendered, "Sudoku / Easy = 2") {293 t.Fatal("expected summary to include selected game, difficulty, and value")294 }295}296297func TestExportMouseClickOnSummaryButtonSubmits(t *testing.T) {298 tmp := t.TempDir()299 spec := packexport.DefaultSpec(tmp)300 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)301302 screenWidth, screenHeight := 220, 40303 contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)304 metrics := state.contentMetrics(contentWidth, contentHeight)305 buttonWidth := lipgloss.Width(renderChoiceChip("Export", false))306 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))307308 click := tea.MouseClickMsg{309 X: contentX + metrics.Width - buttonWidth + 1,310 Y: contentY + metrics.SettingsHeight,311 }312 cmd, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)313 if !handled {314 t.Fatal("expected export button click to be handled")315 }316 if state.focus != exportFocusSubmit {317 t.Fatalf("focus = %v, want exportFocusSubmit after submit button click", state.focus)318 }319 if cmd == nil {320 t.Fatal("expected submit button click to return a non-nil command")321 }322}323324func TestExportMouseClickFocusesTextFields(t *testing.T) {325 tmp := t.TempDir()326 spec := packexport.DefaultSpec(tmp)327 spec.Seed = "sample-seed"328 spec.JSONLOutputPath = filepath.Join(tmp, "out", "sample.jsonl")329 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)330331 screenWidth, screenHeight := 220, 40332 contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)333 metrics := state.contentMetrics(contentWidth, contentHeight)334 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))335336 tests := []struct {337 name string338 rect exportRect339 focus exportFocus340 position func() int341 }{342 {"title", metrics.Settings.Title, exportFocusTitle, func() int { return state.titleInput.Position() }},343 {"advert", metrics.Settings.Advert, exportFocusAdvert, func() int { return state.advertInput.Position() }},344 {"seed", metrics.Settings.Seed, exportFocusSeed, func() int { return state.seedInput.Position() }},345 {"pdf", metrics.Settings.PDFPath, exportFocusPDFPath, func() int { return state.pdfPathInput.Position() }},346 {"jsonl path", metrics.Settings.JSONLPath, exportFocusJSONLPath, func() int { return state.jsonlPathInput.Position() }},347 }348349 for _, tt := range tests {350 click := tea.MouseClickMsg{351 X: contentX + tt.rect.X + min(tt.rect.W-2, 14),352 Y: contentY + tt.rect.Y + 1,353 }354 _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)355 if !handled {356 t.Fatalf("%s click was not handled", tt.name)357 }358 if state.focus != tt.focus {359 t.Fatalf("%s focus = %v, want %v", tt.name, state.focus, tt.focus)360 }361 if got := tt.position(); got <= 0 {362 t.Fatalf("%s cursor position = %d, want > 0", tt.name, got)363 }364 }365}366367func TestExportMouseClickRepositionsTextCursor(t *testing.T) {368 tmp := t.TempDir()369 spec := packexport.DefaultSpec(tmp)370 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)371372 screenWidth, screenHeight := 220, 40373 contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)374 metrics := state.contentMetrics(contentWidth, contentHeight)375 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))376377 first := tea.MouseClickMsg{378 X: contentX + metrics.Settings.Title.X + 8,379 Y: contentY + metrics.Settings.Title.Y + 1,380 }381 second := tea.MouseClickMsg{382 X: contentX + metrics.Settings.Title.X + 20,383 Y: contentY + metrics.Settings.Title.Y + 1,384 }385 _, handled := state.handleMouseClick(first, screenWidth, screenHeight, contentWidth, contentHeight)386 if !handled {387 t.Fatal("expected first title click to be handled")388 }389 firstPos := state.titleInput.Position()390 _, handled = state.handleMouseClick(second, screenWidth, screenHeight, contentWidth, contentHeight)391 if !handled {392 t.Fatal("expected second title click to be handled")393 }394 if state.titleInput.Position() <= firstPos {395 t.Fatalf("title cursor did not move forward: first=%d second=%d", firstPos, state.titleInput.Position())396 }397}398399func TestExportMouseClickFocusesHeaderTextarea(t *testing.T) {400 tmp := t.TempDir()401 spec := packexport.DefaultSpec(tmp)402 spec.Header = "line one\nline two\nline three"403 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)404405 screenWidth, screenHeight := 220, 40406 contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)407 metrics := state.contentMetrics(contentWidth, contentHeight)408 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))409410 click := tea.MouseClickMsg{411 X: contentX + metrics.Settings.Header.X + 18,412 Y: contentY + metrics.Settings.Header.Y + 4,413 }414 _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)415 if !handled {416 t.Fatal("expected header click to be handled")417 }418 if state.focus != exportFocusHeader {419 t.Fatalf("focus = %v, want exportFocusHeader", state.focus)420 }421 if got := state.headerInput.Line(); got != 2 {422 t.Fatalf("header line = %d, want 2", got)423 }424 if got := state.headerInput.LineInfo().CharOffset; got <= 0 {425 t.Fatalf("header char offset = %d, want > 0", got)426 }427}428429func TestExportMouseClickTogglesJSONLImmediately(t *testing.T) {430 tmp := t.TempDir()431 spec := packexport.DefaultSpec(tmp)432 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)433434 screenWidth, screenHeight := 220, 40435 contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)436 metrics := state.contentMetrics(contentWidth, contentHeight)437 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))438439 click := tea.MouseClickMsg{440 X: contentX + metrics.Settings.JSONL.X + 4,441 Y: contentY + metrics.Settings.JSONL.Y + 1,442 }443 _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)444 if !handled {445 t.Fatal("expected JSONL click to be handled")446 }447 if !state.values.JSONLEnabled {448 t.Fatal("expected JSONL click to toggle enabled")449 }450 if state.focus != exportFocusJSONLToggle {451 t.Fatalf("focus = %v, want exportFocusJSONLToggle", state.focus)452 }453}454455func TestExportMouseClickCyclesLayoutImmediately(t *testing.T) {456 tmp := t.TempDir()457 spec := packexport.DefaultSpec(tmp)458 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)459460 screenWidth, screenHeight := 220, 40461 contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)462 metrics := state.contentMetrics(contentWidth, contentHeight)463 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))464465 click := tea.MouseClickMsg{466 X: contentX + metrics.Settings.Layout.X + 5,467 Y: contentY + metrics.Settings.Layout.Y + 1,468 }469 _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)470 if !handled {471 t.Fatal("expected layout click to be handled")472 }473 if state.values.SheetLayout != "duplex-booklet" {474 t.Fatalf("layout = %q, want %q", state.values.SheetLayout, "duplex-booklet")475 }476}477478func TestExportMouseClickSelectsCardBucketWithoutChangingCount(t *testing.T) {479 tmp := t.TempDir()480 spec := packexport.DefaultSpec(tmp)481 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)482483 screenWidth, screenHeight := 220, 40484 contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)485 metrics := state.contentMetrics(contentWidth, contentHeight)486 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))487488 target := metrics.CardRects[1]489 before := state.cards[target.Index].Buckets[1]490 click := tea.MouseClickMsg{491 X: contentX + target.Buckets[1].X + 1,492 Y: contentY + target.Buckets[1].Y,493 }494 _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)495 if !handled {496 t.Fatal("expected card bucket click to be handled")497 }498 if state.cardIndex != target.Index || state.bucketIndex != 1 {499 t.Fatalf("selection = (%d,%d), want (%d,1)", state.cardIndex, state.bucketIndex, target.Index)500 }501 if got := state.cards[target.Index].Buckets[1]; got != before {502 t.Fatalf("bucket count changed on click: got %d want %d", got, before)503 }504}505506func TestExportMouseWheelScrollsCardRows(t *testing.T) {507 tmp := t.TempDir()508 spec := packexport.DefaultSpec(tmp)509 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)510511 screenWidth, screenHeight := 220, 30512 contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)513 metrics := state.contentMetrics(contentWidth, contentHeight)514 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))515516 if max((len(state.cards)+metrics.CardColumns-1)/metrics.CardColumns, 1) <= metrics.VisibleRows {517 t.Fatal("test setup expected scrollable card grid")518 }519520 wheel := tea.MouseWheelMsg{521 X: contentX + metrics.CardsRect.X + 2,522 Y: contentY + metrics.CardsRect.Y + 1,523 Button: tea.MouseWheelDown,524 }525 if handled := state.handleMouseWheel(wheel, screenWidth, screenHeight, contentWidth, contentHeight); !handled {526 t.Fatal("expected wheel event to be handled")527 }528 if state.cardRowOffset <= 0 {529 t.Fatalf("cardRowOffset = %d, want > 0", state.cardRowOffset)530 }531}532533func findExportCardIndex(cards []exportGameCard, gameID puzzle.GameID) int {534 for i, card := range cards {535 if card.GameID == gameID {536 return i537 }538 }539 return 0540}