- .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
208 lines
5.6 KiB
Go
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")
|
|
}
|
|
}
|