From f4b2f8270c8c13ae212fbf7ee68aa6f349f42750 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Sat, 4 Jul 2026 20:38:46 +1000 Subject: [PATCH] feat: add preview package + `shellcade-kit preview pack` for games-screen preview loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Game authors compile a hand-authored games-screen preview loop — a preview/ directory of a preview.yaml manifest plus plain-text frame files — into a single self-contained preview.scp bundle that ships as a release asset. - New public `preview` package (Pack/Load + validation + format constants), documented for game authors. Pack validates and compiles the authoring directory; Load parses a bundle back into a playable Animation. The frame-text contract is enforced at pack time (46x7 cells, SGR-only escapes, rejects C1/invalid-UTF-8/VS16/ZWJ/keycap hazards, 1-64 frames, per-frame ms clamped 80-10000, pad-never-truncate, 64 KiB cap). Widths use github.com/charmbracelet/x/ansi StringWidth so the packer agrees byte-for-byte with the arcade's intake re-validation. - New `shellcade-kit preview pack [-o out.scp]` CLI subcommand (dispatch, usage line, trailing-flag handling, loud stray-positional rejection). - GUIDE.md: a "Preview loops" authoring section. Co-Authored-By: Claude Fable 5 --- .changeset/preview-pack.md | 5 + GUIDE.md | 60 +++++++++++ cmd/shellcade-kit/main.go | 6 +- cmd/shellcade-kit/preview.go | 47 ++++++++ go.mod | 5 + go.sum | 10 ++ preview/pack.go | 108 +++++++++++++++++++ preview/preview.go | 187 ++++++++++++++++++++++++++++++++ preview/preview_test.go | 202 +++++++++++++++++++++++++++++++++++ 9 files changed, 629 insertions(+), 1 deletion(-) create mode 100644 .changeset/preview-pack.md create mode 100644 cmd/shellcade-kit/preview.go create mode 100644 preview/pack.go create mode 100644 preview/preview.go create mode 100644 preview/preview_test.go diff --git a/.changeset/preview-pack.md b/.changeset/preview-pack.md new file mode 100644 index 0000000..fc4700b --- /dev/null +++ b/.changeset/preview-pack.md @@ -0,0 +1,5 @@ +--- +"kit": minor +--- + +Add a `preview` package and a `shellcade-kit preview pack` CLI subcommand for authoring the arcade's games-screen preview loops. Game authors write a `preview/` directory (a `preview.yaml` manifest plus plain-text frame files, 46×7 cells, optional ANSI SGR color) and pack it into a single self-contained `preview.scp` bundle that ships as a release asset. `preview.Pack` validates and compiles the authoring directory; `preview.Load` parses a bundle back into a playable `Animation`. The format enforces the frame-text contract at pack time — SGR-only escapes, no C1/invalid-UTF-8/variation-selector/ZWJ/keycap hazards, 1–64 frames, per-frame durations clamped to 80–10000ms, pad-never-truncate, and a 64 KiB bundle cap — and the arcade re-runs the identical checks on intake. See the new "Preview loops" section in GUIDE.md. diff --git a/GUIDE.md b/GUIDE.md index f67b509..41fb283 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -702,6 +702,66 @@ Authoring tips: The `smoke` package exposes the same machinery as Go API (`smoke.Parse`, `smoke.Run`, `smoke.RenderANSI`) if you want shots inside your own tests. +## Preview loops: the games-screen art block + +The arcade's games screen shows a little looping animation beside each game's +blurb — a hand-authored preview loop, distinct from the automatic smoke shots. +You author it as a `preview/` directory: a `preview.yaml` manifest plus one +plain-text file per frame, and `shellcade-kit preview pack` compiles the whole +thing into a single self-contained `preview.scp` bundle that ships as a release +asset alongside your `.wasm`. + +``` +preview/ + preview.yaml + 01-idle.frame + 02-spin.frame + 03-win.frame +``` + +The manifest lists frames in playback order, each with a file and a duration: + +```yaml +frames: + - file: 01-idle.frame + ms: 900 # hold this frame 900ms + - file: 02-spin.frame + ms: 120 + - file: 03-win.frame + ms: 600 +``` + +Looping is implicit — playback runs the frames in order, then loops from the +last back to the first. There is nothing to configure. + +Each frame file is plain text: **at most 7 rows of at most 46 display cells** +(the art block's interior — the arcade draws the border). Short rows and short +frames are space-padded for you; the packer **never truncates**, so an oversize +row is a hard error naming the offending file and line — trim the art yourself. +Wide glyphs (CJK, most emoji) count as two cells. + +The frame-text contract is **printable text plus ANSI SGR color only**: + +- `ESC[…m` styling sequences are allowed (colors, bold, reverse). Any other + escape — cursor movement, screen clears, anything that isn't SGR — is + rejected, as are raw control characters and invalid UTF-8. +- **No variation selectors (VS16 `U+FE0F`), zero-width joiners, or keycap + combiners.** These render at terminal-dependent widths: a frame that measures + 46 cells in the packer can overflow the art block on a real terminal and + desync every column to its right. Compose your art from single, unambiguous + glyphs. (Styling need not be closed at end of line — the packer resets it + before the padding.) + +Pack it and ship the bundle: + +```sh +shellcade-kit preview pack preview/ -o preview.scp +``` + +The bundle is small (capped at 64 KiB) and versioned; the arcade re-runs every +one of these checks when it ingests your bundle, so a preview that +`preview pack` accepts is one the arcade will accept. + ## The full loop | Stage | Command | What it proves | diff --git a/cmd/shellcade-kit/main.go b/cmd/shellcade-kit/main.go index 2032010..4b4c697 100644 --- a/cmd/shellcade-kit/main.go +++ b/cmd/shellcade-kit/main.go @@ -6,6 +6,7 @@ // shellcade-kit lint-width ... lint source for wide-glyph width-contract violations only // shellcade-kit play [flags] play the game in a local 80x24 terminal room // shellcade-kit smoke run the game's smoke.yaml and write the shot files +// shellcade-kit preview pack [-o out.scp] compile a games-screen preview loop into a preview.scp bundle // // check/play/smoke accept either a built .wasm or the game directory — a // directory is built first (TinyGo for go.mod, cargo wasm32-wasip1 for @@ -109,6 +110,9 @@ func main() { fmt.Fprintln(os.Stderr, "shellcade-kit:", err) os.Exit(1) } + case "preview": + // preview pack [-o out.scp]: compile a games-screen preview loop. + runPreview(os.Args[2:]) default: usage() } @@ -145,7 +149,7 @@ func printVersion() { } func usage() { - fmt.Fprintln(os.Stderr, "usage: shellcade-kit version | new [--rust] [--license ID] | check | lint-width ... | meta | play [flags] | smoke [--out dir]") + fmt.Fprintln(os.Stderr, "usage: shellcade-kit version | new [--rust] [--license ID] | check | lint-width ... | meta | play [flags] | smoke [--out dir] | preview pack [-o out.scp]") os.Exit(2) } diff --git a/cmd/shellcade-kit/preview.go b/cmd/shellcade-kit/preview.go new file mode 100644 index 0000000..8137b96 --- /dev/null +++ b/cmd/shellcade-kit/preview.go @@ -0,0 +1,47 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/shellcade/kit/v2/preview" +) + +// preview.go is the author-facing preview tooling: +// `shellcade-kit preview pack ` validates an authoring directory +// (preview.yaml + frame files) and writes the single preview.scp bundle the +// arcade's games screen plays — the bundle a game ships as a release asset. + +func runPreview(args []string) { + if len(args) == 0 || args[0] != "pack" { + fmt.Fprintln(os.Stderr, "usage: shellcade-kit preview pack [-o out.scp]") + os.Exit(2) + } + fs := flag.NewFlagSet("preview pack", flag.ExitOnError) + out := fs.String("o", "preview.scp", "output bundle path") + fs.Parse(args[1:]) + if fs.NArg() < 1 { + fmt.Fprintln(os.Stderr, "usage: shellcade-kit preview pack [-o out.scp]") + os.Exit(2) + } + dir := fs.Arg(0) + fs.Parse(fs.Args()[1:]) // accept flags after the positional dir too + if fs.NArg() != 0 { + // A stray positional would silently swallow any flags after it + // (flag stops at the first non-flag) — reject it loudly instead. + fmt.Fprintf(os.Stderr, "preview pack: unexpected argument %q\nusage: shellcade-kit preview pack [-o out.scp]\n", fs.Arg(0)) + os.Exit(2) + } + + data, err := preview.Pack(dir) + if err != nil { + fmt.Fprintf(os.Stderr, "preview pack: %v\n", err) + os.Exit(1) + } + if err := os.WriteFile(*out, data, 0o644); err != nil { + fmt.Fprintf(os.Stderr, "preview pack: %v\n", err) + os.Exit(1) + } + fmt.Printf("packed %s (%d bytes)\n", *out, len(data)) +} diff --git a/go.mod b/go.mod index 6ec39fe..b6cd8e7 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/shellcade/kit/v2 go 1.25.11 require ( + github.com/charmbracelet/x/ansi v0.11.7 github.com/charmbracelet/x/term v0.2.2 github.com/extism/go-pdk v1.1.3 github.com/extism/go-sdk v1.7.1 @@ -14,9 +15,13 @@ require ( ) require ( + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect golang.org/x/sys v0.46.0 // indirect diff --git a/go.sum b/go.sum index 855d8b0..9fb9ab7 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,11 @@ +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE= @@ -18,6 +24,10 @@ github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca h1:T54Ema1 github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= diff --git a/preview/pack.go b/preview/pack.go new file mode 100644 index 0000000..0df15cf --- /dev/null +++ b/preview/pack.go @@ -0,0 +1,108 @@ +package preview + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/charmbracelet/x/ansi" +) + +// pack.go turns an authoring directory (preview.yaml + frame files) into the +// single preview.scp bundle. Errors name the offending file/row/sequence so +// authors can fix art without reading source. The packer pads short +// rows/frames but NEVER truncates: an oversize row is the author's call to +// make, not ours. + +// ManifestName is the manifest file every authoring directory carries. +const ManifestName = "preview.yaml" + +// manifest is the authoring manifest: frames in playback order, each with its +// file and duration. Looping is implicit — there is nothing to configure. +type manifest struct { + Frames []manifestFrame `yaml:"frames"` +} + +type manifestFrame struct { + File string `yaml:"file"` + MS int `yaml:"ms"` +} + +// Pack reads dir's manifest and frame files and returns the bundle bytes. +func Pack(dir string) ([]byte, error) { + raw, err := os.ReadFile(filepath.Join(dir, ManifestName)) + if err != nil { + return nil, fmt.Errorf("read manifest: %w", err) + } + var m manifest + dec := yaml.NewDecoder(strings.NewReader(string(raw))) + dec.KnownFields(true) + if err := dec.Decode(&m); err != nil { + return nil, fmt.Errorf("%s: %w", ManifestName, err) + } + if len(m.Frames) < MinFrames || len(m.Frames) > MaxFrames { + return nil, fmt.Errorf("%s lists %d frames; want %d..%d", ManifestName, len(m.Frames), MinFrames, MaxFrames) + } + + b := bundleJSON{V: Version, Frames: make([]frameJSON, len(m.Frames))} + for i, entry := range m.Frames { + if entry.File == "" { + return nil, fmt.Errorf("%s frames[%d]: missing file", ManifestName, i) + } + if entry.MS == 0 { + return nil, fmt.Errorf("%s frames[%d] (%s): missing ms", ManifestName, i, entry.File) + } + if entry.MS < 0 { + return nil, fmt.Errorf("%s frames[%d] (%s): negative ms", ManifestName, i, entry.File) + } + lines, err := packFrame(dir, entry.File) + if err != nil { + return nil, err + } + b.Frames[i] = frameJSON{MS: clampMS(entry.MS), Lines: lines} + } + + out, err := json.Marshal(b) + if err != nil { + return nil, fmt.Errorf("encode bundle: %w", err) + } + if len(out) > MaxBundleBytes { + return nil, fmt.Errorf("packed bundle is %d bytes; limit %d", len(out), MaxBundleBytes) + } + return out, nil +} + +// packFrame reads, validates, and pads one frame file to exactly Rows×Cols. +func packFrame(dir, name string) ([]string, error) { + if filepath.IsAbs(name) || strings.Contains(name, "..") { + return nil, fmt.Errorf("frame file %q: name must be relative to the preview directory", name) + } + raw, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + return nil, fmt.Errorf("read frame: %w", err) + } + src := strings.Split(strings.TrimSuffix(string(raw), "\n"), "\n") + if len(src) > Rows { + return nil, fmt.Errorf("%s has %d rows; a frame is at most %d", name, len(src), Rows) + } + lines := make([]string, Rows) + for r := range lines { + if r >= len(src) { + lines[r] = strings.Repeat(" ", Cols) + continue + } + line := src[r] + if err := validateText(line); err != nil { + return nil, fmt.Errorf("%s row %d: %w", name, r+1, err) + } + if w := ansi.StringWidth(line); w > Cols { + return nil, fmt.Errorf("%s row %d is %d cells; the art block is %d — trim the art, the packer never truncates", name, r+1, w, Cols) + } + lines[r] = pad(line) + } + return lines, nil +} diff --git a/preview/preview.go b/preview/preview.go new file mode 100644 index 0000000..4bb1d7c --- /dev/null +++ b/preview/preview.go @@ -0,0 +1,187 @@ +// Package preview compiles a game's animated preview — the little looping art +// block the arcade shows on the games screen — into the single self-contained +// preview.scp bundle a game ships as a release asset. +// +// You author a preview as a preview.yaml manifest plus plain-text frame files +// (46×7 cells, optional ANSI SGR color); Pack validates and packs them into the +// bundle, and Load turns a bundle back into a playable Animation. This is the +// authoring side of the contract — the arcade re-runs every validation limit +// here when it ingests a bundle, so a preview that Pack accepts is one the +// arcade will accept. Playback loops implicitly from the last frame back to the +// first; there is nothing to configure. +package preview + +import ( + "encoding/json" + "fmt" + "strings" + "unicode/utf8" + + "github.com/charmbracelet/x/ansi" +) + +// Geometry and limits. Cols×Rows is the games-screen art block's interior: the +// arcade draws the border, you get the inside. +const ( + Cols = 46 // display cells per row (wide glyphs count 2) + Rows = 7 // rows per frame + MaxFrames = 64 + MinFrames = 1 + // MinFrameMS / MaxFrameMS clamp per-frame durations; MaxBundleBytes caps + // the packed artifact. + MinFrameMS = 80 + MaxFrameMS = 10000 + MaxBundleBytes = 64 * 1024 + // Version is the bundle format version this package writes and accepts. An + // unknown version loads as "no preview" (fallback), never an error page. + Version = 1 +) + +// Frame is one packed frame: exactly Rows rows of exactly Cols display cells +// (already space-padded by the packer), plus its display duration. +type Frame struct { + MS int + Rows []string +} + +// Animation is a loaded, validated preview loop. Playback loops from the last +// frame back to the first; looping is implicit and always on. +type Animation struct { + Frames []Frame +} + +// bundleJSON is the preview.scp wire form: {"v":1,"frames":[{"ms":…,"lines":[…]}]}. +type bundleJSON struct { + V int `json:"v"` + Frames []frameJSON `json:"frames"` +} + +type frameJSON struct { + MS int `json:"ms"` + Lines []string `json:"lines"` +} + +// Load parses and validates a preview.scp bundle. Every limit is re-checked +// here regardless of what packed the file; durations outside the clamp are +// clamped (not rejected). Any structural violation returns an error — callers +// treat that as "no preview", never a fatal condition. +func Load(data []byte) (*Animation, error) { + if len(data) > MaxBundleBytes { + return nil, fmt.Errorf("bundle is %d bytes; limit %d", len(data), MaxBundleBytes) + } + var b bundleJSON + if err := json.Unmarshal(data, &b); err != nil { + return nil, fmt.Errorf("bundle parse: %w", err) + } + if b.V != Version { + return nil, fmt.Errorf("bundle version %d; this host speaks %d", b.V, Version) + } + if len(b.Frames) < MinFrames || len(b.Frames) > MaxFrames { + return nil, fmt.Errorf("bundle has %d frames; want %d..%d", len(b.Frames), MinFrames, MaxFrames) + } + anim := &Animation{Frames: make([]Frame, len(b.Frames))} + for i, f := range b.Frames { + if len(f.Lines) != Rows { + return nil, fmt.Errorf("frame %d has %d rows; want exactly %d", i, len(f.Lines), Rows) + } + rows := make([]string, Rows) + for r, line := range f.Lines { + if err := validateText(line); err != nil { + return nil, fmt.Errorf("frame %d row %d: %w", i, r, err) + } + if w := ansi.StringWidth(line); w != Cols { + return nil, fmt.Errorf("frame %d row %d is %d cells; want exactly %d", i, r, w, Cols) + } + rows[r] = line + } + anim.Frames[i] = Frame{MS: clampMS(f.MS), Rows: rows} + } + return anim, nil +} + +// clampMS bounds a per-frame duration to [MinFrameMS, MaxFrameMS]. +func clampMS(ms int) int { + if ms < MinFrameMS { + return MinFrameMS + } + if ms > MaxFrameMS { + return MaxFrameMS + } + return ms +} + +// validateText enforces the frame-text contract: printable text plus SGR +// styling only. Every ESC must open a well-formed SGR sequence +// (ESC '[' params 'm', params drawn from digits ';' ':'), and no other +// control characters are allowed — including the C1 range (U+0080–U+009F: +// U+009B is a one-character CSI that would smuggle cursor movement past an +// ESC-only scan) and invalid UTF-8. Frames are pure styled text, never +// cursor movement or terminal state. +// +// Width-treacherous glyph machinery is rejected too: variation selectors, +// ZWJ, and the keycap combiner render at terminal-dependent widths (the +// keycap class corrupted a slot-machine preview in production), so a frame +// that measures 46 cells here could overflow the art block on a real terminal. +func validateText(s string) error { + for i := 0; i < len(s); { + if s[i] == 0x1b { + j, err := scanSGR(s, i) + if err != nil { + return err + } + i = j + continue + } + r, size := utf8.DecodeRuneInString(s[i:]) + switch { + case r == utf8.RuneError && size == 1: + return fmt.Errorf("invalid UTF-8 at byte %d", i) + case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f): + return fmt.Errorf("control character %#x; frames are text plus SGR styling only", r) + case r == 0xfe0f || r == 0x200d || r == 0x20e3: + return fmt.Errorf("glyph-width hazard %#x: variation selectors, ZWJ, and keycap combiners render at terminal-dependent widths", r) + } + i += size + } + return nil +} + +// scanSGR checks that the escape sequence starting at s[i] is a complete SGR +// and returns the index just past it. +func scanSGR(s string, i int) (int, error) { + rest := s[i:] + if len(rest) < 2 || rest[1] != '[' { + return 0, fmt.Errorf("escape sequence %q: only SGR (ESC[…m) styling is allowed", clipSeq(rest)) + } + for j := 2; j < len(rest); j++ { + switch c := rest[j]; { + case c == 'm': + return i + j + 1, nil + case c >= '0' && c <= '9', c == ';', c == ':': + // param bytes — keep scanning + default: + return 0, fmt.Errorf("escape sequence %q: only SGR (ESC[…m) styling is allowed", clipSeq(rest[:j+1])) + } + } + return 0, fmt.Errorf("unterminated escape sequence %q", clipSeq(rest)) +} + +// clipSeq bounds an offending sequence for error messages. +func clipSeq(s string) string { + const max = 12 + if len(s) > max { + s = s[:max] + } + return strings.ReplaceAll(s, "\x1b", `\x1b`) +} + +// pad returns line space-padded on the right to exactly Cols cells, closing +// any open SGR styling first so padding (and anything after it) is unstyled. +// The caller has already validated width ≤ Cols. +func pad(line string) string { + w := ansi.StringWidth(line) + if strings.Contains(line, "\x1b") { + line += "\x1b[0m" + } + return line + strings.Repeat(" ", Cols-w) +} diff --git a/preview/preview_test.go b/preview/preview_test.go new file mode 100644 index 0000000..54c2448 --- /dev/null +++ b/preview/preview_test.go @@ -0,0 +1,202 @@ +package preview + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// preview_test.go covers the bundle load/validate limits and the packer's +// pad-never-truncate contract. + +// writeAuthoring lays down a valid 2-frame authoring dir and returns it. +func writeAuthoring(t *testing.T) string { + t.Helper() + dir := t.TempDir() + files := map[string]string{ + ManifestName: "frames:\n - file: 01-a.frame\n ms: 900\n - file: 02-b.frame\n ms: 400\n", + "01-a.frame": "hello\nworld\n", + "02-b.frame": "\x1b[31mred row\x1b[0m\nplain\n", + } + for name, body := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + return dir +} + +func TestPackLoadRoundTrip(t *testing.T) { + data, err := Pack(writeAuthoring(t)) + if err != nil { + t.Fatalf("Pack: %v", err) + } + anim, err := Load(data) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(anim.Frames) != 2 { + t.Fatalf("frames = %d, want 2", len(anim.Frames)) + } + if anim.Frames[0].MS != 900 || anim.Frames[1].MS != 400 { + t.Fatalf("durations = %d/%d, want 900/400", anim.Frames[0].MS, anim.Frames[1].MS) + } + for i, f := range anim.Frames { + if len(f.Rows) != Rows { + t.Fatalf("frame %d rows = %d, want %d", i, len(f.Rows), Rows) + } + } + if !strings.HasPrefix(anim.Frames[0].Rows[0], "hello ") { + t.Fatalf("row 0 = %q, want padded %q", anim.Frames[0].Rows[0], "hello …") + } + // The styled row must close its styling before the padding. + if !strings.Contains(anim.Frames[1].Rows[0], "\x1b[0m") { + t.Fatalf("styled row lacks a closing reset: %q", anim.Frames[1].Rows[0]) + } +} + +// packOne packs a single-frame authoring dir whose frame file holds body. +func packOne(t *testing.T, body string) ([]byte, error) { + t.Helper() + dir := t.TempDir() + manifest := "frames:\n - file: f.frame\n ms: 500\n" + if err := os.WriteFile(filepath.Join(dir, ManifestName), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "f.frame"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return Pack(dir) +} + +func TestPackRejects(t *testing.T) { + cases := []struct { + name, body, wantErr string + }{ + {"cursor escape", "ok\n\x1b[2;3Hmoved\n", "SGR"}, + {"unterminated escape", "bad\x1b[31\n", "unterminated"}, + {"control byte", "a\tb\n", "control character"}, + {"C1 CSI smuggle", "a\u009b2Jb\n", "control character"}, + {"raw C1 byte", "a\x9b2J\n", "invalid UTF-8"}, + {"keycap combiner", "press 7\ufe0f\u20e3 now\n", "glyph-width hazard"}, + {"zero-width joiner", "a\u200db\n", "glyph-width hazard"}, + {"too many rows", strings.Repeat("r\n", Rows+1), "rows"}, + {"oversize row", strings.Repeat("x", Cols+1) + "\n", "never truncates"}, + {"wide glyph overflow", strings.Repeat("x", Cols-1) + "🀄\n", "never truncates"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := packOne(t, tc.body) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want containing %q", err, tc.wantErr) + } + if err != nil && !strings.Contains(err.Error(), "f.frame") { + t.Fatalf("err %q does not name the offending file", err) + } + }) + } +} + +func TestPackManifestErrors(t *testing.T) { + write := func(t *testing.T, manifest string, frames map[string]string) error { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ManifestName), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + for name, body := range frames { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + _, err := Pack(dir) + return err + } + if err := write(t, "frames:\n - file: missing.frame\n ms: 100\n", nil); err == nil || + !strings.Contains(err.Error(), "missing.frame") { + t.Fatalf("missing frame file: err = %v", err) + } + if err := write(t, "frames:\n - file: f.frame\n", map[string]string{"f.frame": "x\n"}); err == nil || + !strings.Contains(err.Error(), "missing ms") { + t.Fatalf("missing ms: err = %v", err) + } + if err := write(t, "frames:\n - ms: 100\n", nil); err == nil || + !strings.Contains(err.Error(), "missing file") { + t.Fatalf("missing file field: err = %v", err) + } + if err := write(t, "frames: []\n", nil); err == nil || + !strings.Contains(err.Error(), "0 frames") { + t.Fatalf("empty manifest: err = %v", err) + } +} + +// bundle builds a valid single-frame bundle, then lets a test mutate it. +func bundle(t *testing.T, mutate func(*bundleJSON)) []byte { + t.Helper() + row := strings.Repeat(" ", Cols) + lines := make([]string, Rows) + for i := range lines { + lines[i] = row + } + b := bundleJSON{V: Version, Frames: []frameJSON{{MS: 500, Lines: lines}}} + if mutate != nil { + mutate(&b) + } + out, err := json.Marshal(b) + if err != nil { + t.Fatal(err) + } + return out +} + +func TestLoadValidates(t *testing.T) { + if _, err := Load(bundle(t, nil)); err != nil { + t.Fatalf("valid bundle: %v", err) + } + cases := []struct { + name string + mutate func(*bundleJSON) + wantErr string + }{ + {"future version", func(b *bundleJSON) { b.V = 2 }, "version 2"}, + {"no frames", func(b *bundleJSON) { b.Frames = nil }, "0 frames"}, + {"short frame", func(b *bundleJSON) { b.Frames[0].Lines = b.Frames[0].Lines[:3] }, "3 rows"}, + {"narrow row", func(b *bundleJSON) { b.Frames[0].Lines[2] = "short" }, "cells"}, + {"cursor escape", func(b *bundleJSON) { + b.Frames[0].Lines[0] = "\x1b[2J" + strings.Repeat(" ", Cols) + }, "SGR"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := Load(bundle(t, tc.mutate)) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want containing %q", err, tc.wantErr) + } + }) + } + if _, err := Load([]byte("{not json")); err == nil { + t.Fatal("garbage bundle loaded") + } + if _, err := Load(make([]byte, MaxBundleBytes+1)); err == nil || + !strings.Contains(err.Error(), "limit") { + t.Fatal("oversize bundle loaded") + } +} + +func TestLoadClampsDurations(t *testing.T) { + anim, err := Load(bundle(t, func(b *bundleJSON) { b.Frames[0].MS = 10 })) + if err != nil { + t.Fatal(err) + } + if anim.Frames[0].MS != MinFrameMS { + t.Fatalf("MS = %d, want clamped %d", anim.Frames[0].MS, MinFrameMS) + } + anim, err = Load(bundle(t, func(b *bundleJSON) { b.Frames[0].MS = 99999 })) + if err != nil { + t.Fatal(err) + } + if anim.Frames[0].MS != MaxFrameMS { + t.Fatalf("MS = %d, want clamped %d", anim.Frames[0].MS, MaxFrameMS) + } +}