Files
aego/tests/refactor/refactor_test.go
sanspie 2744cff481
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci / checks (push) Successful in 21m57s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 23m31s
Stage 9.6c1: project refs and refactorings without the editor (D298)
2026-10-20 12:12:18 +03:00

459 lines
12 KiB
Go

package refactor_test
import (
"bytes"
"image"
"image/color"
"image/png"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"aego/app/projects"
"aego/app/run"
"aego/asset/pipeline"
"aego/core/ids"
"aego/core/log"
"aego/project"
"aego/project/refactor"
"aego/project/refs"
"aego/project/rename"
)
const (
builtinSprite = "ae900000000000000000000000000010"
mainScene = "scenes/main.aescene"
enemyPrefab = "prefabs/enemy.aeprefab"
otherScene = "scenes/other.aescene"
brokenScene = "scenes/broken.aescene"
texA = "assets/tex/a.png"
texB = "assets/tex/b.png"
missingTarget = "c00000000000000000000000000000ff"
)
type fixture struct {
t *testing.T
dir string
p *project.Project
pl *pipeline.Pipeline
a, b ids.AssetID
}
func copyExample(t *testing.T, name string) string {
t.Helper()
src := filepath.Join("..", "..", "examples", name)
dst := t.TempDir()
err := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, p)
if err != nil {
return err
}
if d.IsDir() {
switch rel {
case ".aego", "native", "exports":
return filepath.SkipDir
}
return os.MkdirAll(filepath.Join(dst, rel), 0o755)
}
if rel == "go.mod" || rel == "go.sum" {
return nil
}
data, err := os.ReadFile(p)
if err != nil {
return err
}
if rel == "project.aego" {
data = []byte(strings.Replace(string(data), "native=true", "native=false", 1))
}
return os.WriteFile(filepath.Join(dst, rel), data, 0o644)
})
if err != nil {
t.Fatal(err)
}
return dst
}
func pngBytes(t *testing.T, c color.NRGBA) []byte {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, 4, 4))
for i := 0; i < len(img.Pix); i += 4 {
img.Pix[i], img.Pix[i+1], img.Pix[i+2], img.Pix[i+3] = c.R, c.G, c.B, c.A
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func (f *fixture) abs(rel string) string {
return filepath.Join(f.dir, filepath.FromSlash(rel))
}
func (f *fixture) read(rel string) []byte {
f.t.Helper()
data, err := os.ReadFile(f.abs(rel))
if err != nil {
f.t.Fatal(err)
}
return data
}
func (f *fixture) write(rel string, data []byte) {
f.t.Helper()
if err := os.MkdirAll(filepath.Dir(f.abs(rel)), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(f.abs(rel), data, 0o644); err != nil {
f.t.Fatal(err)
}
}
func (f *fixture) sync() {
f.t.Helper()
if _, _, err := f.pl.Scan(); err != nil {
f.t.Fatal(err)
}
if _, err := f.pl.ImportAll(); err != nil {
f.t.Fatal(err)
}
}
func (f *fixture) id(rel string) ids.AssetID {
f.t.Helper()
id, ok := f.pl.Lookup(rel)
if !ok {
f.t.Fatalf("%s is not imported", rel)
}
return id
}
func (f *fixture) reimport(rels ...string) {
f.t.Helper()
if _, err := refactor.Reimport(f.pl, rels); err != nil {
f.t.Fatal(err)
}
}
func open(t *testing.T) *fixture {
t.Helper()
f := &fixture{t: t, dir: copyExample(t, "platformer")}
p, err := project.Load(f.dir)
if err != nil {
t.Fatal(err)
}
ws, err := projects.OpenWorkspace(p, "", false)
if err != nil {
t.Fatal(err)
}
imps, err := run.Importers(ws)
if err != nil {
t.Fatal(err)
}
pl, err := pipeline.Open(p, imps, log.Nop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(pl.Close)
f.p, f.pl = p, pl
f.write(texA, pngBytes(t, color.NRGBA{R: 255, A: 255}))
f.write(texB, pngBytes(t, color.NRGBA{B: 255, A: 255}))
f.write(brokenScene, []byte(`scene version=1 {
node "c00000000000000000000000000000f1" name="Hook" enabled=true layers=1 {
transform {
position=[0, 0]
rotation=0
scale=[1, 1]
}
component "physics.DistanceJoint2D" version=1 {
target=node("`+missingTarget+`")
}
}
}
`))
f.sync()
f.a, f.b = f.id(texA), f.id(texB)
sprite, err := ids.ParseAssetID(builtinSprite)
if err != nil {
t.Fatal(err)
}
builtin := string(refs.Token(sprite))
main := string(f.read(mainScene))
f.write(otherScene, []byte(strings.ReplaceAll(main, builtin, string(refs.Token(f.b)))))
f.write(mainScene, []byte(strings.ReplaceAll(main, builtin, string(refs.Token(f.a)))))
f.write(enemyPrefab, []byte(strings.ReplaceAll(string(f.read(enemyPrefab)), builtin, string(refs.Token(f.a)))))
f.sync()
f.reimport(mainScene, enemyPrefab)
return f
}
func paths(list []refs.Ref) []string {
var out []string
for _, r := range list {
out = append(out, r.Path)
}
slices.Sort(out)
return out
}
func TestReferrers(t *testing.T) {
f := open(t)
got := paths(refs.Referrers(f.p, f.pl, f.a))
if !slices.Contains(got, mainScene) || !slices.Contains(got, enemyPrefab) {
t.Fatalf("referrers of the texture: %v", got)
}
var deps []string
for _, id := range f.pl.Dependents(f.a) {
if rel, ok := f.pl.PathOf(id); ok {
deps = append(deps, rel)
}
}
slices.Sort(deps)
if !slices.Equal(got, deps) {
t.Fatalf("referrers %v, pipeline dependents %v", got, deps)
}
hits := refs.Find(f.p, f.pl, f.a)
for _, h := range hits {
if h.Err != nil || len(h.Places) == 0 {
t.Fatalf("%s: %v, %d places", h.Ref.Path, h.Err, len(h.Places))
}
for _, pl := range h.Places {
if pl.Owner != "engine.SpriteRenderer" || pl.Field != "sprite" {
t.Fatalf("%s: place %s", h.Ref.Path, pl)
}
}
}
}
func snapshot(f *fixture, rels []string) map[string][]byte {
out := map[string][]byte{}
for _, rel := range rels {
out[rel] = f.read(rel)
}
return out
}
func TestReplaceRoundTrip(t *testing.T) {
f := open(t)
plan, err := refactor.PlanReplace(f.p, f.pl, f.a, f.b, nil)
if err != nil {
t.Fatal(err)
}
if len(plan.Files) < 2 {
t.Fatalf("plan %+v", plan)
}
before := snapshot(f, plan.Files)
backup := refactor.BackupDir(time.Unix(0, 0))
changed, err := plan.Write(f.dir, f.pl.Abs, backup)
if err != nil {
t.Fatal(err)
}
f.reimport(changed...)
if hits := refs.Find(f.p, f.pl, f.a); len(hits) != 0 {
t.Fatalf("%d files still use the old texture", len(hits))
}
now := paths(refs.Referrers(f.p, f.pl, f.b))
for _, rel := range plan.Files {
if !slices.Contains(now, rel) {
t.Fatalf("%s does not use the new texture: %v", rel, now)
}
bak, err := os.ReadFile(filepath.Join(f.dir, filepath.FromSlash(backup), filepath.FromSlash(rel)))
if err != nil || !bytes.Equal(bak, before[rel]) {
t.Fatalf("backup of %s: %v", rel, err)
}
}
if !strings.HasPrefix(backup, refactor.RecoveryDir+"/refs-") {
t.Fatalf("backup dir %s", backup)
}
back, err := refactor.PlanReplace(f.p, f.pl, f.b, f.a, func(rel string) bool { return !slices.Contains(plan.Files, rel) })
if err != nil {
t.Fatal(err)
}
changed, err = back.Write(f.dir, f.pl.Abs, "")
if err != nil {
t.Fatal(err)
}
f.reimport(changed...)
for rel, data := range before {
if !bytes.Equal(f.read(rel), data) {
t.Fatalf("%s differs after the reverse replacement", rel)
}
}
}
func TestEditsWriteNothing(t *testing.T) {
f := open(t)
plan, err := refactor.PlanReplace(f.p, f.pl, f.a, f.b, nil)
if err != nil {
t.Fatal(err)
}
stamp := map[string]time.Time{}
for _, rel := range plan.Files {
st, err := os.Stat(f.abs(rel))
if err != nil {
t.Fatal(err)
}
stamp[rel] = st.ModTime()
}
edits, err := plan.Edits(f.pl.Abs)
if err != nil || len(edits) != len(plan.Files) {
t.Fatalf("%d edits for %d files: %v", len(edits), len(plan.Files), err)
}
for _, e := range edits {
if bytes.Equal(e.Before, e.After) || !bytes.Contains(e.After, refs.Token(f.b)) {
t.Fatalf("%s: the edit does not replace the token", e.Path)
}
st, _ := os.Stat(f.abs(e.Path))
if !st.ModTime().Equal(stamp[e.Path]) {
t.Fatalf("%s was written by Edits", e.Path)
}
}
}
func TestCheckReplace(t *testing.T) {
f := open(t)
if err := refactor.CheckReplace(f.pl, f.a, f.id(mainScene)); err == nil {
t.Fatal("a texture was replaced with a scene")
}
if err := refactor.CheckReplace(f.pl, f.a, f.a); err == nil {
t.Fatal("an asset replaced itself")
}
if err := refactor.CheckReplace(f.pl, f.a, f.b); err != nil {
t.Fatal(err)
}
}
func TestMissingAssets(t *testing.T) {
f := open(t)
if got := refs.MissingAssets(f.p, f.pl); len(got) != 0 {
t.Fatalf("the fixture already misses %v", got)
}
data := f.read(texA)
if _, err := refactor.Delete(f.pl, []ids.AssetID{f.a}); err != nil {
t.Fatal(err)
}
f.sync()
got := refs.MissingAssets(f.p, f.pl)
if len(got) != 1 || got[0].ID != f.a || !got[0].Buried || got[0].Grave.Path != texA {
t.Fatalf("missing %+v", got)
}
f.write(texA, data)
f.sync()
fresh := f.id(texA)
got = refs.MissingAssets(f.p, f.pl)
if len(got) != 1 || got[0].Suggest != fresh || got[0].Path != texA || fresh == f.a {
t.Fatalf("suggestion %+v, new id %s", got, fresh)
}
plan, err := refactor.PlanReplace(f.p, f.pl, f.a, got[0].Suggest, nil)
if err != nil {
t.Fatal(err)
}
changed, err := plan.Write(f.dir, f.pl.Abs, "")
if err != nil {
t.Fatal(err)
}
f.reimport(changed...)
if got := refs.MissingAssets(f.p, f.pl); len(got) != 0 {
t.Fatalf("still missing %+v", got)
}
}
func TestUsers(t *testing.T) {
f := open(t)
if got := refactor.Users(f.p, f.pl, []ids.AssetID{f.b}); !slices.Equal(paths(got), []string{otherScene}) {
t.Fatalf("users of b: %v", paths(got))
}
if got := refactor.Users(f.p, f.pl, []ids.AssetID{f.b, f.id(otherScene)}); len(got) != 0 {
t.Fatalf("deleting b with its only user leaves users %v", paths(got))
}
}
func TestMissingNodes(t *testing.T) {
f := open(t)
var found []refs.MissingNode
for _, m := range refs.MissingNodes(f.pl) {
if m.Scene == brokenScene {
found = append(found, m)
}
}
if len(found) != 1 {
t.Fatalf("missing nodes %+v", found)
}
m := found[0]
if m.Path != "Hook" || m.Owner != "physics.DistanceJoint2D" || m.Field != "target" || m.Target.String() != missingTarget {
t.Fatalf("missing node %+v", m)
}
}
func exists(f *fixture, rel string) bool {
_, err := os.Stat(f.abs(rel))
return err == nil
}
func TestRename(t *testing.T) {
f := open(t)
rows, ops, err := refactor.PlanRename(f.pl, rename.Rule{Pattern: "{name}_x"}, []ids.AssetID{f.a, f.b})
if err != nil {
t.Fatalf("%v %+v", err, rows)
}
if _, err := refactor.ApplyOps(f.p, f.pl, ops); err != nil {
t.Fatal(err)
}
for id, want := range map[ids.AssetID]string{f.a: "assets/tex/a_x.png", f.b: "assets/tex/b_x.png"} {
if rel, _ := f.pl.PathOf(id); rel != want || !exists(f, want+".meta") {
t.Fatalf("%s is at %s", want, rel)
}
}
if exists(f, texA) || exists(f, texA+".meta") {
t.Fatal("the old file or meta is still there")
}
swap := refactor.RenameOps([]string{"assets/tex/a_x.png", "assets/tex/b_x.png"}, []ids.AssetID{f.a, f.b},
[]rename.Row{{From: "a_x", To: "b_x"}, {From: "b_x", To: "a_x"}})
if len(swap) != 4 || !strings.Contains(swap[0].To, "__rename_") {
t.Fatalf("swap ops %+v", swap)
}
if _, err := refactor.ApplyOps(f.p, f.pl, swap); err != nil {
t.Fatal(err)
}
if ra, _ := f.pl.PathOf(f.a); ra != "assets/tex/b_x.png" {
t.Fatalf("a is at %s after the swap", ra)
}
if rb, _ := f.pl.PathOf(f.b); rb != "assets/tex/a_x.png" {
t.Fatalf("b is at %s after the swap", rb)
}
rows, _, err = refactor.PlanRename(f.pl, rename.Rule{Pattern: "a_x"}, []ids.AssetID{f.a})
if err == nil || !rename.Blocked(rows) {
t.Fatalf("a name taken by another file was accepted: %+v", rows)
}
}
func TestMoveScript(t *testing.T) {
f := open(t)
const from, to = "scripts/enemy/enemy.aes", "scripts/shared/enemy.aes"
id := f.id(from)
before := f.read(from)
note, err := refactor.Move(f.p, f.pl, id, to)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(note, "package shared") || !strings.Contains(note, "was enemy") {
t.Fatalf("note %q", note)
}
if !bytes.HasPrefix(f.read(to), []byte("package shared")) {
t.Fatalf("package line not rewritten:\n%s", f.read(to))
}
if _, err := refactor.Move(f.p, f.pl, id, from); err != nil {
t.Fatal(err)
}
if !bytes.Equal(f.read(from), before) {
t.Fatal("moving back did not restore the script")
}
}