Files
aego/script/compiler/compiler_test.go

367 lines
13 KiB
Go

package compiler
import (
"bytes"
"flag"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"aego/script/abi"
"aego/script/ast"
"aego/script/bytecode"
"aego/script/checker"
"aego/script/diag"
"aego/script/parser"
"aego/script/source"
"aego/script/verify"
"aego/script/vm"
)
var update = flag.Bool("update", false, "rewrite golden disassembly")
const scriptsDir = "../../tests/scripts"
func surface() *abi.Table {
t := abi.NewTable()
f := abi.T(abi.Float)
t.AddFunc("aego/log", "Info", abi.Sig(abi.T(abi.Void), abi.T(abi.String)))
t.AddFunc("aego/math", "Clamp", abi.Sig(f, f, f, f))
t.AddFunc("aego/math", "Abs", abi.Sig(f, f))
t.AddFunc("aego/input", "Axis", abi.Sig(f, abi.T(abi.String)))
t.AddEnum(&abi.EnumDef{Module: "aego/input", Name: "Key", Members: []abi.EnumMember{{Name: "A", Value: 4}, {Name: "Space", Value: 44}}})
t.AddConst("aego/input", "KeySpace", abi.Const{Type: abi.EnumOf("aego/input.Key"), Int: 44})
t.AddFunc("aego/input", "KeyPressed", abi.Sig(abi.T(abi.Bool), abi.EnumOf("aego/input.Key")))
t.AddFunc("mygame/combat", "Damage", abi.Sig(abi.T(abi.Void), abi.T(abi.NodeRef), abi.T(abi.Int)))
t.AddMethod(abi.NodeRef, "Transform2D", abi.Sig(abi.T(abi.Transform2D)))
t.AddMethod(abi.NodeRef, "SetTransform2D", abi.Sig(abi.T(abi.Void), abi.T(abi.Transform2D)))
t.AddComponent(&abi.Component{Name: "engine.SpriteRenderer", Fields: []abi.Field{{Name: "FlipX", Type: abi.T(abi.Bool)}}})
t.Freeze()
return t
}
type record struct {
pose [3]vm.Value
damage []int64
logs []string
}
type comps map[int]vm.Value
func (c comps) GetField(ref vm.Value, f int, dst *vm.Value) error {
*dst = c[f]
return nil
}
func (c comps) SetField(ref vm.Value, f int, v vm.Value) error {
c[f] = v
return nil
}
func hosts(rec *record) func(bytecode.HostImport) (vm.HostFunc, bool) {
table := map[string]vm.HostFunc{
"aego/log.Info": func(m *vm.Machine, w []vm.Value) error {
s, _ := m.Str(w[0])
rec.logs = append(rec.logs, s)
return nil
},
"aego/math.Clamp": func(m *vm.Machine, w []vm.Value) error {
v, lo, hi := w[0].Float(), w[1].Float(), w[2].Float()
w[0] = vm.Float(min(max(v, lo), hi))
return nil
},
"aego/math.Abs": func(m *vm.Machine, w []vm.Value) error {
w[0] = vm.Float(max(w[0].Float(), -w[0].Float()))
return nil
},
"aego/input.Axis": func(m *vm.Machine, w []vm.Value) error { w[0] = vm.Float(1); return nil },
"aego/input.KeyPressed": func(m *vm.Machine, w []vm.Value) error { w[0] = vm.Bool(w[0].Int() == 44); return nil },
"mygame/combat.Damage": func(m *vm.Machine, w []vm.Value) error {
rec.damage = append(rec.damage, w[1].Int())
return nil
},
"#NodeRef.Transform2D": func(m *vm.Machine, w []vm.Value) error {
w[0], w[1], w[2] = vm.Vec(1, 2, 0, 0), vm.Float(0), vm.Vec(1, 1, 0, 0)
return nil
},
"#NodeRef.SetTransform2D": func(m *vm.Machine, w []vm.Value) error {
copy(rec.pose[:], w[1:4])
return nil
},
"#NodeRef.Has<engine.SpriteRenderer>": func(m *vm.Machine, w []vm.Value) error { w[0] = vm.Bool(true); return nil },
"#NodeRef.Get<engine.SpriteRenderer>": func(m *vm.Machine, w []vm.Value) error { w[0] = vm.Value{A: 1}; return nil },
}
return func(imp bytecode.HostImport) (vm.HostFunc, bool) {
h, ok := table[imp.Module+"."+imp.Name]
return h, ok
}
}
func build(t *testing.T, pkgs map[string]string) (*checker.Analysis, *source.Manager) {
t.Helper()
sm := source.NewManager()
files := map[string][]*ast.File{}
for p, src := range pkgs {
f, ds := parser.ParseSource(sm, p+"/main.aes", []byte(src))
if diag.HasErrors(ds) {
var b bytes.Buffer
diag.Print(&b, sm, ds)
t.Fatal(b.String())
}
files[p] = append(files[p], f)
}
a := checker.Check(sm, files, surface(), checker.Options{})
if a.HasErrors() {
var b bytes.Buffer
diag.Print(&b, sm, a.Diagnostics)
t.Fatal(b.String())
}
return a, sm
}
func compile(t *testing.T, a *checker.Analysis, sm *source.Manager, pkg string) *bytecode.Module {
t.Helper()
m, ds := Compile(a, pkg, Options{Sources: sm})
if m == nil || diag.HasErrors(ds) {
var b bytes.Buffer
diag.Print(&b, sm, ds)
t.Fatalf("compile failed:\n%s", b.String())
}
if vd := verify.Module(m); len(vd) > 0 {
var b bytes.Buffer
bytecode.Disassemble(&b, m)
t.Fatalf("verify: %s\n%s", vd[0].Message, b.String())
}
return m
}
func runFunc(t *testing.T, src, name string, args ...vm.Value) ([]vm.Value, *vm.Fault) {
t.Helper()
a, sm := build(t, map[string]string{"game/t": src})
mod := compile(t, a, sm, "game/t")
m := vm.New(vm.Options{Budget: 10_000_000})
lm, err := m.Load(mod, hosts(&record{}))
if err != nil {
t.Fatal(err)
}
out, fl := m.Call(lm.Func(name), nil, args)
return append([]vm.Value(nil), out...), fl
}
func TestConformance(t *testing.T) {
cases := []struct {
name string
src string
fn string
args []vm.Value
want int64
}{
{"fib", "package t\nfunc Fib(n int) int {\n\tif n < 2 {\n\t\treturn n\n\t}\n\treturn Fib(n-1) + Fib(n-2)\n}\n", "Fib", []vm.Value{vm.Int(20)}, 6765},
{"loop", "package t\nfunc F(n int) int {\n\ts := 0\n\tfor i := 0; i < n; i++ {\n\t\tif i%2 == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif i > 50 {\n\t\t\tbreak\n\t\t}\n\t\ts += i\n\t}\n\treturn s\n}\n", "F", []vm.Value{vm.Int(100)}, 625},
{"convert", "package t\nfunc F(f float) int {\n\treturn int(f*2) + int(float(3)/2)\n}\n", "F", []vm.Value{vm.Float(2.75)}, 6},
{"strings", "package t\nfunc F(n int) int {\n\ts := \"a\" + string(n) + \"b\"\n\tif s == \"a12b\" && s < \"b\" {\n\t\treturn len(s)\n\t}\n\treturn -1\n}\n", "F", []vm.Value{vm.Int(12)}, 4},
{"structs", "package t\ntype P struct {\n\tX int\n\tY int\n}\nfunc F() int {\n\tp := P{X: 1, Y: 2}\n\tq := p\n\tq.X = 10\n\tif p == (P{X: 1, Y: 2}) && q != p {\n\t\treturn q.X + p.Y\n\t}\n\treturn -1\n}\n", "F", nil, 12},
{"enumSwitch", "package t\nenum E {\n\tA\n\tB\n\tC\n}\nfunc F(e E) int {\n\tswitch e {\n\tcase E.A:\n\t\treturn 1\n\tcase E.B, E.C:\n\t\treturn 2\n\t}\n\treturn 0\n}\n", "F", []vm.Value{vm.Int(2)}, 2},
{"slices", "package t\nfunc F() int {\n\ts := []int{1, 2}\n\ts = append(s, 3)\n\ts[0] += 4\n\tt := 0\n\tfor i := 0; i < len(s); i++ {\n\t\tt += s[i]\n\t}\n\treturn t\n}\n", "F", nil, 10},
{"structSlice", "package t\ntype P struct {\n\tX int\n\tY int\n}\nfunc F() int {\n\tvar ps []P\n\tps = append(ps, P{X: 1, Y: 2})\n\tps = append(ps, P{X: 3, Y: 4})\n\tps[1].Y = 40\n\treturn ps[0].X + ps[1].Y\n}\n", "F", nil, 41},
{"shortCircuit", "package t\nfunc F(n int) int {\n\tif n > 0 && 10/n > 2 {\n\t\treturn 1\n\t}\n\treturn 0\n}\n", "F", []vm.Value{vm.Int(0)}, 0},
{"consts", "package t\nconst K = 3 * 4\nconst M = -9223372036854775808\nfunc F() int {\n\tif M < 0 {\n\t\treturn K + 1\n\t}\n\treturn 0\n}\n", "F", nil, 13},
{"bigConst", "package t\nfunc F() int {\n\treturn 1_000_000 + 7\n}\n", "F", nil, 1000007},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, fl := runFunc(t, tc.src, tc.fn, tc.args...)
if fl != nil {
t.Fatalf("fault: %v", fl)
}
if len(out) != 1 || out[0].Int() != tc.want {
t.Fatalf("got %v want %d", out, tc.want)
}
})
}
}
func TestFloatPrograms(t *testing.T) {
cases := map[string]struct {
src string
want float32
}{
"vectors": {"package t\nfunc F() float {\n\tv := Vec2{X: 1, Y: 2}\n\tw := (v + v) * 0.5\n\tw.Y += 1\n\treturn w.X + w.Y\n}\n", 4},
"transform": {"package t\nfunc F() float {\n\tt := Transform2D{}\n\tt.Position.X += 2\n\tt.Scale = Vec2{X: 3, Y: 3}\n\treturn t.Position.X * t.Scale.Y\n}\n", 6},
"negate": {"package t\nfunc F() float {\n\tv := -Vec2{X: 1, Y: 2}\n\tx := 2.5\n\treturn v.Y - x\n}\n", -4.5},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
out, fl := runFunc(t, tc.src, "F")
if fl != nil || out[0].Float() != tc.want {
t.Fatalf("got %v %v want %v", out, fl, tc.want)
}
})
}
}
func TestRuntimeFaults(t *testing.T) {
_, fl := runFunc(t, "package t\nfunc Spin() int {\n\tfor {\n\t}\n}\n", "Spin")
if fl == nil || fl.Code != vm.CodeBudget {
t.Fatalf("budget: %v", fl)
}
_, fl = runFunc(t, "package t\nfunc F() int {\n\ts := []int{1}\n\treturn s[3]\n}\n", "F")
if fl == nil || fl.Code != vm.CodeIndex || fl.Stack[0].Line != 4 {
t.Fatalf("index: %+v", fl)
}
}
func TestScriptSelf(t *testing.T) {
src := "package t\nscript Counter {\n\t@export\n\tvar N int = 5\n\tfunc Update(dt float) {\n\t\tN++\n\t}\n}\n"
a, sm := build(t, map[string]string{"game/t": src})
mod := compile(t, a, sm, "game/t")
sd := mod.Scripts[0]
if sd.Slots != 2 || sd.Fields[0].Slot != 1 || !sd.Fields[0].HasDefault || sd.Update < 0 {
t.Fatalf("script def %+v", sd)
}
m := vm.New(vm.Options{})
lm, err := m.Load(mod, nil)
if err != nil {
t.Fatal(err)
}
self := []vm.Value{{A: 1}, {A: sd.Fields[0].Default.A}}
upd := lm.Funcs[sd.Update]
for i := 0; i < 3; i++ {
if _, fl := m.Call(upd, self, []vm.Value{vm.Float(0.016)}); fl != nil {
t.Fatal(fl)
}
}
if self[1].Int() != 8 {
t.Fatalf("N = %d", self[1].Int())
}
}
func readPlayer(t *testing.T) string {
t.Helper()
src, err := os.ReadFile(filepath.Join(scriptsDir, "player.aes"))
if err != nil {
t.Fatal(err)
}
return string(src)
}
func TestPlayerRuns(t *testing.T) {
a, sm := build(t, map[string]string{"mygame/player": readPlayer(t)})
mod := compile(t, a, sm, "mygame/player")
rec := &record{}
m := vm.New(vm.Options{})
cs := comps{}
m.Comps = cs
lm, err := m.Load(mod, hosts(rec))
if err != nil {
t.Fatal(err)
}
sd := mod.Scripts[0]
self := make([]vm.Value, sd.Slots)
self[0] = vm.Value{A: 1}
for _, f := range sd.Fields {
if f.HasDefault {
self[f.Slot] = vm.Value{A: f.Default.A, B: f.Default.B}
}
if f.Name == "Target" {
self[f.Slot] = vm.Value{A: 7}
}
}
if _, fl := m.Call(lm.Funcs[sd.Ready], self, nil); fl != nil {
t.Fatal(fl)
}
if _, fl := m.Call(lm.Funcs[sd.Update], self, []vm.Value{vm.Float(0.5)}); fl != nil {
t.Fatal(fl)
}
if x := rec.pose[0].Lane(0); x != 111 {
t.Fatalf("player moved to x=%v, want 111", x)
}
if len(rec.damage) != 1 || rec.damage[0] != 5 || len(rec.logs) != 1 || rec.logs[0] != "ready" {
t.Fatalf("host calls %+v", rec)
}
if v, ok := cs[0]; !ok || v.Bool() {
t.Fatal("FlipX was not written as false")
}
}
func TestGoldenDisassembly(t *testing.T) {
a, sm := build(t, map[string]string{"mygame/player": readPlayer(t)})
mod := compile(t, a, sm, "mygame/player")
var b bytes.Buffer
bytecode.DisassembleSource(&b, mod, func(file string, line int) string {
id, ok := sm.Lookup(file)
if !ok {
return ""
}
return sm.Line(id, line)
})
golden := filepath.Join("testdata", "player.dis")
if *update {
os.MkdirAll("testdata", 0o755)
if err := os.WriteFile(golden, b.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("golden missing, run go test ./script/compiler -update: %v", err)
}
if !bytes.Equal(want, b.Bytes()) {
t.Errorf("disassembly changed\n%s", b.String())
}
}
func TestInterfaceHashAndCacheKey(t *testing.T) {
hash := func(src string) [32]byte {
a, _ := build(t, map[string]string{"game/t": src})
return InterfaceHash(a, "game/t")
}
base := hash("package t\nfunc Add(a int, b int) int {\n\treturn a + b\n}\nfunc helper() int {\n\treturn 1\n}\n")
body := hash("package t\nfunc Add(a int, b int) int {\n\treturn b + a\n}\nfunc helper() int {\n\treturn 2\n}\n")
sig := hash("package t\nfunc Add(a int, b float) int {\n\treturn a\n}\nfunc helper() int {\n\treturn 1\n}\n")
if base != body {
t.Fatal("body-only edit changed the interface hash")
}
if base == sig {
t.Fatal("signature change kept the interface hash")
}
src := map[string][]byte{"main.aes": []byte("package t")}
k1 := CacheKey(src, nil, [32]byte{1}, Options{})
k2 := CacheKey(src, nil, [32]byte{2}, Options{})
k3 := CacheKey(src, map[string][32]byte{"game/a": {9}}, [32]byte{1}, Options{})
if k1 == k2 || k1 == k3 || k1 != CacheKey(src, nil, [32]byte{1}, Options{}) {
t.Fatal("cache key is not sensitive or not stable")
}
}
func TestRegisterLimit(t *testing.T) {
var b strings.Builder
b.WriteString("package t\nfunc Big() int {\n")
for i := 0; i < 300; i++ {
b.WriteString("\tvar v" + strconv.Itoa(i) + " int\n")
}
b.WriteString("\treturn v0\n}\n")
a, sm := build(t, map[string]string{"game/t": b.String()})
m, ds := Compile(a, "game/t", Options{Sources: sm})
if m != nil || len(ds) == 0 || ds[0].Code != diag.RegisterLimit {
t.Fatalf("limit not reported: %v", ds)
}
}
func TestSliceTooWide(t *testing.T) {
var b strings.Builder
b.WriteString("package t\ntype W struct {\n")
for i := 0; i < 33; i++ {
b.WriteString("\tF" + strconv.Itoa(i) + " int\n")
}
b.WriteString("}\nfunc F() int {\n\ts := []W{}\n\treturn len(s)\n}\n")
a, sm := build(t, map[string]string{"game/t": b.String()})
m, ds := Compile(a, "game/t", Options{Sources: sm})
if m != nil || len(ds) == 0 || ds[0].Code != diag.SliceTooWide {
t.Fatalf("limit not reported: %v", ds)
}
}