Files
agent-tools/pixel-sprite-maker/sprite/sprite_test.go
claude 23135a96d1 pixel-sprite-maker: spritec - .sprite text format -> png/jpg/svg + sprite sheets
- .sprite format: single-char palette keys (hex/rgb()/CSS names/none), grid
  rows, '.' transparent by default, max 256x256 per sprite
- render/sheet/info/preview subcommands; sheets name themselves
  <base>_<cellW>x<cellH>_<cols>x<rows>.<ext> (row-major)
- SVG output RLE-merges pixel runs; JPG composites over --bg
- go tests for parser, colors, scaling, svg, sheet layout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmdG9GqfSWCzts7AkDwRDh
2026-07-14 02:22:16 +02:00

208 lines
5.6 KiB
Go

package sprite
import (
"bytes"
"encoding/xml"
"image/color"
"strings"
"testing"
)
const coin = `
# a tiny coin
sprite: coin
palette:
k = #000000
y = gold
Y = #FFF3A0 highlight
grid:
.kk.
kyYk
kyyk
.kk.
`
func parseOK(t *testing.T, src string) *Sprite {
t.Helper()
s, err := Parse(strings.NewReader(src))
if err != nil {
t.Fatalf("parse failed: %v", err)
}
return s
}
func TestParseBasics(t *testing.T) {
s := parseOK(t, coin)
if s.Name != "coin" {
t.Errorf("name = %q, want coin", s.Name)
}
if s.W != 4 || s.H != 4 {
t.Errorf("size = %dx%d, want 4x4", s.W, s.H)
}
if got := s.At(0, 0); got.A != 0 {
t.Errorf("(0,0) should be transparent via the implicit '.', got %v", got)
}
if got := s.At(1, 1); got != (color.NRGBA{255, 215, 0, 255}) {
t.Errorf("(1,1) = %v, want gold", got)
}
if got := s.At(2, 1); got != (color.NRGBA{255, 243, 160, 255}) {
t.Errorf("(2,1) = %v, want #FFF3A0", got)
}
}
func TestParseErrors(t *testing.T) {
cases := map[string]string{
"ragged rows": "palette:\n a = red\ngrid:\naa\naaa\n",
"unknown key": "palette:\n a = red\ngrid:\nab\naa\n",
"bad color": "palette:\n a = notacolor\ngrid:\na\n",
"no grid": "palette:\n a = red\n",
"dup key": "palette:\n a = red\n a = blue\ngrid:\na\n",
"multirune key": "palette:\n ab = red\ngrid:\na\n",
"colon key": "palette:\n : = red\ngrid:\n.\n",
}
for name, src := range cases {
if _, err := Parse(strings.NewReader(src)); err == nil {
t.Errorf("%s: expected an error", name)
}
}
}
func TestMaxSize(t *testing.T) {
row := strings.Repeat("a", 257)
src := "palette:\n a = red\ngrid:\n" + row + "\n"
if _, err := Parse(strings.NewReader(src)); err == nil {
t.Error("257 px wide sprite should be rejected")
}
ok := "palette:\n a = red\ngrid:\n" + strings.Repeat(strings.Repeat("a", 256)+"\n", 256)
s := parseOK(t, ok)
if s.W != 256 || s.H != 256 {
t.Errorf("size = %dx%d, want 256x256", s.W, s.H)
}
}
func TestParseColorForms(t *testing.T) {
cases := map[string]color.NRGBA{
"none": {0, 0, 0, 0},
"-": {0, 0, 0, 0},
"#F00": {255, 0, 0, 255},
"#F00A": {255, 0, 0, 170},
"#00FF00": {0, 255, 0, 255},
"#00FF0080": {0, 255, 0, 128},
"rgb(1,2,3)": {1, 2, 3, 255},
"rgba(1,2,3,64)": {1, 2, 3, 64},
"rgba(1, 2, 3, .5)": {1, 2, 3, 128},
"RebeccaPurple": {102, 51, 153, 255},
}
for in, want := range cases {
got, err := ParseColor(in)
if err != nil {
t.Errorf("ParseColor(%q): %v", in, err)
continue
}
if got != want {
t.Errorf("ParseColor(%q) = %v, want %v", in, got, want)
}
}
for _, bad := range []string{"", "#12345", "rgb(300,0,0)", "purpleish"} {
if _, err := ParseColor(bad); err == nil {
t.Errorf("ParseColor(%q): expected error", bad)
}
}
}
func TestImageAndScale(t *testing.T) {
s := parseOK(t, coin)
img := Image(s, 3)
if b := img.Bounds(); b.Dx() != 12 || b.Dy() != 12 {
t.Fatalf("scaled bounds = %v, want 12x12", b)
}
// pixel (1,1) is gold -> block at (3..5, 3..5)
if got := img.NRGBAAt(4, 4); got != (color.NRGBA{255, 215, 0, 255}) {
t.Errorf("scaled gold pixel = %v", got)
}
if got := img.NRGBAAt(0, 0); got.A != 0 {
t.Errorf("corner should stay transparent, got %v", got)
}
}
func TestSVGOutput(t *testing.T) {
s := parseOK(t, coin)
var buf bytes.Buffer
if err := WriteSVG(&buf, s, 10); err != nil {
t.Fatal(err)
}
out := buf.String()
if !strings.Contains(out, `viewBox="0 0 4 4"`) || !strings.Contains(out, `width="40"`) {
t.Errorf("svg header wrong:\n%s", out)
}
// row 2 (y=1) has runs k, yY?, no: k y Y k -> y and Y differ, so no merge.
// row 3 (y=2) k yy k -> the two golds merge into one rect of width 2.
if !strings.Contains(out, `<rect x="1" y="2" width="2" height="1" fill="#ffd700"/>`) {
t.Errorf("expected RLE-merged gold rect:\n%s", out)
}
var doc struct{ XMLName xml.Name }
if err := xml.Unmarshal(buf.Bytes(), &doc); err != nil {
t.Errorf("svg is not valid XML: %v", err)
}
}
func TestSheetLayoutAndNaming(t *testing.T) {
a := parseOK(t, coin)
b := parseOK(t, coin)
c := parseOK(t, coin)
sh, err := NewSheet([]*Sprite{a, b, c}, 2)
if err != nil {
t.Fatal(err)
}
if sh.Cols != 2 || sh.Rows != 2 {
t.Errorf("layout = %dx%d, want 2x2", sh.Cols, sh.Rows)
}
if w, h := sh.Bounds(); w != 8 || h != 8 {
t.Errorf("bounds = %dx%d, want 8x8", w, h)
}
if got := sh.FileBase("walk"); got != "walk_4x4_2x2" {
t.Errorf("FileBase = %q, want walk_4x4_2x2", got)
}
// cell (1,1) in frame 2 (top-right) is gold
if got := sh.At(5, 1); got != (color.NRGBA{255, 215, 0, 255}) {
t.Errorf("sheet pixel in frame 2 = %v, want gold", got)
}
// 4th cell (bottom-right) has no sprite -> transparent
if got := sh.At(7, 7); got.A != 0 {
t.Errorf("empty cell should be transparent, got %v", got)
}
}
func TestSheetSizeMismatch(t *testing.T) {
a := parseOK(t, coin)
b := parseOK(t, "palette:\n a = red\ngrid:\naa\n")
if _, err := NewSheet([]*Sprite{a, b}, 0); err == nil {
t.Error("mismatched sprite sizes should be rejected")
}
}
func TestSheetDefaultSingleRow(t *testing.T) {
a := parseOK(t, coin)
sh, err := NewSheet([]*Sprite{a, a, a}, 0)
if err != nil {
t.Fatal(err)
}
if sh.Cols != 3 || sh.Rows != 1 {
t.Errorf("default layout = %dx%d, want 3x1", sh.Cols, sh.Rows)
}
}
func TestJPGComposite(t *testing.T) {
s := parseOK(t, coin)
var buf bytes.Buffer
if err := WriteJPG(&buf, s, 1, color.NRGBA{255, 255, 255, 255}, 90); err != nil {
t.Fatal(err)
}
if buf.Len() == 0 {
t.Error("empty jpg output")
}
if err := WriteJPG(&buf, s, 1, Transparent, 150); err == nil {
t.Error("quality 150 should be rejected")
}
}