- commit
- 39167a3fd7abd9973142f5d191914de69e53dfaa
- parent
- 0c5cbc33eac807a0baf0a01376882acad1026633
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 18 | ++- |
| M | cmd/ | 10 | +- |
| M | cmd/ | 2 | +- |
| M | cmd/ | 109 | ++++++++++++++----- |
| M | cmd/ | 44 | +++++--- |
| A | pdfexport/ | 172 | ++++++++++++++++++++++++++++++ |
| A | pdfexport/ | 80 | ++++++++++++++ |
@@ -106,20 +106,26 @@ puzzletea new --set-seed myseed
106106 puzzletea new nonogram epic --with-seed myseed107107 ```108108 109-Export printable puzzle sets to markdown:109+Export printable puzzle sets to JSONL:110110 111111 ```bash112-# Stream markdown to stdout (redirect if desired)113-puzzletea new nonogram mini --export 2 > nonogram-mini-set.md112+# Stream JSONL to stdout (redirect if desired)113+puzzletea new nonogram mini --export 2 > nonogram-mini-set.jsonl114114 115115 # Single mode export116-puzzletea new nonogram mini -e 6 -o nonogram-mini-set.md116+puzzletea new nonogram mini -e 6 -o nonogram-mini-set.jsonl117117 118118 # Mixed modes within a category (deterministic with --with-seed)119-puzzletea new sudoku --export 10 -o sudoku-mixed.md --with-seed zine-issue-01119+puzzletea new sudoku --export 10 -o sudoku-mixed.jsonl --with-seed zine-issue-01120120 ```121121 122-`Lights Out` is currently excluded from markdown export because it does not translate cleanly to paper workflows.122+Render one or more JSONL packs into an A5 print PDF:123+124+```bash125+puzzletea export-pdf nonogram-mini-set.jsonl -o issue-01.pdf --shuffle-seed issue-01126+```127+128+`Lights Out` is currently excluded from export because it does not translate cleanly to paper workflows.123129 124130 Override the color theme:125131 @@ -21,9 +21,9 @@ var (
2121 )2222 2323 var exportPDFCmd = &cobra.Command{24- Use: "export-pdf <input.md> [more.md ...]",25- Short: "Convert one or more PuzzleTea markdown exports into an A5 printable PDF",26- Long: "Parse one or more markdown export files, order puzzles by progressive difficulty with seeded mixing, and render an A5 PDF with a title page and one puzzle per page.",24+ Use: "export-pdf <input.jsonl> [more.jsonl ...]",25+ Short: "Convert one or more PuzzleTea JSONL exports into an A5 printable PDF",26+ Long: "Parse one or more JSONL export files, order puzzles by progressive difficulty with seeded mixing, and render an A5 PDF with a title page and one puzzle per page.",2727 Args: cobra.MinimumNArgs(1),2828 RunE: runExportPDF,2929 }@@ -36,14 +36,14 @@ func init() {
3636 }3737 3838 func runExportPDF(cmd *cobra.Command, args []string) error {39- docs, err := pdfexport.ParseFiles(args)39+ docs, err := pdfexport.ParseJSONLFiles(args)4040 if err != nil {4141 return err4242 }4343 4444 puzzles := flattenPuzzles(docs)4545 if len(puzzles) == 0 {46- return fmt.Errorf("no puzzles found in input markdown files")46+ return fmt.Errorf("no puzzles found in input jsonl files")4747 }4848 4949 lookup := buildModeDifficultyLookup(app.Categories)@@ -58,7 +58,7 @@ func init() {
5858 newCmd.Flags().StringVar(&flagSetSeed, "set-seed", "", "seed string for deterministic puzzle selection and generation")5959 newCmd.Flags().StringVar(&flagWithSeed, "with-seed", "", "seed string for deterministic puzzle generation within the selected game/mode")6060 newCmd.Flags().IntVarP(&flagExport, "export", "e", 1, "number of puzzles to export")61- newCmd.Flags().StringVarP(&flagOutput, "output", "o", "", "write puzzles to a markdown file (defaults to stdout)")61+ newCmd.Flags().StringVarP(&flagOutput, "output", "o", "", "write puzzles to a jsonl file (defaults to stdout)")6262 }6363 6464 // launchNewGame resolves the game/mode, spawns a new game, and launches the TUI.@@ -1,6 +1,7 @@
11 package cmd22 33 import (4+ "encoding/json"45 "errors"56 "fmt"67 "io"@@ -13,6 +14,8 @@ import (
1314 "github.com/FelineStateMachine/puzzletea/app"1415 "github.com/FelineStateMachine/puzzletea/game"1516 "github.com/FelineStateMachine/puzzletea/markdownexport"17+ "github.com/FelineStateMachine/puzzletea/namegen"18+ "github.com/FelineStateMachine/puzzletea/pdfexport"1619 "github.com/FelineStateMachine/puzzletea/resolve"1720 1821 "github.com/spf13/cobra"@@ -35,7 +38,7 @@ func runNewExport(cmd *cobra.Command, args []string) error {
3538 return err3639 }3740 if !markdownexport.SupportsGameType(cat.Name) {38- return fmt.Errorf("game %q does not support markdown export", cat.Name)41+ return fmt.Errorf("game %q does not support export", cat.Name)3942 }4043 4144 modeArg := ""@@ -48,21 +51,13 @@ func runNewExport(cmd *cobra.Command, args []string) error {
4851 return err4952 }5053 51- sections, err := buildExportSections(cat.Name, entries, flagExport, flagWithSeed)54+ generatedAt := exportNow()55+ records, err := buildExportRecords(cat.Name, modeSelection, entries, flagExport, flagWithSeed, generatedAt)5256 if err != nil {5357 return err5458 }5559 56- doc := markdownexport.BuildDocument(markdownexport.DocumentConfig{57- Version: Version,58- Category: cat.Name,59- ModeSelection: modeSelection,60- Count: flagExport,61- Seed: flagWithSeed,62- GeneratedAt: exportNow(),63- }, sections)64-65- if err := writeExportMarkdown(cmd, flagOutput, doc); err != nil {60+ if err := writeExportJSONL(cmd, flagOutput, records); err != nil {6661 return err6762 }6863 @@ -73,11 +68,11 @@ func validateNewExportFlags(cmd *cobra.Command, args []string) error {
7368 if flagExport < 1 {7469 return fmt.Errorf("--export must be at least 1")7570 }76- if strings.TrimSpace(flagOutput) != "" && !strings.EqualFold(filepath.Ext(flagOutput), ".md") {77- return fmt.Errorf("--output must use a .md extension")71+ if strings.TrimSpace(flagOutput) != "" && !strings.EqualFold(filepath.Ext(flagOutput), ".jsonl") {72+ return fmt.Errorf("--output must use a .jsonl extension")7873 }7974 if flagSetSeed != "" {80- return fmt.Errorf("--set-seed cannot be combined with markdown export (--export/--output)")75+ return fmt.Errorf("--set-seed cannot be combined with export (--export/--output)")8176 }8277 if len(args) == 0 {8378 return fmt.Errorf("requires at least 1 arg(s), only received 0")@@ -119,13 +114,25 @@ func collectExportModes(cat game.Category, modeArg string) ([]exportModeEntry, s
119114 return entries, "mixed modes", nil120115 }121116 122-func buildExportSections(gameType string, entries []exportModeEntry, count int, seed string) ([]markdownexport.PuzzleSection, error) {117+func buildExportRecords(118+ gameType, modeSelection string,119+ entries []exportModeEntry,120+ count int,121+ seed string,122+ generatedAt time.Time,123+) ([]pdfexport.JSONLRecord, error) {123124 var rng *rand.Rand124125 if seed != "" {125126 rng = resolve.RNGFromString(seed)126127 }127128 128- sections := make([]markdownexport.PuzzleSection, 0, count)129+ nameSeed := seed130+ if strings.TrimSpace(nameSeed) == "" {131+ nameSeed = generatedAt.Format(time.RFC3339Nano)132+ }133+ nameRNG := resolve.RNGFromString("export-names:" + nameSeed)134+135+ records := make([]pdfexport.JSONLRecord, 0, count)129136 for i := 0; i < count; i++ {130137 entry := entries[0]131138 if len(entries) > 1 {@@ -147,24 +154,61 @@ func buildExportSections(gameType string, entries []exportModeEntry, count int,
147154 if err != nil {148155 return nil, fmt.Errorf("serialize puzzle %d: %w", i+1, err)149156 }157+ if !json.Valid(save) {158+ return nil, fmt.Errorf("serialize puzzle %d: save payload is not valid JSON", i+1)159+ }150160 151161 snippet, err := markdownexport.RenderPuzzleSnippet(gameType, entry.mode, save)152162 if err != nil {153163 if errors.Is(err, markdownexport.ErrUnsupportedGame) {154- return nil, fmt.Errorf("game %q does not support markdown export", gameType)164+ return nil, fmt.Errorf("game %q does not support export", gameType)155165 }156166 return nil, fmt.Errorf("render puzzle %d: %w", i+1, err)157167 }158168 159- sections = append(sections, markdownexport.PuzzleSection{160- Index: i + 1,161- GameType: gameType,162- Mode: entry.mode,163- Body: snippet,169+ nonogram, table, err := pdfexport.ParsePrintableFromSnippet(gameType, snippet)170+ if err != nil {171+ return nil, fmt.Errorf("build print payload for puzzle %d: %w", i+1, err)172+ }173+174+ printKind := "text"175+ if nonogram != nil {176+ printKind = "nonogram"177+ } else if table != nil {178+ printKind = "grid-table"179+ }180+181+ records = append(records, pdfexport.JSONLRecord{182+ Schema: pdfexport.ExportSchemaV1,183+ Pack: pdfexport.JSONLPackMeta{184+ Generated: generatedAt.Format(time.RFC3339),185+ Version: Version,186+ Category: gameType,187+ ModeSelection: modeSelection,188+ Count: count,189+ Seed: seed,190+ },191+ Puzzle: pdfexport.JSONLPuzzle{192+ Index: i + 1,193+ Name: namegen.GenerateSeeded(nameRNG),194+ Game: gameType,195+ Mode: entry.mode,196+ Save: json.RawMessage(save),197+ Snippet: snippet,198+ },199+ Print: pdfexport.JSONLPrintData{200+ Kind: printKind,201+ Paper: "A5",202+ MarginMM: 10,203+ EmptyGlyph: " ",204+ HintTone: "dim",205+ Nonogram: nonogram,206+ Table: table,207+ },164208 })165209 }166210 167- return sections, nil211+ return records, nil168212 }169213 170214 func spawnExportPuzzle(spawner game.Spawner, rng *rand.Rand) (game.Gamer, error) {@@ -179,10 +223,21 @@ func spawnExportPuzzle(spawner game.Spawner, rng *rand.Rand) (game.Gamer, error)
179223 return seeded.SpawnSeeded(rng)180224 }181225 182-func writeExportMarkdown(cmd *cobra.Command, path, content string) error {226+func writeExportJSONL(cmd *cobra.Command, path string, records []pdfexport.JSONLRecord) error {227+ var b strings.Builder228+ for _, record := range records {229+ data, err := json.Marshal(record)230+ if err != nil {231+ return fmt.Errorf("encode jsonl record: %w", err)232+ }233+ b.Write(data)234+ b.WriteByte('\n')235+ }236+237+ content := b.String()183238 if strings.TrimSpace(path) == "" {184239 if _, err := io.WriteString(cmd.OutOrStdout(), content); err != nil {185- return fmt.Errorf("write export markdown to stdout: %w", err)240+ return fmt.Errorf("write export jsonl to stdout: %w", err)186241 }187242 return nil188243 }@@ -195,7 +250,7 @@ func writeExportMarkdown(cmd *cobra.Command, path, content string) error {
195250 }196251 197252 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {198- return fmt.Errorf("write output markdown: %w", err)253+ return fmt.Errorf("write output jsonl: %w", err)199254 }200255 return nil201256 }@@ -2,6 +2,7 @@ package cmd
22 33 import (44 "bytes"5+ "encoding/json"56 "os"67 "path/filepath"78 "strconv"@@ -9,25 +10,27 @@ import (
910 "testing"1011 "time"1112 13+ "github.com/FelineStateMachine/puzzletea/pdfexport"14+1215 "github.com/spf13/cobra"1316 )1417 1518 func TestRunNewExportRejectsUnsupportedGame(t *testing.T) {1619 withExportFlagReset(t)17- flagOutput = filepath.Join(t.TempDir(), "lights.md")20+ flagOutput = filepath.Join(t.TempDir(), "lights.jsonl")1821 1922 cmd, _ := newExportTestCmd(t, false)2023 err := runNewExport(cmd, []string{"lights-out"})2124 if err == nil {2225 t.Fatal("expected unsupported game error")2326 }24- if !strings.Contains(err.Error(), "does not support markdown export") {27+ if !strings.Contains(err.Error(), "does not support export") {2528 t.Fatalf("unexpected error: %v", err)2629 }2730 }2831 2932 func TestRunNewExportValidation(t *testing.T) {30- t.Run("writes to stdout when output omitted", func(t *testing.T) {33+ t.Run("writes jsonl to stdout when output omitted", func(t *testing.T) {3134 withExportFlagReset(t)3235 flagExport = 23336 @@ -36,12 +39,23 @@ func TestRunNewExportValidation(t *testing.T) {
3639 if err != nil {3740 t.Fatalf("expected stdout export success, got error: %v", err)3841 }39- if !strings.Contains(out.String(), "# PuzzleTea Export") {40- t.Fatalf("expected markdown output on stdout, got:\n%s", out.String())42+43+ lines := strings.Split(strings.TrimSpace(out.String()), "\n")44+ if got, want := len(lines), 2; got != want {45+ t.Fatalf("jsonl lines = %d, want %d", got, want)46+ }47+ for i, line := range lines {48+ var record pdfexport.JSONLRecord49+ if err := json.Unmarshal([]byte(line), &record); err != nil {50+ t.Fatalf("line %d is not valid jsonl: %v", i+1, err)51+ }52+ if record.Schema != pdfexport.ExportSchemaV1 {53+ t.Fatalf("line %d schema = %q, want %q", i+1, record.Schema, pdfexport.ExportSchemaV1)54+ }4155 }4256 })4357 44- t.Run("output extension must be markdown", func(t *testing.T) {58+ t.Run("output extension must be jsonl", func(t *testing.T) {4559 withExportFlagReset(t)4660 flagOutput = filepath.Join(t.TempDir(), "out.txt")4761 @@ -50,7 +64,7 @@ func TestRunNewExportValidation(t *testing.T) {
5064 if err == nil {5165 t.Fatal("expected extension validation error")5266 }53- if !strings.Contains(err.Error(), ".md extension") {67+ if !strings.Contains(err.Error(), ".jsonl extension") {5468 t.Fatalf("unexpected error: %v", err)5569 }5670 })@@ -58,14 +72,14 @@ func TestRunNewExportValidation(t *testing.T) {
5872 t.Run("set-seed cannot be combined with output", func(t *testing.T) {5973 withExportFlagReset(t)6074 flagSetSeed = "abc"61- flagOutput = filepath.Join(t.TempDir(), "out.md")75+ flagOutput = filepath.Join(t.TempDir(), "out.jsonl")6276 6377 cmd, _ := newExportTestCmd(t, false)6478 err := runNewExport(cmd, []string{"nonogram", "mini"})6579 if err == nil {6680 t.Fatal("expected set-seed validation error")6781 }68- if !strings.Contains(err.Error(), "--set-seed cannot be combined with markdown export (--export/--output)") {82+ if !strings.Contains(err.Error(), "--set-seed cannot be combined with export (--export/--output)") {6983 t.Fatalf("unexpected error: %v", err)7084 }7185 })@@ -79,8 +93,8 @@ func TestRunNewExportReproducibleWithSeed(t *testing.T) {
7993 exportNow = func() time.Time { return fixedNow }8094 t.Cleanup(func() { exportNow = previousNow })8195 82- fileA := filepath.Join(t.TempDir(), "a.md")83- fileB := filepath.Join(t.TempDir(), "b.md")96+ fileA := filepath.Join(t.TempDir(), "a.jsonl")97+ fileB := filepath.Join(t.TempDir(), "b.jsonl")8498 8599 flagExport = 386100 flagWithSeed = "zine-seed-01"@@ -105,14 +119,14 @@ func TestRunNewExportReproducibleWithSeed(t *testing.T) {
105119 t.Fatal(err)106120 }107121 if string(a) != string(b) {108- t.Fatal("expected deterministic markdown output for identical seed and args")122+ t.Fatal("expected deterministic jsonl output for identical seed and args")109123 }110124 }111125 112126 func TestRunNewExportOverwritesOutputFile(t *testing.T) {113127 withExportFlagReset(t)114128 115- file := filepath.Join(t.TempDir(), "out.md")129+ file := filepath.Join(t.TempDir(), "out.jsonl")116130 if err := os.WriteFile(file, []byte("old"), 0o644); err != nil {117131 t.Fatal(err)118132 }@@ -133,8 +147,8 @@ func TestRunNewExportOverwritesOutputFile(t *testing.T) {
133147 if string(data) == "old" {134148 t.Fatal("expected output file to be overwritten")135149 }136- if !strings.Contains(string(data), "# PuzzleTea Export") {137- t.Fatal("expected markdown export header")150+ if !strings.Contains(string(data), pdfexport.ExportSchemaV1) {151+ t.Fatal("expected jsonl export schema marker")138152 }139153 }140154 @@ -0,0 +1,172 @@
1+package pdfexport2+3+import (4+ "bufio"5+ "encoding/json"6+ "fmt"7+ "os"8+ "path/filepath"9+ "strings"10+)11+12+const ExportSchemaV1 = "puzzletea.export.v1"13+14+type JSONLRecord struct {15+ Schema string `json:"schema"`16+ Pack JSONLPackMeta `json:"pack"`17+ Puzzle JSONLPuzzle `json:"puzzle"`18+ Print JSONLPrintData `json:"print"`19+}20+21+type JSONLPackMeta struct {22+ Generated string `json:"generated"`23+ Version string `json:"version"`24+ Category string `json:"category"`25+ ModeSelection string `json:"mode_selection"`26+ Count int `json:"count"`27+ Seed string `json:"seed"`28+}29+30+type JSONLPuzzle struct {31+ Index int `json:"index"`32+ Name string `json:"name"`33+ Game string `json:"game"`34+ Mode string `json:"mode"`35+ Save json.RawMessage `json:"save"`36+ Snippet string `json:"snippet,omitempty"`37+}38+39+type JSONLPrintData struct {40+ Kind string `json:"kind"`41+ Paper string `json:"paper"`42+ MarginMM float64 `json:"margin_mm"`43+ EmptyGlyph string `json:"empty_glyph"`44+ HintTone string `json:"hint_tone"`45+ Nonogram *NonogramData `json:"nonogram,omitempty"`46+ Table *GridTable `json:"table,omitempty"`47+}48+49+func ParseJSONLFiles(paths []string) ([]PackDocument, error) {50+ docs := make([]PackDocument, 0, len(paths))51+ for _, path := range paths {52+ doc, err := ParseJSONLFile(path)53+ if err != nil {54+ return nil, err55+ }56+ docs = append(docs, doc)57+ }58+ return docs, nil59+}60+61+func ParseJSONLFile(path string) (PackDocument, error) {62+ if !strings.EqualFold(filepath.Ext(path), ".jsonl") {63+ return PackDocument{}, fmt.Errorf("%s: expected .jsonl input", path)64+ }65+66+ f, err := os.Open(path)67+ if err != nil {68+ return PackDocument{}, fmt.Errorf("open input jsonl: %w", err)69+ }70+ defer f.Close()71+72+ scanner := bufio.NewScanner(f)73+ buf := make([]byte, 0, 64*1024)74+ scanner.Buffer(buf, 16*1024*1024)75+76+ doc := PackDocument{SourcePath: path}77+ doc.Metadata.SourceFileName = filepath.Base(path)78+ puzzles := []Puzzle{}79+ lineNo := 080+ seenAny := false81+82+ for scanner.Scan() {83+ lineNo++84+ line := strings.TrimSpace(scanner.Text())85+ if line == "" {86+ continue87+ }88+ seenAny = true89+90+ var record JSONLRecord91+ if err := json.Unmarshal([]byte(line), &record); err != nil {92+ return PackDocument{}, fmt.Errorf("%s:%d: decode jsonl record: %w", path, lineNo, err)93+ }94+ if record.Schema != ExportSchemaV1 {95+ return PackDocument{}, fmt.Errorf("%s:%d: unsupported schema %q", path, lineNo, record.Schema)96+ }97+98+ if doc.Metadata.GeneratedRaw == "" {99+ doc.Metadata.GeneratedRaw = record.Pack.Generated100+ doc.Metadata.Version = record.Pack.Version101+ doc.Metadata.Category = record.Pack.Category102+ doc.Metadata.ModeSelection = record.Pack.ModeSelection103+ doc.Metadata.Count = record.Pack.Count104+ doc.Metadata.Seed = record.Pack.Seed105+ }106+107+ category := strings.TrimSpace(record.Puzzle.Game)108+ if category == "" {109+ category = strings.TrimSpace(record.Pack.Category)110+ }111+ mode := strings.TrimSpace(record.Puzzle.Mode)112+ if mode == "" {113+ mode = strings.TrimSpace(record.Pack.ModeSelection)114+ }115+116+ p := Puzzle{117+ SourcePath: path,118+ SourceFileName: filepath.Base(path),119+ Category: category,120+ ModeSelection: mode,121+ Name: record.Puzzle.Name,122+ Index: record.Puzzle.Index,123+ Body: record.Puzzle.Snippet,124+ Nonogram: record.Print.Nonogram,125+ Table: record.Print.Table,126+ }127+128+ if p.Nonogram == nil && p.Table == nil && strings.TrimSpace(record.Puzzle.Snippet) != "" {129+ nonogram, table, err := ParsePrintableFromSnippet(category, record.Puzzle.Snippet)130+ if err != nil {131+ return PackDocument{}, fmt.Errorf("%s:%d: parse printable snippet: %w", path, lineNo, err)132+ }133+ p.Nonogram = nonogram134+ p.Table = table135+ }136+137+ puzzles = append(puzzles, p)138+ }139+140+ if err := scanner.Err(); err != nil {141+ return PackDocument{}, fmt.Errorf("read input jsonl: %w", err)142+ }143+ if !seenAny {144+ return PackDocument{}, fmt.Errorf("%s: input jsonl is empty", path)145+ }146+ if len(puzzles) == 0 {147+ return PackDocument{}, fmt.Errorf("%s: no puzzle records found", path)148+ }149+150+ if doc.Metadata.Count == 0 {151+ doc.Metadata.Count = len(puzzles)152+ }153+ doc.Puzzles = puzzles154+ return doc, nil155+}156+157+func ParsePrintableFromSnippet(category, snippet string) (*NonogramData, *GridTable, error) {158+ lines := strings.Split(strings.ReplaceAll(strings.ReplaceAll(snippet, "\r\n", "\n"), "\r", "\n"), "\n")159+ if strings.EqualFold(strings.TrimSpace(category), "nonogram") {160+ nonogram, err := parseNonogramBody(lines, "snippet", 1)161+ if err != nil {162+ return nil, nil, err163+ }164+ return nonogram, nil, nil165+ }166+167+ table, err := parseGridTableBody(lines, "snippet", 1)168+ if err != nil {169+ return nil, nil, err170+ }171+ return nil, table, nil172+}@@ -0,0 +1,80 @@
1+package pdfexport2+3+import (4+ "encoding/json"5+ "os"6+ "path/filepath"7+ "strings"8+ "testing"9+)10+11+func TestParseJSONLFile(t *testing.T) {12+ temp := t.TempDir()13+ path := filepath.Join(temp, "pack.jsonl")14+15+ record := JSONLRecord{16+ Schema: ExportSchemaV1,17+ Pack: JSONLPackMeta{18+ Generated: "2026-02-22T10:00:00Z",19+ Version: "v-test",20+ Category: "Nonogram",21+ ModeSelection: "Mini",22+ Count: 1,23+ Seed: "seed-1",24+ },25+ Puzzle: JSONLPuzzle{26+ Index: 1,27+ Name: "ember-newt",28+ Game: "Nonogram",29+ Mode: "Mini",30+ Save: json.RawMessage(`{"width":2}`),31+ Snippet: "### Puzzle Grid with Integrated Hints\n\n" +32+ "| R1 | C1 | C2 |\n" +33+ "| --- | --- | --- |\n" +34+ "| . | 1 | 2 |\n" +35+ "| 1 | . | . |\n",36+ },37+ Print: JSONLPrintData{38+ Kind: "nonogram",39+ Paper: "A5",40+ MarginMM: 10,41+ EmptyGlyph: " ",42+ HintTone: "dim",43+ },44+ }45+46+ data, err := json.Marshal(record)47+ if err != nil {48+ t.Fatal(err)49+ }50+ if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {51+ t.Fatal(err)52+ }53+54+ doc, err := ParseJSONLFile(path)55+ if err != nil {56+ t.Fatal(err)57+ }58+ if got, want := len(doc.Puzzles), 1; got != want {59+ t.Fatalf("puzzles = %d, want %d", got, want)60+ }61+ if doc.Puzzles[0].Nonogram == nil {62+ t.Fatal("expected nonogram print payload from snippet fallback")63+ }64+}65+66+func TestParseJSONLFileRejectsNonJSONLExtension(t *testing.T) {67+ temp := t.TempDir()68+ path := filepath.Join(temp, "pack.md")69+ if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {70+ t.Fatal(err)71+ }72+73+ _, err := ParseJSONLFile(path)74+ if err == nil {75+ t.Fatal("expected extension validation error")76+ }77+ if !strings.Contains(err.Error(), "expected .jsonl input") {78+ t.Fatalf("unexpected error: %v", err)79+ }80+}