Files
agent-tools/svg-maker/svg/svg_test.go
claude 36631c7709 svg-maker: svgc - .svgd text format -> SVG for agent-helm sharing
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
2026-08-07 00:37:51 +02:00

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 &amp; &lt;you&gt;`, // 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)
}
}