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
This commit is contained in:
207
pixel-sprite-maker/sprite/sprite_test.go
Normal file
207
pixel-sprite-maker/sprite/sprite_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user