clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

chore/regenerate-vhs-gifs
puzzletea / app / export_test.go
export_test.go 18.5K
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/packexport"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("Easy"): 4,29 },30 },31 },32 },33 width: 100,34 height: 30,35 }3637 next, _ := m.handleExportEnter()38 got := next.(model)3940 if got.state != exportView {41 t.Fatalf("state = %d, want %d", got.state, exportView)42 }43 if got.export.values.Title != "Saved Sampler" {44 t.Fatalf("title = %q, want %q", got.export.values.Title, "Saved Sampler")45 }46 if got.export.values.Volume != "3" {47 t.Fatalf("volume = %q, want %q", got.export.values.Volume, "3")48 }49 if got.export.cards[findExportCardIndex(got.export.cards, puzzle.CanonicalGameID("Sudoku"))].Buckets[0] != 4 {50 t.Fatal("expected persisted count to populate the form")51 }52 if !got.export.initialized {53 t.Fatal("expected export editor to be initialized")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 export: exportState{66 jobID: 7,67 initialized: true,68 values: exportValuesFromSpec(packexport.DefaultSpec(tmp)),69 cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp)),70 },71 }7273 next, _ := m.handleExportComplete(exportCompleteMsg{74 jobID: 7,75 result: packexport.Result{76 TotalCount: 10,77 PDFOutputPath: filepath.Join(tmp, "sample.pdf"),78 },79 })80 got := next.(model)8182 if got.state != exportView {83 t.Fatalf("state = %d, want %d", got.state, exportView)84 }85 if got.notice.level != noticeLevelSuccess {86 t.Fatalf("notice level = %q, want %q", got.notice.level, noticeLevelSuccess)87 }88 if got.notice.message == "" {89 t.Fatal("expected success notice")90 }91 if strings.Contains(got.notice.message, ".jsonl") {92 t.Fatalf("notice message = %q, did not expect jsonl path when jsonl export is disabled", got.notice.message)93 }94}9596func TestHandleExportCompleteSuccessIncludesJSONLWhenPresent(t *testing.T) {97 tmp := t.TempDir()98 t.Chdir(tmp)99100 jsonlPath := filepath.Join(tmp, "sample.jsonl")101 m := model{102 state: exportRunningView,103 width: 100,104 height: 30,105 export: exportState{106 jobID: 8,107 initialized: true,108 values: exportValuesFromSpec(packexport.DefaultSpec(tmp)),109 cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp)),110 },111 }112113 next, _ := m.handleExportComplete(exportCompleteMsg{114 jobID: 8,115 result: packexport.Result{116 TotalCount: 10,117 PDFOutputPath: filepath.Join(tmp, "sample.pdf"),118 JSONLOutputPath: jsonlPath,119 },120 })121 got := next.(model)122123 if !strings.Contains(got.notice.message, jsonlPath) {124 t.Fatalf("notice message = %q, want jsonl output path", got.notice.message)125 }126}127128func TestHandleExportCompleteFailureShowsErrorNotice(t *testing.T) {129 tmp := t.TempDir()130 t.Chdir(tmp)131132 m := model{133 state: exportRunningView,134 width: 100,135 height: 30,136 export: exportState{137 jobID: 9,138 initialized: true,139 values: exportValuesFromSpec(packexport.DefaultSpec(tmp)),140 cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp)),141 },142 }143144 next, _ := m.handleExportComplete(exportCompleteMsg{145 jobID: 9,146 err: assertiveError("boom"),147 })148 got := next.(model)149150 if got.notice.level != noticeLevelError {151 t.Fatalf("notice level = %q, want %q", got.notice.level, noticeLevelError)152 }153 if got.state != exportView {154 t.Fatalf("state = %d, want %d", got.state, exportView)155 }156}157158func TestHandleExportSubmitRejectsInvalidVolumeInput(t *testing.T) {159 tmp := t.TempDir()160 spec := packexport.DefaultSpec(tmp)161 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 160)162 state.volumeInput.SetValue("abc")163164 m := model{165 state: exportView,166 width: 100,167 height: 30,168 export: state,169 }170171 next, _ := m.handleExportSubmit()172 got := next.(model)173174 if got.state != exportView {175 t.Fatalf("state = %d, want %d", got.state, exportView)176 }177 if got.notice.level != noticeLevelError {178 t.Fatalf("notice level = %q, want %q", got.notice.level, noticeLevelError)179 }180 if !strings.Contains(got.notice.message, "volume") {181 t.Fatalf("notice message = %q, want volume validation error", got.notice.message)182 }183}184185func TestExportHeaderInputUsesThreeLineTextarea(t *testing.T) {186 tmp := t.TempDir()187 spec := packexport.DefaultSpec(tmp)188 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 160)189190 if got := state.headerInput.Height(); got != 3 {191 t.Fatalf("headerInput.Height() = %d, want 3", got)192 }193}194195func TestExportCardLayoutTargetsDenseDesktop(t *testing.T) {196 tmp := t.TempDir()197 spec := packexport.DefaultSpec(tmp)198 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)199200 if got := state.cardColumns(190); got != 5 {201 t.Fatalf("cardColumns(190) = %d, want 5", got)202 }203 if got := state.visibleCardRows(190, 34); got < 3 {204 t.Fatalf("visibleCardRows(190, 34) = %d, want at least 3", got)205 }206}207208func TestRenderCardUsesCompactDifficultyPairs(t *testing.T) {209 tmp := t.TempDir()210 spec := packexport.DefaultSpec(tmp)211 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)212 state.focus = exportFocusCards213 state.cardIndex = findExportCardIndex(state.cards, puzzle.CanonicalGameID("Sudoku"))214 state.bucketIndex = 0215216 rendered := state.renderCard(state.cardIndex, exportCardWidth(190, state.cardColumns(190)))217218 if !strings.Contains(rendered, "▸") || !strings.Contains(rendered, "◂") {219 t.Fatal("expected selected difficulty game cursor markers in card")220 }221 if !strings.Contains(rendered, "E2") {222 t.Fatal("expected compact difficulty pair in card")223 }224}225226func TestExportCardArrowNavigationTraversesBucketsAcrossGames(t *testing.T) {227 tmp := t.TempDir()228 spec := packexport.DefaultSpec(tmp)229 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)230 state.focus = exportFocusCards231 state.cardIndex = 0232 state.bucketIndex = 2233234 if handled := state.handleNavigationKey(tea.KeyPressMsg{Code: tea.KeyRight}, 190, 30); !handled {235 t.Fatal("expected right arrow to be handled")236 }237 if state.cardIndex != 1 || state.bucketIndex != 0 {238 t.Fatalf("selection = (%d,%d), want (1,0)", state.cardIndex, state.bucketIndex)239 }240241 if handled := state.handleNavigationKey(tea.KeyPressMsg{Code: tea.KeyLeft}, 190, 30); !handled {242 t.Fatal("expected left arrow to be handled")243 }244 if state.cardIndex != 0 || state.bucketIndex != 2 {245 t.Fatalf("selection = (%d,%d), want (0,2)", state.cardIndex, state.bucketIndex)246 }247}248249func TestExportCardDigitsEditSelectedBucket(t *testing.T) {250 tmp := t.TempDir()251 spec := packexport.DefaultSpec(tmp)252 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)253 state.focus = exportFocusCards254 state.cardIndex = findExportCardIndex(state.cards, puzzle.CanonicalGameID("Nurikabe"))255 state.bucketIndex = 1256257 state.cards[state.cardIndex].Buckets[state.bucketIndex] = 0258 if handled := state.handleNavigationKey(tea.KeyPressMsg{Code: '4', Text: "4"}, 190, 30); !handled {259 t.Fatal("expected digit key to be handled")260 }261 if got := state.cards[state.cardIndex].Buckets[state.bucketIndex]; got != 4 {262 t.Fatalf("bucket count = %d, want 4", got)263 }264265 if handled := state.handleNavigationKey(tea.KeyPressMsg{Code: tea.KeyBackspace}, 190, 30); !handled {266 t.Fatal("expected backspace to be handled")267 }268 if got := state.cards[state.cardIndex].Buckets[state.bucketIndex]; got != 0 {269 t.Fatalf("bucket count after backspace = %d, want 0", got)270 }271}272273func TestRenderSettingsShowsFullJSONLOffLabel(t *testing.T) {274 tmp := t.TempDir()275 spec := packexport.DefaultSpec(tmp)276 spec.JSONLOutputPath = ""277 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)278279 rendered := state.renderSettings(190)280 if !strings.Contains(rendered, "JSONL") || !strings.Contains(rendered, "Off") {281 t.Fatal("expected JSONL toggle to render the full Off label in settings")282 }283}284285func TestRenderSummaryIncludesSelectedValue(t *testing.T) {286 tmp := t.TempDir()287 spec := packexport.DefaultSpec(tmp)288 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)289 state.focus = exportFocusCards290 state.cardIndex = findExportCardIndex(state.cards, puzzle.CanonicalGameID("Sudoku"))291 state.bucketIndex = 0292293 rendered := state.renderSummary(190)294 if !strings.Contains(rendered, "Sudoku / Easy = 2") {295 t.Fatal("expected summary to include selected game, difficulty, and value")296 }297}298299func TestExportMouseClickOnSummaryButtonSubmits(t *testing.T) {300 tmp := t.TempDir()301 spec := packexport.DefaultSpec(tmp)302 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 190)303304 screenWidth, screenHeight := 220, 40305 contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)306 metrics := state.contentMetrics(contentWidth, contentHeight)307 buttonWidth := lipgloss.Width(renderChoiceChip("Export", false))308 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))309310 click := tea.MouseClickMsg{311 X: contentX + metrics.Width - buttonWidth + 1,312 Y: contentY + metrics.SettingsHeight,313 }314 _, action, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)315 if !handled {316 t.Fatal("expected export button click to be handled")317 }318 if _, ok := action.(exportSubmitAction); !ok {319 t.Fatalf("action = %T, want exportSubmitAction", action)320 }321}322323func TestExportMouseClickFocusesTextFields(t *testing.T) {324 tmp := t.TempDir()325 spec := packexport.DefaultSpec(tmp)326 spec.Seed = "sample-seed"327 spec.JSONLOutputPath = filepath.Join(tmp, "out", "sample.jsonl")328 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)329330 screenWidth, screenHeight := 220, 40331 contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)332 metrics := state.contentMetrics(contentWidth, contentHeight)333 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))334335 tests := []struct {336 name string337 rect exportRect338 focus exportFocus339 position func() int340 }{341 {"title", metrics.Settings.Title, exportFocusTitle, func() int { return state.titleInput.Position() }},342 {"advert", metrics.Settings.Advert, exportFocusAdvert, func() int { return state.advertInput.Position() }},343 {"seed", metrics.Settings.Seed, exportFocusSeed, func() int { return state.seedInput.Position() }},344 {"pdf", metrics.Settings.PDFPath, exportFocusPDFPath, func() int { return state.pdfPathInput.Position() }},345 {"jsonl path", metrics.Settings.JSONLPath, exportFocusJSONLPath, func() int { return state.jsonlPathInput.Position() }},346 }347348 for _, tt := range tests {349 click := tea.MouseClickMsg{350 X: contentX + tt.rect.X + min(tt.rect.W-2, 14),351 Y: contentY + tt.rect.Y + 1,352 }353 _, _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)354 if !handled {355 t.Fatalf("%s click was not handled", tt.name)356 }357 if state.focus != tt.focus {358 t.Fatalf("%s focus = %v, want %v", tt.name, state.focus, tt.focus)359 }360 if got := tt.position(); got <= 0 {361 t.Fatalf("%s cursor position = %d, want > 0", tt.name, got)362 }363 }364}365366func TestExportMouseClickRepositionsTextCursor(t *testing.T) {367 tmp := t.TempDir()368 spec := packexport.DefaultSpec(tmp)369 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)370371 screenWidth, screenHeight := 220, 40372 contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)373 metrics := state.contentMetrics(contentWidth, contentHeight)374 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))375376 first := tea.MouseClickMsg{377 X: contentX + metrics.Settings.Title.X + 8,378 Y: contentY + metrics.Settings.Title.Y + 1,379 }380 second := tea.MouseClickMsg{381 X: contentX + metrics.Settings.Title.X + 20,382 Y: contentY + metrics.Settings.Title.Y + 1,383 }384 _, _, handled := state.handleMouseClick(first, screenWidth, screenHeight, contentWidth, contentHeight)385 if !handled {386 t.Fatal("expected first title click to be handled")387 }388 firstPos := state.titleInput.Position()389 _, _, handled = state.handleMouseClick(second, screenWidth, screenHeight, contentWidth, contentHeight)390 if !handled {391 t.Fatal("expected second title click to be handled")392 }393 if state.titleInput.Position() <= firstPos {394 t.Fatalf("title cursor did not move forward: first=%d second=%d", firstPos, state.titleInput.Position())395 }396}397398func TestExportMouseClickFocusesHeaderTextarea(t *testing.T) {399 tmp := t.TempDir()400 spec := packexport.DefaultSpec(tmp)401 spec.Header = "line one\nline two\nline three"402 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)403404 screenWidth, screenHeight := 220, 40405 contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)406 metrics := state.contentMetrics(contentWidth, contentHeight)407 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))408409 click := tea.MouseClickMsg{410 X: contentX + metrics.Settings.Header.X + 18,411 Y: contentY + metrics.Settings.Header.Y + 4,412 }413 _, _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)414 if !handled {415 t.Fatal("expected header click to be handled")416 }417 if state.focus != exportFocusHeader {418 t.Fatalf("focus = %v, want exportFocusHeader", state.focus)419 }420 if got := state.headerInput.Line(); got != 2 {421 t.Fatalf("header line = %d, want 2", got)422 }423 if got := state.headerInput.LineInfo().CharOffset; got <= 0 {424 t.Fatalf("header char offset = %d, want > 0", got)425 }426}427428func TestExportMouseClickTogglesJSONLImmediately(t *testing.T) {429 tmp := t.TempDir()430 spec := packexport.DefaultSpec(tmp)431 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)432433 screenWidth, screenHeight := 220, 40434 contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)435 metrics := state.contentMetrics(contentWidth, contentHeight)436 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))437438 click := tea.MouseClickMsg{439 X: contentX + metrics.Settings.JSONL.X + 4,440 Y: contentY + metrics.Settings.JSONL.Y + 1,441 }442 _, _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)443 if !handled {444 t.Fatal("expected JSONL click to be handled")445 }446 if !state.values.JSONLEnabled {447 t.Fatal("expected JSONL click to toggle enabled")448 }449 if state.focus != exportFocusJSONLToggle {450 t.Fatalf("focus = %v, want exportFocusJSONLToggle", state.focus)451 }452}453454func TestExportMouseClickCyclesLayoutImmediately(t *testing.T) {455 tmp := t.TempDir()456 spec := packexport.DefaultSpec(tmp)457 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)458459 screenWidth, screenHeight := 220, 40460 contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)461 metrics := state.contentMetrics(contentWidth, contentHeight)462 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))463464 click := tea.MouseClickMsg{465 X: contentX + metrics.Settings.Layout.X + 5,466 Y: contentY + metrics.Settings.Layout.Y + 1,467 }468 _, _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)469 if !handled {470 t.Fatal("expected layout click to be handled")471 }472 if state.values.SheetLayout != "duplex-booklet" {473 t.Fatalf("layout = %q, want %q", state.values.SheetLayout, "duplex-booklet")474 }475}476477func TestExportMouseClickSelectsCardBucketWithoutChangingCount(t *testing.T) {478 tmp := t.TempDir()479 spec := packexport.DefaultSpec(tmp)480 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)481482 screenWidth, screenHeight := 220, 40483 contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)484 metrics := state.contentMetrics(contentWidth, contentHeight)485 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))486487 target := metrics.CardRects[1]488 before := state.cards[target.Index].Buckets[1]489 click := tea.MouseClickMsg{490 X: contentX + target.Buckets[1].X + 1,491 Y: contentY + target.Buckets[1].Y,492 }493 _, _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)494 if !handled {495 t.Fatal("expected card bucket click to be handled")496 }497 if state.cardIndex != target.Index || state.bucketIndex != 1 {498 t.Fatalf("selection = (%d,%d), want (%d,1)", state.cardIndex, state.bucketIndex, target.Index)499 }500 if got := state.cards[target.Index].Buckets[1]; got != before {501 t.Fatalf("bucket count changed on click: got %d want %d", got, before)502 }503}504505func TestExportMouseWheelScrollsCardRows(t *testing.T) {506 tmp := t.TempDir()507 spec := packexport.DefaultSpec(tmp)508 state := buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), 220)509510 screenWidth, screenHeight := 220, 30511 contentWidth, contentHeight := exportContentBounds(screenWidth, screenHeight)512 metrics := state.contentMetrics(contentWidth, contentHeight)513 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, state.view(contentWidth, contentHeight))514515 if max((len(state.cards)+metrics.CardColumns-1)/metrics.CardColumns, 1) <= metrics.VisibleRows {516 t.Fatal("test setup expected scrollable card grid")517 }518519 wheel := tea.MouseWheelMsg{520 X: contentX + metrics.CardsRect.X + 2,521 Y: contentY + metrics.CardsRect.Y + 1,522 Button: tea.MouseWheelDown,523 }524 if handled := state.handleMouseWheel(wheel, screenWidth, screenHeight, contentWidth, contentHeight); !handled {525 t.Fatal("expected wheel event to be handled")526 }527 if state.cardRowOffset <= 0 {528 t.Fatalf("cardRowOffset = %d, want > 0", state.cardRowOffset)529 }530}531532func findExportCardIndex(cards []exportGameCard, gameID puzzle.GameID) int {533 for i, card := range cards {534 if card.GameID == gameID {535 return i536 }537 }538 return 0539}