clone

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

chore/regenerate-vhs-gifs
puzzletea / pdfexport / jsonl_test.go
jsonl_test.go 16.7K
1package pdfexport23import (4 "encoding/json"5 "os"6 "path/filepath"7 "strings"8 "sync"9 "testing"1011 "codeberg.org/go-pdf/fpdf"12)1314func TestParseJSONLFile(t *testing.T) {15 path := filepath.Join(t.TempDir(), "pack.jsonl")16 record := JSONLRecord{17 Schema: ExportSchemaV1,18 Pack: JSONLPackMeta{19 Generated: "2026-02-22T10:00:00Z",20 Version: "v-test",21 Category: "Nonogram",22 ModeSelection: "Mini",23 Count: 1,24 Seed: "seed-1",25 },26 Puzzle: JSONLPuzzle{27 Index: 1,28 Name: "ember-newt",29 Game: "Nonogram",30 Mode: "Mini",31 Save: json.RawMessage(`{"width":2,"height":2,"row-hints":[[1],[1]],"col-hints":[[1],[1]],"state":" \n "}`),32 },33 }34 writeSingleJSONLRecord(t, path, record)3536 doc, err := ParseJSONLFile(path)37 if err != nil {38 t.Fatal(err)39 }40 if got, want := len(doc.Puzzles), 1; got != want {41 t.Fatalf("puzzles = %d, want %d", got, want)42 }43 payload, ok := doc.Puzzles[0].PrintPayload.(*NonogramData)44 if !ok || payload == nil {45 t.Fatal("expected nonogram print payload from save hydration")46 }47}4849func TestParseJSONLFileHydratesNonogramFromSaveWithoutSnippet(t *testing.T) {50 path := filepath.Join(t.TempDir(), "nonogram-save-only.jsonl")51 record := JSONLRecord{52 Schema: ExportSchemaV1,53 Pack: JSONLPackMeta{54 Generated: "2026-02-22T10:00:00Z",55 Version: "v-test",56 Category: "Nonogram",57 ModeSelection: "Mini",58 Count: 1,59 },60 Puzzle: JSONLPuzzle{61 Index: 1,62 Name: "fern-owl",63 Game: "Nonogram",64 Mode: "Mini",65 Save: json.RawMessage(`{"width":2,"height":2,"row-hints":[[1],[1]],"col-hints":[[1],[1]],"state":" \n "}`),66 },67 }68 writeSingleJSONLRecord(t, path, record)6970 doc, err := ParseJSONLFile(path)71 if err != nil {72 t.Fatal(err)73 }74 if got, want := len(doc.Puzzles), 1; got != want {75 t.Fatalf("puzzles = %d, want %d", got, want)76 }77 payload, ok := doc.Puzzles[0].PrintPayload.(*NonogramData)78 if !ok || payload == nil {79 t.Fatal("expected nonogram print payload from save hydration")80 }81}8283func TestParseJSONLFileRejectsNonJSONLExtension(t *testing.T) {84 path := filepath.Join(t.TempDir(), "pack.md")85 if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {86 t.Fatal(err)87 }8889 _, err := ParseJSONLFile(path)90 if err == nil {91 t.Fatal("expected extension validation error")92 }93 if !strings.Contains(err.Error(), "expected .jsonl input") {94 t.Fatalf("unexpected error: %v", err)95 }96}9798func TestParseJSONLFileRejectsFilesWithoutPrintablePuzzles(t *testing.T) {99 path := filepath.Join(t.TempDir(), "lights.jsonl")100 record := JSONLRecord{101 Schema: ExportSchemaV1,102 Pack: JSONLPackMeta{103 Generated: "2026-02-22T10:00:00Z",104 Version: "v-test",105 Category: "Lights Out",106 ModeSelection: "Standard",107 Count: 1,108 },109 Puzzle: JSONLPuzzle{110 Index: 1,111 Name: "glow-shore",112 Game: "Lights Out",113 Mode: "Standard",114 Save: json.RawMessage(`{"size":5}`),115 },116 }117 writeSingleJSONLRecord(t, path, record)118119 _, err := ParseJSONLFile(path)120 if err == nil {121 t.Fatal("expected no-printable-puzzles error")122 }123 if !strings.Contains(err.Error(), "no printable puzzles found") {124 t.Fatalf("unexpected error: %v", err)125 }126 if !strings.Contains(err.Error(), "Lights Out") {127 t.Fatalf("expected unsupported category in error, got: %v", err)128 }129}130131func TestParseJSONLFileHydratesTakuzuFromSave(t *testing.T) {132 path := filepath.Join(t.TempDir(), "takuzu-pack.jsonl")133 record := JSONLRecord{134 Schema: ExportSchemaV1,135 Pack: JSONLPackMeta{136 Generated: "2026-02-22T10:00:00Z",137 Version: "v-test",138 Category: "Takuzu",139 ModeSelection: "Beginner",140 Count: 1,141 Seed: "seed-2",142 },143 Puzzle: JSONLPuzzle{144 Index: 1,145 Name: "binary-wave",146 Game: "Takuzu",147 Mode: "Beginner",148 Save: json.RawMessage(`{"size":2,"state":"01\n10","provided":"##\n#."}`),149 },150 }151 writeSingleJSONLRecord(t, path, record)152153 doc, err := ParseJSONLFile(path)154 if err != nil {155 t.Fatal(err)156 }157 if got, want := len(doc.Puzzles), 1; got != want {158 t.Fatalf("puzzles = %d, want %d", got, want)159 }160 payload, ok := doc.Puzzles[0].PrintPayload.(*TakuzuData)161 if !ok || payload == nil {162 t.Fatal("expected takuzu print payload from save hydration")163 }164 if got, want := payload.Givens[1][1], ""; got != want {165 t.Fatalf("takuzu row 1 col 1 = %q, want empty", got)166 }167}168169func TestParseJSONLFileHydratesTakuzuPlusFromSave(t *testing.T) {170 path := filepath.Join(t.TempDir(), "takuzuplus-pack.jsonl")171 record := JSONLRecord{172 Schema: ExportSchemaV1,173 Pack: JSONLPackMeta{174 Generated: "2026-02-22T10:00:00Z",175 Version: "v-test",176 Category: "Takuzu+",177 ModeSelection: "Beginner",178 Count: 1,179 },180 Puzzle: JSONLPuzzle{181 Index: 1,182 Name: "binary-link",183 Game: "Takuzu+",184 Mode: "Beginner",185 Save: json.RawMessage(`{"size":4,"state":"01..\n10..\n0011\n1111","provided":"#...\n.##.\n####\n#...","horizontal_relations":"=x.\n...\n..=\n...","vertical_relations":"x...\n.=..\n...x"}`),186 },187 }188 writeSingleJSONLRecord(t, path, record)189190 doc, err := ParseJSONLFile(path)191 if err != nil {192 t.Fatal(err)193 }194 if got, want := len(doc.Puzzles), 1; got != want {195 t.Fatalf("puzzles = %d, want %d", got, want)196 }197 payload, ok := doc.Puzzles[0].PrintPayload.(*TakuzuData)198 if !ok || payload == nil {199 t.Fatal("expected takuzu+ print payload from save hydration")200 }201 if got, want := payload.HorizontalRelations[0][0], "="; got != want {202 t.Fatalf("horizontal relation = %q, want %q", got, want)203 }204 if got, want := payload.VerticalRelations[1][1], "="; got != want {205 t.Fatalf("vertical relation = %q, want %q", got, want)206 }207}208209func TestParseJSONLFileHydratesSudokuFromSave(t *testing.T) {210 path := filepath.Join(t.TempDir(), "sudoku.jsonl")211 record := JSONLRecord{212 Schema: ExportSchemaV1,213 Pack: JSONLPackMeta{214 Generated: "2026-02-22T10:00:00Z",215 Version: "v-test",216 Category: "Sudoku",217 ModeSelection: "Easy",218 Count: 1,219 },220 Puzzle: JSONLPuzzle{221 Index: 1,222 Name: "moss-pine",223 Game: "Sudoku",224 Mode: "Easy",225 Save: json.RawMessage(`{"grid":"500000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":[{"x":0,"y":0,"v":5}]}`),226 },227 }228 writeSingleJSONLRecord(t, path, record)229230 doc, err := ParseJSONLFile(path)231 if err != nil {232 t.Fatal(err)233 }234 if got, want := len(doc.Puzzles), 1; got != want {235 t.Fatalf("puzzles = %d, want %d", got, want)236 }237 payload, ok := doc.Puzzles[0].PrintPayload.(*SudokuData)238 if !ok || payload == nil {239 t.Fatal("expected sudoku print payload from save hydration")240 }241 if got, want := payload.Givens[0][0], 5; got != want {242 t.Fatalf("sudoku givens[0][0] = %d, want %d", got, want)243 }244}245246func TestParseJSONLFileHydratesSudokuRGBFromSave(t *testing.T) {247 path := filepath.Join(t.TempDir(), "sudoku-rgb.jsonl")248 record := JSONLRecord{249 Schema: ExportSchemaV1,250 Pack: JSONLPackMeta{251 Generated: "2026-02-22T10:00:00Z",252 Version: "v-test",253 Category: "Sudoku RGB",254 ModeSelection: "Easy",255 Count: 1,256 },257 Puzzle: JSONLPuzzle{258 Index: 1,259 Name: "ember-wave",260 Game: "Sudoku RGB",261 Mode: "Easy",262 Save: json.RawMessage(`{"grid":"100000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":[{"x":0,"y":0,"v":1},{"x":1,"y":0,"v":4},{"x":2,"y":0,"v":3}]}`),263 },264 }265 writeSingleJSONLRecord(t, path, record)266267 doc, err := ParseJSONLFile(path)268 if err != nil {269 t.Fatal(err)270 }271 if got, want := len(doc.Puzzles), 1; got != want {272 t.Fatalf("puzzles = %d, want %d", got, want)273 }274 payload, ok := doc.Puzzles[0].PrintPayload.(*SudokuData)275 if !ok || payload == nil {276 t.Fatal("expected sudoku rgb print payload from save hydration")277 }278 if got, want := payload.Givens[0][0], 1; got != want {279 t.Fatalf("sudoku rgb givens[0][0] = %d, want %d", got, want)280 }281 if got := payload.Givens[0][1]; got != 0 {282 t.Fatalf("sudoku rgb givens[0][1] = %d, want 0", got)283 }284}285286func TestParseJSONLFileHydratesWordSearchFromSave(t *testing.T) {287 path := filepath.Join(t.TempDir(), "wordsearch.jsonl")288 record := JSONLRecord{289 Schema: ExportSchemaV1,290 Pack: JSONLPackMeta{291 Generated: "2026-02-22T10:00:00Z",292 Version: "v-test",293 Category: "Word Search",294 ModeSelection: "Standard",295 Count: 1,296 },297 Puzzle: JSONLPuzzle{298 Index: 1,299 Name: "sage-sky",300 Game: "Word Search",301 Mode: "Standard",302 Save: json.RawMessage(`{"width":3,"height":3,"grid":"abc\ndef\nghi","words":[{"text":"ace"},{"text":"fig"}]}`),303 },304 }305 writeSingleJSONLRecord(t, path, record)306307 doc, err := ParseJSONLFile(path)308 if err != nil {309 t.Fatal(err)310 }311 if got, want := len(doc.Puzzles), 1; got != want {312 t.Fatalf("puzzles = %d, want %d", got, want)313 }314 payload, ok := doc.Puzzles[0].PrintPayload.(*WordSearchData)315 if !ok || payload == nil {316 t.Fatal("expected word search print payload from save hydration")317 }318 if got, want := len(payload.Words), 2; got != want {319 t.Fatalf("word count = %d, want %d", got, want)320 }321 if got, want := payload.Words[0], "ACE"; got != want {322 t.Fatalf("first word = %q, want %q", got, want)323 }324}325326func TestParseJSONLFileHydratesNurikabeFromSave(t *testing.T) {327 path := filepath.Join(t.TempDir(), "nurikabe.jsonl")328 record := JSONLRecord{329 Schema: ExportSchemaV1,330 Pack: JSONLPackMeta{331 Generated: "2026-02-22T10:00:00Z",332 Version: "v-test",333 Category: "Nurikabe",334 ModeSelection: "Mini",335 Count: 1,336 },337 Puzzle: JSONLPuzzle{338 Index: 1,339 Name: "mist-pond",340 Game: "Nurikabe",341 Mode: "Mini",342 Save: json.RawMessage(`{"width":2,"height":2,"clues":"1,0\n0,2","marks":"??\n??"}`),343 },344 }345 writeSingleJSONLRecord(t, path, record)346347 doc, err := ParseJSONLFile(path)348 if err != nil {349 t.Fatal(err)350 }351 if got, want := len(doc.Puzzles), 1; got != want {352 t.Fatalf("puzzles = %d, want %d", got, want)353 }354 payload, ok := doc.Puzzles[0].PrintPayload.(*NurikabeData)355 if !ok || payload == nil {356 t.Fatal("expected nurikabe print payload from save hydration")357 }358 if got, want := payload.Clues[1][1], 2; got != want {359 t.Fatalf("nurikabe clues[1][1] = %d, want %d", got, want)360 }361}362363func TestParseJSONLFileHydratesShikakuFromSave(t *testing.T) {364 path := filepath.Join(t.TempDir(), "shikaku.jsonl")365 record := JSONLRecord{366 Schema: ExportSchemaV1,367 Pack: JSONLPackMeta{368 Generated: "2026-02-22T10:00:00Z",369 Version: "v-test",370 Category: "Shikaku",371 ModeSelection: "Mini",372 Count: 1,373 },374 Puzzle: JSONLPuzzle{375 Index: 1,376 Name: "ridge-glen",377 Game: "Shikaku",378 Mode: "Mini",379 Save: json.RawMessage(`{"width":2,"height":2,"clues":[{"x":0,"y":0,"value":1},{"x":1,"y":1,"value":4}]}`),380 },381 }382 writeSingleJSONLRecord(t, path, record)383384 doc, err := ParseJSONLFile(path)385 if err != nil {386 t.Fatal(err)387 }388 if got, want := len(doc.Puzzles), 1; got != want {389 t.Fatalf("puzzles = %d, want %d", got, want)390 }391 payload, ok := doc.Puzzles[0].PrintPayload.(*ShikakuData)392 if !ok || payload == nil {393 t.Fatal("expected shikaku print payload from save hydration")394 }395 if got, want := payload.Clues[1][1], 4; got != want {396 t.Fatalf("shikaku clues[1][1] = %d, want %d", got, want)397 }398}399400func TestParseJSONLFileHydratesHashiFromSave(t *testing.T) {401 path := filepath.Join(t.TempDir(), "hashi.jsonl")402 record := JSONLRecord{403 Schema: ExportSchemaV1,404 Pack: JSONLPackMeta{405 Generated: "2026-02-22T10:00:00Z",406 Version: "v-test",407 Category: "Hashiwokakero",408 ModeSelection: "Standard",409 Count: 1,410 },411 Puzzle: JSONLPuzzle{412 Index: 1,413 Name: "reef-palm",414 Game: "Hashiwokakero",415 Mode: "Standard",416 Save: json.RawMessage(`{"width":7,"height":7,"islands":[{"x":0,"y":0,"required":3},{"x":6,"y":6,"required":2}],"bridges":[]}`),417 },418 }419 writeSingleJSONLRecord(t, path, record)420421 doc, err := ParseJSONLFile(path)422 if err != nil {423 t.Fatal(err)424 }425 if got, want := len(doc.Puzzles), 1; got != want {426 t.Fatalf("puzzles = %d, want %d", got, want)427 }428 payload, ok := doc.Puzzles[0].PrintPayload.(*HashiData)429 if !ok || payload == nil {430 t.Fatal("expected hashi print payload from save hydration")431 }432 if got, want := len(payload.Islands), 2; got != want {433 t.Fatalf("hashi island count = %d, want %d", got, want)434 }435}436437func TestParseJSONLFileMetadataFromFirstPrintableRecord(t *testing.T) {438 path := filepath.Join(t.TempDir(), "mixed.jsonl")439440 records := []JSONLRecord{441 {442 Schema: ExportSchemaV1,443 Pack: JSONLPackMeta{444 Generated: "2026-02-22T10:00:00Z",445 Version: "v-test",446 Category: "Lights Out",447 ModeSelection: "Standard",448 Count: 2,449 Seed: "seed-unsupported",450 },451 Puzzle: JSONLPuzzle{452 Index: 1,453 Name: "glow-shore",454 Game: "Lights Out",455 Mode: "Standard",456 Save: json.RawMessage(`{"size":5}`),457 },458 },459 {460 Schema: ExportSchemaV1,461 Pack: JSONLPackMeta{462 Generated: "2026-02-22T10:00:00Z",463 Version: "v-test",464 Category: "Sudoku",465 ModeSelection: "Easy",466 Count: 2,467 Seed: "seed-supported",468 },469 Puzzle: JSONLPuzzle{470 Index: 2,471 Name: "moss-pine",472 Game: "Sudoku",473 Mode: "Easy",474 Save: json.RawMessage(`{"provided":[{"x":0,"y":0,"v":5}]}`),475 },476 },477 }478479 var lines []byte480 for _, record := range records {481 line, err := json.Marshal(record)482 if err != nil {483 t.Fatal(err)484 }485 lines = append(lines, line...)486 lines = append(lines, '\n')487 }488 if err := os.WriteFile(path, lines, 0o644); err != nil {489 t.Fatal(err)490 }491492 doc, err := ParseJSONLFile(path)493 if err != nil {494 t.Fatalf("expected parse success, got: %v", err)495 }496 if got, want := len(doc.Puzzles), 1; got != want {497 t.Fatalf("puzzles = %d, want %d", got, want)498 }499 if got, want := doc.Metadata.Category, "Sudoku"; got != want {500 t.Fatalf("metadata category = %q, want %q", got, want)501 }502 if got, want := doc.Metadata.ModeSelection, "Easy"; got != want {503 t.Fatalf("metadata mode = %q, want %q", got, want)504 }505 if got, want := doc.Metadata.Seed, "seed-supported"; got != want {506 t.Fatalf("metadata seed = %q, want %q", got, want)507 }508}509510func writeSingleJSONLRecord(t *testing.T, path string, record JSONLRecord) {511 t.Helper()512 data, err := json.Marshal(record)513 if err != nil {514 t.Fatal(err)515 }516 if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {517 t.Fatal(err)518 }519}520521var registerJSONLAdaptersOnce sync.Once522523func init() {524 ensureJSONLTestAdapters()525}526527func ensureJSONLTestAdapters() {528 registerJSONLAdaptersOnce.Do(func() {529 register := func(category string, build func(save []byte) (any, error), aliases ...string) {530 RegisterPrintAdapter(jsonlTestAdapter{531 category: category,532 aliases: aliases,533 build: build,534 })535 }536537 register("Nonogram", buildPayloadAdapter("Nonogram", func(save []byte) (any, error) {538 return ParseNonogramPrintData(save)539 }), "nonogram")540 register("Takuzu", buildPayloadAdapter("Takuzu", func(save []byte) (any, error) {541 return ParseTakuzuPrintData(save)542 }), "takuzu")543 register("Takuzu+", buildPayloadAdapter("Takuzu+", func(save []byte) (any, error) {544 return ParseTakuzuPlusPrintData(save)545 }), "takuzu+", "takuzu plus", "binario+")546 register("Sudoku", buildPayloadAdapter("Sudoku", func(save []byte) (any, error) {547 return ParseSudokuPrintData(save)548 }), "sudoku")549 register("Sudoku RGB", buildPayloadAdapter("Sudoku RGB", func(save []byte) (any, error) {550 return ParseSudokuRGBPrintData(save)551 }), "sudoku rgb", "rgb sudoku", "ripeto", "sudoku ripeto")552 register("Fillomino", buildPayloadAdapter("Fillomino", func(save []byte) (any, error) {553 return ParseFillominoPrintData(save)554 }), "fillomino")555 register("Ripple Effect", buildPayloadAdapter("Ripple Effect", func(save []byte) (any, error) {556 return ParseRippleEffectPrintData(save)557 }), "rippleeffect", "ripple")558 register("Word Search", buildPayloadAdapter("Word Search", func(save []byte) (any, error) {559 return ParseWordSearchPrintData(save)560 }), "wordsearch")561 register("Nurikabe", buildPayloadAdapter("Nurikabe", func(save []byte) (any, error) {562 return ParseNurikabePrintData(save)563 }), "nurikabe")564 register("Shikaku", buildPayloadAdapter("Shikaku", func(save []byte) (any, error) {565 return ParseShikakuPrintData(save)566 }), "shikaku")567 register("Hashiwokakero", buildPayloadAdapter("Hashiwokakero", func(save []byte) (any, error) {568 return ParseHashiPrintData(save)569 }), "hashi")570 })571}572573func buildPayloadAdapter(_ string, parse func(save []byte) (any, error)) func([]byte) (any, error) {574 return parse575}576577type jsonlTestAdapter struct {578 category string579 aliases []string580 build func(save []byte) (any, error)581}582583func (a jsonlTestAdapter) CanonicalGameType() string { return a.category }584func (a jsonlTestAdapter) Aliases() []string { return a.aliases }585func (a jsonlTestAdapter) BuildPDFPayload(save []byte) (any, error) {586 return a.build(save)587}588func (a jsonlTestAdapter) RenderPDFBody(*fpdf.Fpdf, any) error { return nil }