Line-based DSL (shapes, text, groups, color vars) with strict validation and line-numbered errors, SVG emitter with visibility defaults, truecolor terminal preview (same half-block technique as spritec), info with bbox + outside-canvas warnings. Verified end-to-end: svgc build -> helmd share -> visible through the hub. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
202 lines
5.4 KiB
Go
202 lines
5.4 KiB
Go
package svg
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const sample = `# test scene
|
|
canvas 100 80
|
|
bg #112233
|
|
def accent #4f9cf9
|
|
rect 10 10 30 20 fill=$accent rx=3
|
|
circle 70 30 15 fill=#3fca7c stroke=white stroke-width=2
|
|
line 0 70 100 70 stroke=red width=3
|
|
polygon 10,60 30,40 50,60 fill=#e0a63f
|
|
path M60,60 L80,70 L90,50 Z stroke=white
|
|
text 50 25 "hi & <you>" size=10 fill=white anchor=middle
|
|
group stroke=gray
|
|
line 5 5 15 15
|
|
end
|
|
`
|
|
|
|
func TestParseAndEmit(t *testing.T) {
|
|
xml, doc, err := Build(sample)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if doc.W != 100 || doc.H != 80 || doc.Bg != "#112233" {
|
|
t.Errorf("canvas/bg wrong: %+v", doc)
|
|
}
|
|
for _, want := range []string{
|
|
`viewBox="0 0 100 80"`,
|
|
`<rect x="10" y="10" width="30" height="20"`,
|
|
`fill="#4f9cf9"`, // $accent resolved
|
|
`rx="3"`,
|
|
`<circle cx="70" cy="30" r="15"`,
|
|
`stroke-width="3"`, // width alias
|
|
`<polygon points="10,60 30,40 50,60"`,
|
|
`<path d="M60,60 L80,70 L90,50 Z"`,
|
|
`hi & <you>`, // escaped text
|
|
`text-anchor="middle"`,
|
|
`font-size="10"`,
|
|
`<g stroke="gray">`,
|
|
} {
|
|
if !strings.Contains(xml, want) {
|
|
t.Errorf("emitted SVG missing %q\n%s", want, xml)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseErrorsCarryLineNumbers(t *testing.T) {
|
|
cases := map[string]string{
|
|
"canvas 100 100\nrect 1 2 3": "line 2",
|
|
"canvas 100 100\nrect 1 2 3 four": "not a number",
|
|
"canvas 100 100\ncircle 1 2 3 glow=yes": "unknown attribute",
|
|
"canvas 100 100\nrect 1 2 3 4 fill=$missing": "undefined color variable",
|
|
"canvas 100 100\nblob 1 2": "unknown element",
|
|
"canvas 100 100\ngroup\nline 1 2 3 4": "never closed",
|
|
"canvas 100 100\nend": "end without group",
|
|
"canvas 100 100\npath X10,10": "path",
|
|
"canvas 100 100\nrect 1 2 3 4 fill=#zzz": "non-hex",
|
|
"rect 1 2 3 4": "missing canvas",
|
|
}
|
|
for src, want := range cases {
|
|
_, _, err := Build(src)
|
|
if err == nil {
|
|
t.Errorf("no error for %q", src)
|
|
continue
|
|
}
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("error for %q = %q, want substring %q", src, err, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVisibilityDefaults(t *testing.T) {
|
|
xml, _, err := Build("canvas 10 10\nline 0 0 10 10\npolyline 0,0 5,5 10,0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(xml, `<line x1="0" y1="0" x2="10" y2="10" stroke="black"/>`) {
|
|
t.Errorf("line did not get default stroke:\n%s", xml)
|
|
}
|
|
if !strings.Contains(xml, `fill="none"`) {
|
|
t.Errorf("polyline did not get fill=none:\n%s", xml)
|
|
}
|
|
}
|
|
|
|
func TestFlattenPath(t *testing.T) {
|
|
subs, err := flattenPath("M0,0 L10,0 V10 H0 Z")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(subs) != 1 {
|
|
t.Fatalf("want 1 subpath, got %d", len(subs))
|
|
}
|
|
pts := subs[0]
|
|
last := pts[len(pts)-1]
|
|
if last[0] != 0 || last[1] != 0 {
|
|
t.Errorf("Z should close back to start, ended at %v", last)
|
|
}
|
|
// curves flatten into many segments
|
|
subs, err = flattenPath("M0,0 Q50,100 100,0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(subs[0]) < 10 {
|
|
t.Errorf("quadratic should flatten to many points, got %d", len(subs[0]))
|
|
}
|
|
// relative commands
|
|
subs, err = flattenPath("m10,10 l10,0 l0,10 z")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := subs[0][2]; got[0] != 20 || got[1] != 20 {
|
|
t.Errorf("relative path point = %v, want 20,20", got)
|
|
}
|
|
if _, err := flattenPath("L10,10"); err == nil {
|
|
t.Error("path not starting with M should fail")
|
|
}
|
|
if _, err := flattenPath("M0,0 A5,5 0 0 1 10,10"); err == nil {
|
|
t.Error("unsupported arc command should fail")
|
|
}
|
|
}
|
|
|
|
func TestRasterShapes(t *testing.T) {
|
|
doc, err := Parse("canvas 100 100\nbg black\ncircle 50 50 30 fill=red")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
doc.normalize()
|
|
r := RenderGrid(doc, 50)
|
|
if r.W != 50 || r.H != 50 {
|
|
t.Fatalf("grid %dx%d, want 50x50", r.W, r.H)
|
|
}
|
|
center := r.Pix[25*r.W+25]
|
|
if center[0] < 200 || center[1] > 50 {
|
|
t.Errorf("center should be red, got %v", center)
|
|
}
|
|
corner := r.Pix[0]
|
|
if corner[0] > 50 && corner[1] > 50 && corner[2] > 50 {
|
|
t.Errorf("corner should be black, got %v", corner)
|
|
}
|
|
}
|
|
|
|
func TestRasterPolygonFill(t *testing.T) {
|
|
doc, err := Parse("canvas 100 100\npolygon 0,0 100,0 100,100 0,100 fill=#00ff00")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
doc.normalize()
|
|
r := RenderGrid(doc, 20)
|
|
mid := r.Pix[10*r.W+10]
|
|
if mid[1] < 200 {
|
|
t.Errorf("polygon interior not filled: %v", mid)
|
|
}
|
|
}
|
|
|
|
func TestANSIPreviewShape(t *testing.T) {
|
|
doc, _ := Parse("canvas 40 20\nbg #000000\nrect 0 0 40 20 fill=white")
|
|
doc.normalize()
|
|
out := RenderGrid(doc, 40).ANSI()
|
|
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
|
if len(lines) != 10 { // 20 pixel rows / 2 per text row
|
|
t.Errorf("ANSI preview has %d rows, want 10", len(lines))
|
|
}
|
|
if !strings.Contains(out, "▀") {
|
|
t.Error("preview contains no half-block characters")
|
|
}
|
|
}
|
|
|
|
func TestInfoWarnsOutsideCanvas(t *testing.T) {
|
|
doc, err := Parse("canvas 50 50\ncircle 25 25 10 fill=red\nrect 100 100 20 20 fill=blue\nline 40 40 60 60")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
doc.normalize()
|
|
info := Info(doc)
|
|
for _, want := range []string{
|
|
"canvas: 50x50",
|
|
"circle:1",
|
|
"entirely outside",
|
|
"sticks outside",
|
|
} {
|
|
if !strings.Contains(info, want) {
|
|
t.Errorf("info missing %q:\n%s", want, info)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGroupAttrInheritanceInRaster(t *testing.T) {
|
|
doc, err := Parse("canvas 10 10\ngroup fill=#ff0000\nrect 0 0 10 10\nend")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
doc.normalize()
|
|
r := RenderGrid(doc, 10)
|
|
if p := r.Pix[5*r.W+5]; p[0] < 200 {
|
|
t.Errorf("group fill not inherited: %v", p)
|
|
}
|
|
}
|