Files

315 lines
9.1 KiB
Go

package langsvc
import (
"encoding/json"
"os"
"path/filepath"
"slices"
"strings"
"testing"
scriptstdlib "aego/modules/scripting/stdlib"
"aego/scene"
"aego/scene/frame"
"aego/scene/scriptcomp"
"aego/schema"
"aego/script/bind"
"aego/script/bind/manifest"
"aego/script/source"
"aego/script/stdlib"
"aego/script/workspace"
)
const srcA = "package a\n\n// Add sums two ints.\nfunc Add(x int, y int) int {\n\treturn x + y\n}\n"
const srcB = "package b\n\nimport \"game/a\"\n\nfunc Twice(x int) int {\n\treturn a.Add(x, x)\n}\n"
const scriptTmpl = `package c
import (
"aego/math"
"game/combat"
)
script Mover {
@export
var Speed float = 1
func Update(dt float) {
CURSOR
}
}
`
type queue struct {
work []func()
main []func()
}
func (q *queue) Submit(fn func()) { q.work = append(q.work, fn) }
func (q *queue) OnMain(fn func()) { q.main = append(q.main, fn) }
func (q *queue) run() {
for len(q.work) > 0 || len(q.main) > 0 {
if len(q.work) > 0 {
fn := q.work[0]
q.work = q.work[1:]
fn()
continue
}
fn := q.main[0]
q.main = q.main[1:]
fn()
}
}
func setup(t *testing.T, extra ...func(*bind.Registry)) (*Service, *workspace.Workspace, *queue) {
t.Helper()
root := t.TempDir()
for rel, src := range map[string]string{"scripts/a/a.aes": srcA, "scripts/b/b.aes": srcB} {
p := filepath.Join(root, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(src), 0o644); err != nil {
t.Fatal(err)
}
}
types := scene.NewTypes(schema.NewRegistry())
if err := frame.RegisterTypes(types); err != nil {
t.Fatal(err)
}
if err := registerMarker(types); err != nil {
t.Fatal(err)
}
if err := scriptcomp.Register(types); err != nil {
t.Fatal(err)
}
r := bind.NewManifest()
scriptstdlib.Install(r, stdlib.Options{Seed: 1, Testing: true})
bind.Proc2(r.Module("game/combat"), "Damage", func(*bind.Ctx, int64, float32) {}, bind.Doc("deals damage"))
for _, fn := range extra {
fn(r)
}
m, err := manifest.FromRegistry(r, types.Schema())
if err != nil {
t.Fatal(err)
}
kw, err := os.ReadFile("../../tests/docs/testdata/keywords.golden.json")
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(kw, &m.Keywords); err != nil {
t.Fatal(err)
}
tab, err := m.Table()
if err != nil {
t.Fatal(err)
}
w, err := workspace.Open(workspace.Config{Root: root, Module: "game", Bindings: tab, Docs: m})
if err != nil {
t.Fatal(err)
}
q := &queue{}
return New(w, q), w, q
}
func overlay(t *testing.T, s *Service, w *workspace.Workspace, src string) (source.FileID, uint32) {
t.Helper()
off := strings.Index(src, "|")
src = src[:off] + src[off+1:]
w.SetOverlay("scripts/c/c.aes", []byte(src))
s.Invalidate()
s.Refresh()
id, ok := w.FileID("scripts/c/c.aes")
if !ok {
t.Fatal("overlay file has no id")
}
return id, uint32(off)
}
func labels(c Completion) []string {
out := make([]string, len(c.Items))
for i, it := range c.Items {
out[i] = it.Label
}
return out
}
func TestStaleCompletionDropped(t *testing.T) {
s, w, q := setup(t)
id, off := overlay(t, s, w, strings.Replace(scriptTmpl, "CURSOR", "\t\tx := math.|", 1))
var got []string
g1 := s.Invalidate()
s.Complete(g1, id, off, func(Completion) { got = append(got, "first") })
g2 := s.Invalidate()
s.Complete(g2, id, off, func(Completion) { got = append(got, "second") })
q.work[1]()
q.work[0]()
q.work = nil
q.run()
if !slices.Equal(got, []string{"second"}) {
t.Fatalf("delivered %v", got)
}
}
func TestCompletionContexts(t *testing.T) {
s, w, _ := setup(t)
cases := []struct {
src string
want []string
}{
{strings.Replace(scriptTmpl, "CURSOR", "\t\tx := math.|", 1), []string{"Clamp", "Pi"}},
{strings.Replace(scriptTmpl, "CURSOR", "\t\tx := node.|", 1), []string{"Position", "Get", "Has"}},
{strings.Replace(scriptTmpl, "CURSOR", "\t\tx := node.Get<|", 1), []string{"Marker"}},
{strings.Replace(scriptTmpl, "CURSOR", "\t\tx := spe|", 1), []string{"Speed"}},
{strings.Replace(scriptTmpl, "CURSOR", "\t\tcombat.|", 1), []string{"Damage"}},
{strings.Replace(scriptTmpl, "\t@export\n", "\t@|\n", 1), []string{"export", "former", "hide", "pause"}},
{strings.Replace(scriptTmpl, "\t@export\n", "\t@export(|)\n", 1), []string{"min", "unit"}},
}
for _, tc := range cases {
id, off := overlay(t, s, w, tc.src)
got := labels(s.CompleteNow(id, off))
for _, want := range tc.want {
if !slices.Contains(got, want) {
t.Errorf("%q: missing %s in %v", tc.want, want, got)
}
}
}
}
func TestHoverNativeDoc(t *testing.T) {
s, w, _ := setup(t)
src := strings.Replace(scriptTmpl, "CURSOR", "\t\tcombat.Damage(1, 2)", 1)
id, _ := overlay(t, s, w, src+"|")
off := uint32(strings.Index(src, "Damage") + 2)
h, ok := s.Hover(id, off)
if !ok || h.Doc != "deals damage" || !strings.Contains(h.Signature, "game/combat.Damage(int, float)") {
t.Fatalf("hover = %+v", h)
}
}
func TestDefinitionReferencesHover(t *testing.T) {
s, w, _ := setup(t)
s.Refresh()
b, _ := w.FileID("scripts/b/b.aes")
a, _ := w.FileID("scripts/a/a.aes")
use := uint32(strings.Index(srcB, "Add") + 1)
def, ok := s.Definition(b, use)
if !ok || def.Path != "scripts/a/a.aes" {
t.Fatalf("definition = %+v", def)
}
if h, ok := s.Hover(b, use); !ok || h.Doc != "Add sums two ints." {
t.Fatalf("hover = %+v", h)
}
refs := s.References(a, uint32(strings.Index(srcA, "Add(")+1))
if len(refs) != 2 || refs[0].Path != "scripts/a/a.aes" || refs[1].Path != "scripts/b/b.aes" {
t.Fatalf("references = %+v", refs)
}
}
func TestDefinitionHostStub(t *testing.T) {
s, w, _ := setup(t)
cases := []struct {
call, name, stub, symbol string
}{
{"combat.Damage(1, 2)", "Damage", StubPath("game/combat"), "func:Damage"},
{"x := node.IsAlive()", "IsAlive", StubPath(StubBuiltin), "method:NodeRef.IsAlive"},
}
for _, tc := range cases {
src := strings.Replace(scriptTmpl, "CURSOR", "\t\t"+tc.call, 1)
id, _ := overlay(t, s, w, src+"|")
def, ok := s.Definition(id, uint32(strings.Index(src, tc.name)+1))
if !ok || !def.Native || def.Path != "" || def.Stub != tc.stub || def.Symbol != tc.symbol {
t.Fatalf("%s: definition = %+v", tc.name, def)
}
st, ok := s.Stub(def.Stub)
if !ok {
t.Fatalf("%s: no stub %s", tc.name, def.Stub)
}
if _, ok := st.Lines[def.Symbol]; !ok {
t.Fatalf("%s: stub has no line for %s", tc.name, def.Symbol)
}
}
}
func TestImplementationInNativeRoot(t *testing.T) {
root := filepath.Join(t.TempDir(), "native")
strike := filepath.Join(root, "combat", "strike.go")
s, w, _ := setup(t, func(r *bind.Registry) {
m := r.Module("mygame/combat")
bind.Proc1(m, "Strike", func(*bind.Ctx, int64) {})
bind.Describe(m, "Strike", func(i *bind.Info) { i.Loc = strike + ":12" })
})
s.SetNativeRoot(root)
src := strings.Replace(strings.Replace(scriptTmpl, "\"game/combat\"", "\"mygame/combat\"", 1),
"CURSOR", "\t\tcombat.Strike(1)\n\t\tx := math.Clamp(1, 0, 2)", 1)
id, _ := overlay(t, s, w, src+"|")
if st, ok := s.Stub(StubPath("mygame/combat")); !ok || st.Locs["func:Strike"] != strike+":12" {
t.Fatalf("stub loc = %v", st)
}
def, ok := s.Definition(id, uint32(strings.Index(src, "Strike")+1))
if !ok || !def.Native || def.Path != "" || def.Stub != StubPath("mygame/combat") || def.Symbol != "func:Strike" {
t.Fatalf("definition = %+v", def)
}
impl, ok := s.Implementation(def)
if !ok || impl.Path != strike || impl.Line != 12 {
t.Fatalf("implementation = %+v %v", impl, ok)
}
def, ok = s.Definition(id, uint32(strings.Index(src, "Clamp")+1))
if !ok || !def.Native {
t.Fatalf("clamp definition = %+v", def)
}
if impl, ok := s.Implementation(def); ok {
t.Fatalf("an engine builtin resolved to %+v", impl)
}
}
func TestSemanticTokens(t *testing.T) {
s, w, _ := setup(t)
s.Refresh()
b, _ := w.FileID("scripts/b/b.aes")
toks := s.SemanticTokens(b)
kinds := map[string]TokenKind{}
for i, tk := range toks {
if i > 0 && toks[i-1].Span.Start >= tk.Span.Start {
t.Fatal("tokens are not sorted")
}
kinds[srcB[tk.Span.Start:tk.Span.End]] = tk.Kind
}
want := map[string]TokenKind{"b": TokNamespace, "Twice": TokFunction, "Add": TokFunction, "x": TokParameter}
for name, k := range want {
if kinds[name] != k {
t.Errorf("%s: kind %d, want %d", name, kinds[name], k)
}
}
}
func TestInlayHints(t *testing.T) {
s, w, _ := setup(t)
src := "package c\n\nfunc add(first int, second int) int {\n\treturn first + second\n}\n\nfunc f() int {\n\tfirst := 1\n\treturn add(first, 2)\n}\n|"
id, _ := overlay(t, s, w, src)
hs := s.HintsIn(s.Latest(), id)
if len(hs) != 1 || hs[0].Label != "second:" {
t.Fatalf("hints = %+v", hs)
}
}
func TestStubs(t *testing.T) {
s, _, _ := setup(t)
s.Refresh()
st, ok := s.Stub(StubPath("aego/math"))
if !ok || !strings.Contains(st.Text, "func Clamp(v float, lo float, hi float) float") {
t.Fatalf("math stub %v", ok)
}
if line := strings.Split(st.Text, "\n")[st.Lines["func:Clamp"]]; !strings.HasPrefix(line, "func Clamp(") {
t.Fatalf("Clamp line = %q", line)
}
comps, ok := s.Stub(StubPath(StubComponents))
if !ok || !strings.Contains(comps.Text, "component test.Marker {") {
t.Fatal("components stub")
}
if _, ok := s.Stub(StubPath("nope/x")); ok {
t.Fatal("stub for an unknown module")
}
}