418 lines
14 KiB
Go
418 lines
14 KiB
Go
package edit
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"image"
|
|
"os"
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
|
|
"aego/art"
|
|
"aego/asset"
|
|
"aego/asset/importers/artsource"
|
|
"aego/asset/pipeline"
|
|
"aego/core/ids"
|
|
"aego/editor/core/artedit"
|
|
"aego/editor/core/command"
|
|
"aego/editor/core/document"
|
|
"aego/engine"
|
|
inputmodule "aego/modules/input"
|
|
"aego/modules/render2d"
|
|
"aego/scene"
|
|
"aego/scene/frame"
|
|
"aego/scene/sceneio"
|
|
)
|
|
|
|
func TestPlatformerEditSourceArtHeadless(t *testing.T) {
|
|
platformerSourceArtWorkflow(t, headless(), nil, nil, nil)
|
|
}
|
|
|
|
// Opening and the first-save prompt must not create a source or change either
|
|
// the original image or its metadata before explicit confirmation.
|
|
func assertPlayerLinkPending(t *testing.T, s *Session, pngBefore, metaBefore []byte) {
|
|
t.Helper()
|
|
for _, check := range []struct {
|
|
path string
|
|
want []byte
|
|
}{
|
|
{"assets/surfaces/runner.png", pngBefore},
|
|
{"assets/surfaces/runner.png.meta", metaBefore},
|
|
} {
|
|
got, err := s.Pipeline.SourceBytes(check.path)
|
|
if err != nil || !bytes.Equal(got, check.want) {
|
|
t.Fatalf("link prompt modified %s: %v", check.path, err)
|
|
}
|
|
}
|
|
for _, path := range []string{"assets/surfaces/runner.aeart", "assets/surfaces/runner.aeart.meta"} {
|
|
if _, err := os.Stat(s.App.Context().Docs.Abs(path)); !os.IsNotExist(err) {
|
|
t.Fatalf("link prompt created %s before confirmation: %v", path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Native callers supply real Inspector and canvas SDL interactions. This
|
|
// shared test always uses the canonical platformer scene, imported PNG and
|
|
// Animator, rather than replacing Player with a synthetic art-only fixture.
|
|
func platformerSourceArtWorkflow(t *testing.T, cfg Config, open func(*testing.T, *Session, ids.AssetID), paint func(*testing.T, *Session, string, *artedit.Document), observe func(*testing.T, *Session, string, string)) {
|
|
t.Helper()
|
|
p := platformerAuthoringProject(t)
|
|
s, err := Open(p, cfg, platformerAuthoringModule{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { s.Close() }()
|
|
if !s.Step() {
|
|
t.Fatal("editor startup stopped")
|
|
}
|
|
ed := s.App.Context()
|
|
sceneDoc, err := ed.Docs.Open("scenes/main.aescene")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.Step()
|
|
sc := sceneDoc.Scene()
|
|
player := sc.Find(sc.Root(), "Player")
|
|
sprite, ok := scene.Get[render2d.SpriteRenderer](sc, player)
|
|
if !ok || sprite.Sprite.ID.IsZero() {
|
|
t.Fatal("canonical Player SpriteRenderer missing")
|
|
}
|
|
ed.Select(sc.ID(player))
|
|
playerFrame := sprite.Sprite.ID
|
|
sceneBefore := sceneio.Save(sc)
|
|
const pngPath = "assets/surfaces/runner.png"
|
|
const artPath = "assets/surfaces/runner.aeart"
|
|
root, ok := ed.Pipeline.Lookup(pngPath)
|
|
if !ok {
|
|
t.Fatal("canonical runner source missing")
|
|
}
|
|
originalPNG, err := ed.Pipeline.SourceBytes(pngPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
originalMeta, err := ed.Pipeline.SourceBytes(pngPath + ".meta")
|
|
if err != nil || len(originalMeta) == 0 {
|
|
t.Fatal("canonical runner metadata missing", err)
|
|
}
|
|
metaBefore, _ := ed.Pipeline.MetaOf(root)
|
|
readPixels := func() []byte {
|
|
t.Helper()
|
|
e, ok := ed.Loader.Index().Lookup(root)
|
|
if !ok {
|
|
t.Fatal("original PNG lost its runtime identity")
|
|
}
|
|
a, ok := e.Artifact(asset.ArtTexture)
|
|
if !ok {
|
|
t.Fatal("runner texture is unavailable")
|
|
}
|
|
encoded, err := ed.Loader.Artifacts().ReadFile(a.Path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
texture, err := asset.DecodeTexture(encoded)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return bytes.Clone(texture.Pixels)
|
|
}
|
|
initial := readPixels()
|
|
previousBeforeSwap := ed.BeforeSwap
|
|
batches := 0
|
|
ed.BeforeSwap = func(batch *pipeline.Batch) func() {
|
|
batches++
|
|
if len(batch.Failed) != 0 || len(batch.Retry) != 0 {
|
|
t.Errorf("link import batch failed or requires retry: failed=%v retry=%v", batch.Failed, batch.Retry)
|
|
}
|
|
if previousBeforeSwap != nil {
|
|
return previousBeforeSwap(batch)
|
|
}
|
|
return nil
|
|
}
|
|
if open != nil {
|
|
open(t, s, playerFrame)
|
|
} else {
|
|
if err := ed.EditSourceArt(playerFrame); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if questions := ed.TakeQuestions(); len(questions) != 0 {
|
|
t.Fatal("opening the PNG unexpectedly requested conversion")
|
|
}
|
|
assertPlayerLinkPending(t, s, originalPNG, originalMeta)
|
|
draft := ed.Docs.AssetDocument(root)
|
|
if draft == nil {
|
|
t.Fatal("opening PNG did not create a shared draft")
|
|
}
|
|
if err := ed.SaveAsset(draft); !errors.Is(err, document.ErrSavePending) {
|
|
t.Fatalf("first Save did not wait for confirmation: %v", err)
|
|
}
|
|
questions := ed.TakeQuestions()
|
|
if len(questions) != 1 || questions[0].Submit == nil {
|
|
t.Fatal("first Save did not show the linked source warning and path")
|
|
}
|
|
assertPlayerLinkPending(t, s, originalPNG, originalMeta)
|
|
questions[0].Submit([]string{artPath})
|
|
}
|
|
ed.BeforeSwap = previousBeforeSwap
|
|
if batches == 0 {
|
|
t.Fatal("link creation did not publish an import batch")
|
|
}
|
|
artID, ok := ed.Pipeline.Lookup(artPath)
|
|
if !ok {
|
|
notice, _ := ed.LastNotice()
|
|
t.Fatalf("Edit Sprite did not create the confirmed authoring source: %+v", notice)
|
|
}
|
|
for _, id := range []ids.AssetID{root, artID, playerFrame} {
|
|
if err := ed.Pipeline.VerifyCurrent(id); err != nil {
|
|
t.Fatalf("linked source or frame is not current: %v", err)
|
|
}
|
|
}
|
|
doc := ed.Docs.AssetDocument(artID)
|
|
if doc == nil {
|
|
t.Fatal("Edit Sprite did not open an art document")
|
|
}
|
|
model, ok := doc.Binding.Model.(*artedit.Document)
|
|
if !ok || len(model.Session.Info().Document.Frames) != 8 {
|
|
t.Fatal("runner spritesheet did not open as eight Art Studio frames")
|
|
}
|
|
panelID := "render2d.art/" + artID.String()
|
|
if !s.App.Workspace().Visible(panelID) || !bytes.Equal(initial, readPixels()) {
|
|
t.Fatal("Edit Sprite lost source pixels or did not open a dockable authoring tab")
|
|
}
|
|
linkedMeta, _ := ed.Pipeline.MetaOf(root)
|
|
link, err := artsource.TextureLinkOf(&linkedMeta)
|
|
if err != nil || link == nil || len(link.Frames) != 8 || len(linkedMeta.Subs) != len(metaBefore.Subs) {
|
|
t.Fatalf("linked runner source mapping invalid: %v", err)
|
|
}
|
|
for i, sub := range linkedMeta.Subs {
|
|
if sub.ID != metaBefore.Subs[i].ID || sub.Rect != metaBefore.Subs[i].Rect || sub.EffectivePivot() != metaBefore.Subs[i].EffectivePivot() {
|
|
t.Fatal("Edit Sprite changed a legacy frame ID, region or pivot")
|
|
}
|
|
}
|
|
if model.Session.Frame != link.Frames[0].Frame {
|
|
t.Fatal("Edit Sprite did not select the Player's current region")
|
|
}
|
|
if observe != nil {
|
|
observe(t, s, panelID, "scene-before")
|
|
}
|
|
if paint != nil {
|
|
paint(t, s, panelID, model)
|
|
} else if _, err := model.Execute(context.Background(), art.Operation{Kind: "stroke", Points: []image.Point{{X: 8, Y: 8}}, Brush: art.Brush{Size: 1, Paint: art.Paint{Color: art.Color{R: 255, B: 255, A: 255}}}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !doc.Dirty() || model.History.Cursor() != 1 {
|
|
t.Fatal("the paint gesture is not one undoable authoring operation")
|
|
}
|
|
waitPreview := func(previous uint64) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(15 * time.Second)
|
|
for doc.WorkingPreview.Applied == previous && doc.WorkingPreview.Error == "" && time.Now().Before(deadline) {
|
|
if !s.Step() {
|
|
t.Fatal("editor stopped during runner preview")
|
|
}
|
|
runtime.Gosched()
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if doc.WorkingPreview.Applied == previous || doc.WorkingPreview.Pending || doc.WorkingPreview.Error != "" {
|
|
t.Fatalf("runner preview failed: %+v", doc.WorkingPreview)
|
|
}
|
|
}
|
|
waitPreview(0)
|
|
painted := readPixels()
|
|
if bytes.Equal(initial, painted) || !ed.WorkingSourceCurrent(doc, model.PreviewRevision()) {
|
|
t.Fatal("unsaved frame pixels did not reach the scene's shared asset loader")
|
|
}
|
|
if observe != nil {
|
|
observe(t, s, panelID, "scene-painted")
|
|
}
|
|
run := func(id string) {
|
|
t.Helper()
|
|
s.App.Workspace().Focus(panelID)
|
|
if err := ed.Commands.Run(id); err != nil {
|
|
t.Fatal(id, err)
|
|
}
|
|
}
|
|
previous := doc.WorkingPreview.Applied
|
|
run(command.EditUndo)
|
|
waitPreview(previous)
|
|
if !bytes.Equal(initial, readPixels()) {
|
|
t.Fatal("common Undo did not restore the Player source pixels")
|
|
}
|
|
if observe != nil {
|
|
observe(t, s, panelID, "scene-undo")
|
|
}
|
|
previous = doc.WorkingPreview.Applied
|
|
run(command.EditRedo)
|
|
waitPreview(previous)
|
|
if !bytes.Equal(painted, readPixels()) {
|
|
t.Fatal("common Redo did not restore the painted Player pixels")
|
|
}
|
|
if observe != nil {
|
|
observe(t, s, panelID, "scene-redo")
|
|
}
|
|
shape, ok := ed.Loader.SpriteShadow(playerFrame)
|
|
if !ok || len(shape.Parts) == 0 {
|
|
t.Fatal("Player's existing alpha silhouette missing")
|
|
}
|
|
previous = doc.WorkingPreview.Applied
|
|
if _, err := model.Execute(context.Background(), art.Operation{Kind: "clear"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitPreview(previous)
|
|
shape, ok = ed.Loader.SpriteShadow(playerFrame)
|
|
if !ok || len(shape.Parts) != 0 {
|
|
t.Fatal("editing alpha did not rebuild the Player frame's real silhouette")
|
|
}
|
|
if observe != nil {
|
|
observe(t, s, panelID, "scene-cleared-alpha")
|
|
}
|
|
previous = doc.WorkingPreview.Applied
|
|
run(command.EditUndo)
|
|
waitPreview(previous)
|
|
shape, ok = ed.Loader.SpriteShadow(playerFrame)
|
|
if !ok || len(shape.Parts) == 0 {
|
|
t.Fatal("Undo did not restore Player's silhouette")
|
|
}
|
|
if err := model.Session.Select("", model.Session.Frame, art.Emission); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
previous = doc.WorkingPreview.Applied
|
|
if _, err := model.Execute(context.Background(), art.Operation{Kind: "stroke", Points: []image.Point{{X: 8, Y: 8}}, Brush: art.Brush{Size: 1, Paint: art.Paint{Color: art.Color{R: 255, G: 170, A: 255}}}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitPreview(previous)
|
|
if id, _, ok := ed.Loader.EmissionFor(playerFrame); !ok || id.IsZero() {
|
|
t.Fatal("dirty emission did not become a real runtime surface for Player")
|
|
}
|
|
if err := model.Session.Select("", model.Session.Frame, art.Albedo); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The existing Animator and character script must continue to reference the
|
|
// original PNG frame IDs, with real normals and FlipX in the draw stream.
|
|
pc := s.App.Play()
|
|
if err := pc.Start(sceneDoc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
playWorld := pc.World()
|
|
inputs := inputmodule.Of(playWorld)
|
|
playPlayer := pc.Scene().Find(pc.Scene().Root(), "Player")
|
|
if inputs == nil || playPlayer.IsZero() {
|
|
t.Fatal("canonical Player runtime was not started")
|
|
}
|
|
seen := map[ids.AssetID]bool{}
|
|
for _, move := range []float32{1, -1} {
|
|
if err := inputs.Inject(0, "Move", move); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for range 30 {
|
|
pc.Tick(1.0 / 60)
|
|
s.Engine.RunPhase(playWorld, engine.PhaseRenderPrepare, engine.ScopePlay, 0, 1)
|
|
s.Engine.RunPhase(playWorld, engine.PhaseRender, engine.ScopePlay, 0, 1)
|
|
sr, ok := scene.Get[render2d.SpriteRenderer](pc.Scene(), playPlayer)
|
|
if !ok || sr.FlipX != (move < 0) {
|
|
t.Fatal("Player script did not drive FlipX")
|
|
}
|
|
seen[sr.Sprite.ID] = true
|
|
albedo, ok := sr.Sprite.Region(ed.Loader)
|
|
_, normal, normalOK := ed.Loader.NormalFor(sr.Sprite.ID)
|
|
if !ok || !normalOK || normal.Size() != albedo.Size() {
|
|
t.Fatal("authored Player frame lost its aligned normal surface")
|
|
}
|
|
r := frame.Of(&engine.Context{World: playWorld})
|
|
found := false
|
|
for i := range r.Views() {
|
|
for _, draw := range r.Bucket(i).Sprites {
|
|
if draw.Region == albedo && draw.Order == int16(sr.Order) && draw.Tint == sr.Tint {
|
|
found = true
|
|
if draw.FlipX != sr.FlipX || draw.Normal != normal || !draw.Lighting || !draw.HasLinear {
|
|
t.Fatal("authored Player is not rendered through the real Lit/FlipX path")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("Player is absent from Game View draw stream")
|
|
}
|
|
}
|
|
}
|
|
if len(seen) < 2 {
|
|
t.Fatal("real Animator did not advance authored frames")
|
|
}
|
|
for id := range seen {
|
|
if _, ok := metaBefore.Frame(id); !ok {
|
|
t.Fatal("runtime animation references changed during source editing")
|
|
}
|
|
}
|
|
if observe != nil {
|
|
// Let the real Animator select the authored frame again, then hold the
|
|
// existing Play world while the native UI captures actual Game View
|
|
// pixels. Do not replace its SpriteRef, camera, transform or collider.
|
|
if err := inputs.Inject(0, "Move", 1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found := false
|
|
for range 120 {
|
|
pc.Tick(1.0 / 60)
|
|
sr, ok := scene.Get[render2d.SpriteRenderer](pc.Scene(), playPlayer)
|
|
if ok && sr.Sprite.ID == playerFrame {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("canonical Animator did not return to the painted Player frame")
|
|
}
|
|
pc.Pause()
|
|
observe(t, s, panelID, "game-painted")
|
|
// The two current changes are albedo and emission. Undo both through
|
|
// the same authoring command, compare the Player's paused GPU region,
|
|
// then restore both before saving the intended authored source.
|
|
for range 2 {
|
|
previous = doc.WorkingPreview.Applied
|
|
run(command.EditUndo)
|
|
waitPreview(previous)
|
|
}
|
|
observe(t, s, panelID, "game-undo")
|
|
for range 2 {
|
|
previous = doc.WorkingPreview.Applied
|
|
run(command.EditRedo)
|
|
waitPreview(previous)
|
|
}
|
|
observe(t, s, panelID, "game-redo")
|
|
}
|
|
pc.Stop()
|
|
run(command.FileSave)
|
|
if doc.Dirty() {
|
|
t.Fatal("Save did not save the canonical dirty source")
|
|
}
|
|
if sceneDoc.Dirty() || !bytes.Equal(sceneBefore, sceneio.Save(sc)) || sprite.Sprite.ID != playerFrame {
|
|
t.Fatal("source authoring changed EditScene, collider properties or AssetRef")
|
|
}
|
|
if png, err := ed.Pipeline.SourceBytes(pngPath); err != nil || !bytes.Equal(png, originalPNG) {
|
|
t.Fatal("source editing overwrote the user's original PNG", err)
|
|
}
|
|
s.Close()
|
|
s, err = Open(p, cfg, platformerAuthoringModule{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.Step()
|
|
ed = s.App.Context()
|
|
if err := ed.EditSourceArt(playerFrame); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
doc = ed.Docs.AssetDocument(artID)
|
|
if doc == nil || doc.Dirty() || !bytes.Equal(painted, readPixels()) {
|
|
t.Fatal("editor restart lost saved authored pixels or source ownership")
|
|
}
|
|
model = doc.Binding.Model.(*artedit.Document)
|
|
if len(model.Session.Info().Document.Frames) != 8 || model.Session.Frame != link.Frames[0].Frame {
|
|
t.Fatal("editor restart lost frame identity or selected source frame")
|
|
}
|
|
if id, _, ok := ed.Loader.EmissionFor(playerFrame); !ok || id.IsZero() {
|
|
t.Fatal("editor restart lost the saved emission surface")
|
|
}
|
|
t.Logf("canonical Player: eight legacy frames, paint/Undo/Redo, alpha silhouette, emission, Lit normal/FlipX, actual Animator, source save and editor restart PASS")
|
|
}
|