Files
toml/macro/macro_test.go
T
LixenandClaude Fable 5.1 5f0e9f30a4
Test / test (push) Waiting to run
v0.5.0 macro: expand macros in a parsed document, moved from vif's pkg/tomlmacro
A macro is a [macros.<Name>] table with typed params, defaults, a doc line
and a body; any table with a "macro" key invokes one, as a value or spliced
into an array, once per zipped element under "each". Expansion runs on the
parsed tree between Parse and Decode, is bounded, and reads nothing but the
documents it is given. The package is the expander vif's scenarios, bot
graphs and audio banks already use, placed here so that a module outside
vif, the audio engine's coming spin-off first, can share it.


Claude-Session: https://claude.ai/code/session_01RP6NsvbSaLTt1UbBXHNfL4

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 16:23:23 -04:00

269 lines
7.7 KiB
Go

package macro
import (
"reflect"
"strconv"
"strings"
"testing"
"github.com/lixenwraith/toml"
)
func parse(t testing.TB, src string) map[string]any {
t.Helper()
doc, err := toml.NewParser([]byte(src)).Parse()
if err != nil {
t.Fatalf("parse: %v", err)
}
return doc
}
func expand(t *testing.T, src string) map[string]any {
t.Helper()
out, err := ExpandDocument(parse(t, src))
if err != nil {
t.Fatalf("expand: %v", err)
}
return out
}
func equal(t *testing.T, got map[string]any, want string) {
t.Helper()
if w := parse(t, want); !reflect.DeepEqual(normalize(got), normalize(w)) {
t.Fatalf("expansion\n got %v\nwant %v", got, w)
}
}
// normalize folds the parser's two array forms into one so trees compare by value.
func normalize(v any) any {
if list, ok := elements(v); ok {
out := make([]any, len(list))
for i, el := range list {
out[i] = normalize(el)
}
return out
}
if t, ok := v.(map[string]any); ok {
out := make(map[string]any, len(t))
for k, x := range t {
out[k] = normalize(x)
}
return out
}
return v
}
// A whole-string reference keeps the argument's type; a reference inside a string
// or a key is spliced as text; an omitted argument takes its default.
func TestInvocationTakesTheBodyWithArgumentsSubstituted(t *testing.T) {
got := expand(t, `
[macros.Spawn]
params = { id = "string", x = "int", hp = "float", at = "table" }
defaults = { hp = 2.5 }
body = { name = "Td${id}", x = "${x}", hp = "${hp}", pos = "${at}", "${id}_anchor" = "$$${x}" }
[states]
A = { macro = "Spawn", id = "Q1", x = 125, at = { y = 62 } }
`)
equal(t, got, `
[states.A]
name = "TdQ1"
x = 125
hp = 2.5
pos = { y = 62 }
Q1_anchor = "$125"
`)
}
// An expansion that is an array splices into the array holding the invocation;
// each expands once per zipped element, beside the arguments shared by all.
func TestArrayInvocationsSplice(t *testing.T) {
got := expand(t, `
[macros.Announce]
params = { message = "string" }
body = [{ strobe = true }, { message = "${message}" }]
[macros.Zero]
params = { name = "string", value = "int" }
defaults = { value = 0 }
body = { set = "${name}", value = "${value}" }
[states.A]
on_enter = [
{ first = true },
{ macro = "Announce", message = "GO" },
{ macro = "Zero", each = { name = ["a", "b"], value = [1, 2] } },
{ macro = "Zero", name = "c" },
]
`)
equal(t, got, `
[states.A]
on_enter = [
{ first = true },
{ strobe = true }, { message = "GO" },
{ set = "a", value = 1 }, { set = "b", value = 2 },
{ set = "c", value = 0 },
]
`)
}
// Bodies invoke other macros, arguments are expanded where they are written, and
// argument text is never read as a template, so a $ an author passes stays one.
func TestNestedInvocationsExpandAndArgumentsStayInert(t *testing.T) {
got := expand(t, `
[macros.Emit]
params = { event = "string", payload = "table" }
defaults = { payload = {} }
body = { action = "EmitEvent", event = "${event}", payload = "${payload}" }
[macros.Say]
params = { text = "string" }
body = { macro = "Emit", event = "Message", payload = { message = "${text}" } }
[macros.Both]
params = { first = "array", text = "string" }
body = { list = "${first}", say = { macro = "Say", text = "${text}" } }
[states]
A = { macro = "Both", first = [{ macro = "Emit", event = "Clear" }], text = "costs ${x} $" }
`)
equal(t, got, `
[states.A]
list = [{ action = "EmitEvent", event = "Clear", payload = {} }]
say = { action = "EmitEvent", event = "Message", payload = { message = "costs ${x} $" } }
`)
}
func TestMalformedMacrosAreRejected(t *testing.T) {
cases := []struct{ name, src, want string }{
{"unknown macro", `a = { macro = "Nope" }`, `unknown macro "Nope"`},
{"unknown argument", `
[macros.M]
body = 1
[x]
a = { macro = "M", y = 1 }`, `unknown argument "y"`},
{"missing argument", `
[macros.M]
params = { y = "int" }
body = "${y}"
[x]
a = { macro = "M" }`, `missing argument "y"`},
{"argument type", `
[macros.M]
params = { y = "int" }
body = "${y}"
[x]
a = { macro = "M", y = "one" }`, `argument "y": want int, got string`},
{"undeclared reference", `
[macros.M]
body = { a = "${y}" }`, `body.a: undeclared parameter "y"`},
{"lone dollar", `
[macros.M]
body = "5 $"`, `a literal $ is written $$`},
{"recursion", `
[macros.A]
body = { macro = "B" }
[macros.B]
body = [{ macro = "A" }]
[x]
a = { macro = "A" }`, `macro "A" invokes itself through A → B`},
{"each outside an array", `
[macros.M]
params = { y = "int" }
body = "${y}"
[x]
a = { macro = "M", each = { y = [1, 2] } }`, `each expands only inside an array`},
{"each lengths", `
[macros.M]
params = { y = "int", z = "int" }
body = "${y}"
[x]
a = [{ macro = "M", each = { y = [1, 2], z = [1] } }]`, `has 1 elements, the others 2`},
{"table spliced into text", `
[macros.M]
params = { y = "table" }
body = "at ${y}"
[x]
a = { macro = "M", y = {} }`, `table argument "y" cannot be part of a string`},
{"keys collide", `
[macros.M]
params = { y = "string" }
body = { "${y}" = 1, b = 2 }
[x]
a = { macro = "M", y = "b" }`, `key "b" appears twice`},
{"default type", `
[macros.M]
params = { y = "int" }
defaults = { y = "one" }
body = 1`, `default for "y": want int, got string`},
{"reserved parameter", `
[macros.M]
params = { each = "int" }
body = 1`, `parameter "each": not a usable name`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := ExpandDocument(parse(t, c.src))
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Fatalf("got %v, want an error containing %q", err, c.want)
}
})
}
}
// A document invoking from another document's set may not define its own, and a
// name defined in both is refused rather than shadowed.
func TestDefinitionsBelongToTheSet(t *testing.T) {
s := NewSet()
if err := s.Define(parse(t, "[macros.M]\nbody = 1\n")); err != nil {
t.Fatal(err)
}
if err := s.Define(parse(t, "[macros.M]\nbody = 2\n")); err == nil {
t.Fatal("a second definition of M was accepted")
}
if _, err := s.Expand(parse(t, "[macros.N]\nbody = 1\n")); err == nil {
t.Fatal("a document that only invokes defined a macro")
}
out, err := s.Expand(parse(t, "a = { macro = \"M\" }\n"))
if err != nil || out["a"] != int64(1) {
t.Fatalf("got %v, %v", out, err)
}
}
// Doubling bodies grow exponentially in the invocation depth; the set's budget
// stops them long before memory does, and nesting has its own ceiling.
func TestExpansionIsBounded(t *testing.T) {
var b strings.Builder
b.WriteString("[macros.M0]\nbody = [1, 2, 3, 4, 5, 6, 7, 8]\n")
for i := 1; i <= 24; i++ {
b.WriteString("[macros.M" + strconv.Itoa(i) + "]\nbody = [{ macro = \"M" + strconv.Itoa(i-1) + "\" }, { macro = \"M" + strconv.Itoa(i-1) + "\" }]\n")
}
b.WriteString("[x]\na = { macro = \"M24\" }\n")
if _, err := ExpandDocument(parse(t, b.String())); err == nil || !strings.Contains(err.Error(), "expansion exceeds") {
t.Fatalf("doubling to 2^24 copies: %v", err)
}
b.Reset()
for i := range maxDepth + 1 {
b.WriteString("[macros.N" + strconv.Itoa(i) + "]\nbody = { macro = \"N" + strconv.Itoa(i+1) + "\" }\n")
}
b.WriteString("[macros.N" + strconv.Itoa(maxDepth+1) + "]\nbody = 1\n[x]\na = { macro = \"N0\" }\n")
if _, err := ExpandDocument(parse(t, b.String())); err == nil || !strings.Contains(err.Error(), "nest deeper") {
t.Fatalf("a chain of %d macros: %v", maxDepth+2, err)
}
}
// Scenarios arrive from peers: any parsed document either expands or is refused.
func FuzzExpandDocument(f *testing.F) {
f.Add("[macros.M]\nparams = { y = \"int\" }\nbody = [\"${y}\"]\n[x]\na = [{ macro = \"M\", each = { y = [1, 2] } }]\n")
f.Add("[macros.M]\nbody = { \"${y}\" = 1 }\n")
f.Add("a = { macro = \"M\" }\n")
f.Fuzz(func(t *testing.T, src string) {
doc, err := toml.NewParser([]byte(src)).Parse()
if err != nil {
return
}
_, _ = ExpandDocument(doc)
})
}