Files
aego/app/edit/art_file_interop_gl_test.go

530 lines
20 KiB
Go

//go:build aego_gltest && linux && cgo && !aego_nonative
package edit
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"image"
"image/png"
"io/fs"
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
"time"
"aego/anim"
"aego/art"
"aego/asset"
"aego/core/log"
"aego/core/mathx"
"aego/editor/core/artedit"
"aego/editor/core/workspace"
"aego/modules/animation"
"aego/modules/render2d"
"aego/platform/input"
"aego/platform/sdl"
"aego/project"
"aego/scene"
)
// These are evidence records, not another document format. The transferred
// source is the exact .aeart written by the normal editor Save command.
type artInteropState struct {
Document art.Info `json:"document"`
FrameHashes map[art.ID]map[art.Channel]string `json:"frameHashes"`
SourceSHA256 string `json:"sourceSHA256"`
}
type artInteropWitness struct {
Schema int `json:"schema"`
State artInteropState `json:"state"`
SourcePath string `json:"sourcePath"`
ScenePath string `json:"scenePath"`
ProjectFiles map[string]string `json:"projectFiles"`
SourceAsset string `json:"sourceAsset"`
SceneRefs map[string]string `json:"sceneRefs"`
SubAssets map[string]string `json:"subAssets"`
ClipSHA256 string `json:"clipSHA256"`
ClipFrames []string `json:"clipFrames"`
}
const interopArtPath = "assets/hero.aeart"
const interopScenePath = "scenes/art-workflow.aescene"
func interopSHA(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func interopRead(t *testing.T, path string) []byte {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return b
}
func interopWrite(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if err != nil {
t.Fatal(err)
}
_, err = f.Write(data)
closeErr := f.Close()
if err != nil || closeErr != nil {
t.Fatal(err, closeErr)
}
}
func interopJSON(t *testing.T, path string, value any) {
t.Helper()
b, err := json.MarshalIndent(value, "", " ")
if err != nil {
t.Fatal(err)
}
interopWrite(t, path, append(b, '\n'))
}
func interopState(t *testing.T, model *artedit.Document, saved []byte) artInteropState {
t.Helper()
state := artInteropState{Document: model.Session.Document.Info(), SourceSHA256: interopSHA(saved), FrameHashes: map[art.ID]map[art.Channel]string{}}
for _, frame := range state.Document.Frames {
state.FrameHashes[frame.ID] = map[art.Channel]string{}
for _, channel := range []art.Channel{art.Albedo, art.Normal, art.Height, art.Emission} {
// This is exactly the render/hash contract of aegoArt.hash(frame, channel).
pixels, err := model.Session.Document.Render(frame.ID, art.RenderOptions{Channel: channel})
if err != nil {
t.Fatal(err)
}
state.FrameHashes[frame.ID][channel] = interopSHA(pixels.Pix)
}
}
return state
}
func interopStep(t *testing.T, s *Session) {
t.Helper()
if !s.Step() {
t.Fatal("native editor stopped during file interoperability")
}
}
func interopClick(t *testing.T, s *Session, x, y float32) {
t.Helper()
for kind := range 3 {
if !sdl.TestPointerEvent(x, y, kind) {
t.Fatal("SDL file interoperability pointer failed")
}
interopStep(t, s)
}
}
func interopKey(t *testing.T, s *Session, key input.Scancode, ctrl bool) {
t.Helper()
for _, down := range []bool{true, false} {
if !sdl.TestEditKey(key, down, down && ctrl, false) {
t.Fatal("SDL file interoperability key failed")
}
interopStep(t, s)
}
}
func interopMaximize(t *testing.T, s *Session, panel string) mathx.Rect {
t.Helper()
ws := s.App.Workspace()
ws.Tree.Restore()
ws.Show(panel)
ws.Focus(panel)
if !ws.ToggleMaximize(panel) {
t.Fatal("cannot show interoperability panel", panel)
}
for range 3 {
interopStep(t, s)
}
r, ok := ws.Tree.PanelRect(panel)
if !ok {
t.Fatal("interoperability panel has no visible rectangle", panel)
}
return r
}
func interopShot(t *testing.T, s *Session, path string) {
t.Helper()
var pixels *image.RGBA
var captureErr error
s.Engine.CaptureFrame(func(img *image.RGBA, err error) { pixels, captureErr = img, err })
interopStep(t, s)
if pixels == nil || captureErr != nil {
t.Fatal("native interoperability screenshot failed", captureErr)
}
var data bytes.Buffer
if err := png.Encode(&data, pixels); err != nil {
t.Fatal(err)
}
interopWrite(t, path, data.Bytes())
}
func interopWitness(t *testing.T, s *Session, model *artedit.Document) artInteropWitness {
t.Helper()
ed := s.App.Context()
source := interopRead(t, filepath.Join(s.Project.Root, interopArtPath))
meta, err := asset.ParseSourceMeta(interopRead(t, filepath.Join(s.Project.Root, interopArtPath+".meta")), interopArtPath+".meta")
if err != nil {
t.Fatal(err)
}
w := artInteropWitness{Schema: 1, State: interopState(t, model, source), SourcePath: interopArtPath, ScenePath: interopScenePath,
SourceAsset: meta.ID.String(), SceneRefs: map[string]string{}, SubAssets: map[string]string{}}
if len(w.State.Document.Frames) != 8 {
t.Fatal("file interoperability requires the eight authored frames")
}
sceneDoc := ed.Docs.Find(interopScenePath)
if sceneDoc == nil {
t.Fatal("the actual assigned source scene is not open")
}
sc := sceneDoc.Scene()
hero := sc.Find(sc.Root(), "Hero")
sprite, hasSprite := scene.Get[render2d.SpriteRenderer](sc, hero)
player, hasPlayer := scene.Get[animation.AnimationPlayer](sc, hero)
if !hasSprite || !hasPlayer || sprite.Sprite.ID.IsZero() || player.Clip.ID.IsZero() || sprite.Sprite.ID != meta.ID {
t.Fatal("file interoperability lost the real SpriteRenderer/AnimationPlayer assignment")
}
w.SceneRefs["SpriteRenderer.sprite"], w.SceneRefs["AnimationPlayer.clip"] = sprite.Sprite.ID.String(), player.Clip.ID.String()
for _, sub := range meta.Subs {
if err := ed.Pipeline.VerifyCurrent(sub.ID); err != nil {
t.Fatal("saved source subasset is unavailable", sub.Name, err)
}
w.SubAssets[sub.Name] = sub.ID.String()
}
var clip *anim.Clip
var clipBytes []byte
deadline := time.Now().Add(8 * time.Second)
for time.Now().Before(deadline) {
if data, ok := ed.Loader.Anim(player.Clip.ID); ok {
if value, err := anim.Unmarshal(data); err == nil && len(value.Tracks) == 1 && len(value.Tracks[0].Keys) == 8 {
clip, clipBytes = value, data
break
}
}
interopStep(t, s)
runtime.Gosched()
}
if clip == nil {
t.Fatal("assigned runtime clip did not publish eight prepared frames after Save")
}
clipEntry, ok := ed.Pipeline.Entry(player.Clip.ID)
if !ok {
t.Fatal("assigned runtime clip is absent from the persistent index")
}
w.SubAssets[clipEntry.Name] = player.Clip.ID.String()
w.ClipSHA256 = interopSHA(clipBytes)
for _, key := range clip.Tracks[0].Keys {
if key.V.A.IsZero() {
t.Fatal("runtime clip has an empty frame AssetRef")
}
if err := ed.Pipeline.VerifyCurrent(key.V.A); err != nil {
t.Fatal("runtime frame AssetRef does not resolve", err)
}
entry, ok := ed.Pipeline.Entry(key.V.A)
if !ok {
t.Fatal("runtime frame is absent from the persistent index")
}
w.SubAssets[entry.Name] = key.V.A.String()
w.ClipFrames = append(w.ClipFrames, key.V.A.String())
}
if len(w.SubAssets) < 9 {
t.Fatal("the actual persistent clip/frame identities were not recorded")
}
return w
}
func artFileInteropSourceNative(t *testing.T, s *Session, panelID string) {
t.Helper()
id, ok := s.Pipeline.Lookup(interopArtPath)
if !ok {
t.Fatal("authoring workflow lost its source")
}
doc := s.App.Context().Docs.AssetDocument(id)
model := doc.Binding.Model.(*artedit.Document)
interopMaximize(t, s, panelID)
// The existing workflow just proved a two-frame dirty preview. Return to
// its saved, SDL-painted first frame through common Undo, then author all
// seven new frames with real Timeline controls, including a linked pair.
for n := 0; len(model.Session.Document.Info().Frames) > 1 && n < 8; n++ {
interopKey(t, s, input.KeyZ, true)
}
if len(model.Session.Document.Info().Frames) != 1 {
t.Fatal("common Undo did not return to the SDL-painted source frame")
}
r := interopMaximize(t, s, "art.timeline")
ui := s.App.Context().UI
pad, row, gap := ui.Theme.Padding, ui.Theme.RowHeight, ui.Theme.Spacing
width, y := r.W-2*pad, r.Y+pad+(row+gap)+row/2
for range 6 {
interopClick(t, s, r.X+pad+width*3/8, y) // Duplicate: independent image.
}
interopClick(t, s, r.X+pad+width*5/8, y) // Link: shared image.
info := model.Session.Document.Info()
if len(info.Frames) != 8 {
t.Fatal("native Timeline did not create eight frames")
}
linked := map[art.ID]int{}
for _, cel := range info.Cels {
linked[cel.Image]++
}
hasLinked := false
for _, count := range linked {
hasLinked = hasLinked || count > 1
}
if !hasLinked {
t.Fatal("native Link control did not preserve shared cel identity")
}
interopMaximize(t, s, panelID)
interopKey(t, s, input.KeyS, true)
if doc.Dirty() {
t.Fatal("SDL Ctrl+S did not save the native eight-frame document")
}
saved := interopRead(t, filepath.Join(s.Project.Root, interopArtPath))
encoded, err := model.Session.Document.Encode()
if err != nil || !bytes.Equal(saved, encoded) {
t.Fatal("normal Save did not persist the exact current Art bytes", err)
}
w := interopWitness(t, s, model)
if out := os.Getenv("AEGO_ART_INTEROP_OUT"); out != "" {
if err := os.Mkdir(out, 0755); err != nil {
t.Fatal("use a new interoperability evidence directory", err)
}
w.ProjectFiles = map[string]string{}
err := filepath.WalkDir(s.Project.Root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(s.Project.Root, path)
if err != nil {
return err
}
if entry.IsDir() {
if rel == ".aego" || rel == "exports" {
return filepath.SkipDir
}
return os.MkdirAll(filepath.Join(out, "project", rel), 0755)
}
if !entry.Type().IsRegular() {
t.Fatal("unexpected non-regular file in the native fixture", rel)
}
data := interopRead(t, path)
rel = filepath.ToSlash(rel)
w.ProjectFiles[rel] = interopSHA(data)
interopWrite(t, filepath.Join(out, "project", filepath.FromSlash(rel)), data)
return nil
})
if err != nil {
t.Fatal(err)
}
interopJSON(t, filepath.Join(out, "native-source.json"), w)
interopShot(t, s, filepath.Join(out, "native-source.png"))
}
t.Log("file interoperability producer: actual SDL-painted source, six Timeline Duplicates, one Link, Ctrl+S; eight frames, live SpriteRenderer/AnimationPlayer and current prepared AssetRefs")
}
func artFileInteropReturnNative(t *testing.T, root string) {
t.Helper()
t.Setenv("AEGO_LANG", "en")
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_DATA_HOME", t.TempDir())
var before artInteropWitness
if err := json.Unmarshal(interopRead(t, filepath.Join(root, "native-source.json")), &before); err != nil {
t.Fatal(err)
}
if before.Schema != 1 || before.SourcePath != interopArtPath || before.ScenePath != interopScenePath || len(before.ProjectFiles) < 4 {
t.Fatal("invalid native interoperability witness")
}
var web struct {
Status string `json:"status"`
State artInteropState `json:"state"`
InputSHA256 string `json:"inputSHA256"`
DownloadSHA256 string `json:"downloadSHA256"`
}
if err := json.Unmarshal(interopRead(t, filepath.Join(root, "browser.json")), &web); err != nil || web.Status != "PASS" || web.InputSHA256 != before.State.SourceSHA256 || web.DownloadSHA256 != web.State.SourceSHA256 {
t.Fatal("native return requires a successful exact-file browser witness", err)
}
projectRoot := filepath.Join(root, "project")
for path, expected := range before.ProjectFiles {
if path == before.SourcePath {
expected = web.DownloadSHA256
}
if interopSHA(interopRead(t, filepath.Join(projectRoot, filepath.FromSlash(path)))) != expected {
t.Fatal("returned project changed a file other than the downloaded Art source", path)
}
}
p, err := project.Load(projectRoot)
if err != nil {
t.Fatal(err)
}
s, err := Open(p, Config{Hidden: true, NoWatch: true, Width: 1280, Height: 900, Logger: log.Nop()})
if err != nil {
t.Fatal(err)
}
defer s.Close()
interopStep(t, s)
ed := s.App.Context()
if _, err := ed.Docs.Open(before.ScenePath); err != nil {
t.Fatal(err)
}
id, ok := s.Pipeline.Lookup(before.SourcePath)
if !ok || id.String() != before.SourceAsset {
t.Fatal("returned source lost its original AssetID")
}
interopMaximize(t, s, workspace.Assets)
s.App.AssetsPanel().SetFolder("assets")
for range 3 {
interopStep(t, s)
}
tile, _, visible := s.App.AssetsPanel().TileRect(id)
if !visible || ed.Docs.AssetDocument(id) != nil {
t.Fatal("returned source was not a closed, visible Assets tile")
}
failOpen := func(stage string) {
t.Helper()
current, _, tileVisible := s.App.AssetsPanel().TileRect(id)
d := ed.Docs.AssetDocument(id)
dirty, handler := false, ""
var model any
if d != nil {
dirty, handler, model = d.Dirty(), d.Handler, d.Binding.Model
}
notice, _ := ed.LastNotice()
ws := s.App.Workspace()
interopShot(t, s, filepath.Join(root, "native-return-failure.png"))
t.Fatalf("%s: docNil=%t dirty=%t handler=%q model=%T artVisible=%t workspace=%q maximized=%q focused=%q originalTile=%+v currentTile=%+v tileVisible=%t notice=%+v",
stage, d == nil, dirty, handler, model, ws.Visible("render2d.art/"+id.String()), ws.Current().ID, ws.Tree.Maximized(), ws.Focused(), tile, current, tileVisible, notice)
}
interopClick(t, s, tile.X+tile.W/2, tile.Y+tile.H/2)
currentTile, _, tileVisible := s.App.AssetsPanel().TileRect(id)
if s.App.Workspace().Tree.Maximized() != workspace.Assets || !tileVisible || currentTile != tile {
failOpen("single Assets click changed the explicitly maximized layout or tile")
}
// A real user double-clicks at the same position. Do not reacquire or
// compensate for a moved tile: automatic inspection must not move it.
interopClick(t, s, tile.X+tile.W/2, tile.Y+tile.H/2)
doc := ed.Docs.AssetDocument(id)
if doc == nil || doc.Dirty() || !s.App.Workspace().Visible("render2d.art/"+id.String()) {
failOpen("actual Assets double-click did not open the downloaded Art document")
}
model, ok := doc.Binding.Model.(*artedit.Document)
if !ok {
t.Fatal("returned source opened a viewer instead of the Art Canvas")
}
after := interopWitness(t, s, model)
if !reflect.DeepEqual(after.State, web.State) || after.SourceAsset != before.SourceAsset || !reflect.DeepEqual(after.SceneRefs, before.SceneRefs) || !reflect.DeepEqual(after.SubAssets, before.SubAssets) || !reflect.DeepEqual(after.ClipFrames, before.ClipFrames) || after.ClipSHA256 != before.ClipSHA256 {
t.Fatal("native reopen changed browser pixels, stable document topology, durations, palette, pivot or runtime AssetRefs")
}
if reflect.DeepEqual(after.State.FrameHashes, before.State.FrameHashes) {
t.Fatal("returned document has no actual browser pixel edit")
}
canvasPanel := interopMaximize(t, s, "render2d.art/"+id.String())
interopShot(t, s, filepath.Join(root, "native-reopened.png"))
// The independently reopened document has its own zoom/pan. Locate the
// actual rendered Canvas texture, then a visible opaque pixel of the
// selected cel through read-only sampling; never reuse producer coordinates
// or mutate the document to make an input target convenient.
initial := after.State.FrameHashes
interopKey(t, s, input.KeyE, false)
ui := ed.UI
canvas := model.Session.Document.Info().Canvas
var displayed, clip mathx.Rect
textures := 0
for _, item := range s.App.UIList().TestDrawnItems(mathx.Rect{W: ui.Size().X, H: ui.Size().Y}, ui.Font(), "", canvas.Width, canvas.Height) {
if item.Bounds.W <= float32(canvas.Width) || item.Bounds.H <= float32(canvas.Height) ||
item.Bounds.X < canvasPanel.X || item.Bounds.Y < canvasPanel.Y ||
item.Bounds.X+item.Bounds.W > canvasPanel.X+canvasPanel.W || item.Bounds.Y+item.Bounds.H > canvasPanel.Y+canvasPanel.H {
continue
}
displayed, clip = item.Bounds, item.Clip
textures++
}
if textures != 1 {
t.Fatalf("expected one visible source-sized texture inside the actual Canvas, got %d", textures)
}
pixel, screenPoint := image.Pt(-1, -1), mathx.Vec2{}
var pixelRect mathx.Rect
var originalPaint art.Paint
for y := 0; y < canvas.Height && pixel.X < 0; y++ {
for x := 0; x < canvas.Width; x++ {
sample, err := model.Session.SamplePaint(image.Pt(x, y))
if err != nil {
t.Fatal(err)
}
if sample.Color.A == 0 {
continue
}
cell := mathx.Rect{X: displayed.X + float32(x)*displayed.W/float32(canvas.Width), Y: displayed.Y + float32(y)*displayed.H/float32(canvas.Height), W: displayed.W / float32(canvas.Width), H: displayed.H / float32(canvas.Height)}
center := mathx.V2(cell.X+cell.W/2, cell.Y+cell.H/2)
if !clip.Contains(center) || !canvasPanel.Contains(center) {
continue
}
pixel, pixelRect, screenPoint, originalPaint = image.Pt(x, y), cell, center, sample
break
}
}
if pixel.X < 0 {
t.Fatal("no visible opaque pixel of the actual selected cel is available to erase")
}
interopJSON(t, filepath.Join(root, "native-erase-target.json"), struct {
SourcePixel image.Point `json:"sourcePixel"`
ScreenPoint mathx.Vec2 `json:"screenPoint"`
PixelRect, CanvasRect, Clip mathx.Rect
Frame, Layer art.ID
Channel art.Channel
}{pixel, screenPoint, pixelRect, displayed, clip, model.Session.Frame, model.Session.Layer, model.Session.Channel})
interopClick(t, s, screenPoint.X, screenPoint.Y)
erased, sampleErr := model.Session.SamplePaint(pixel)
if sampleErr != nil || erased.Color.A != 0 || model.History.Cursor() != 1 || !doc.Dirty() || reflect.DeepEqual(interopState(t, model, nil).FrameHashes, initial) {
interopShot(t, s, filepath.Join(root, "native-return-edit-failure.png"))
t.Fatalf("returned source was not editable through its real native Canvas: pixel=%v screen=%v history=%d dirty=%t erased=%+v error=%v", pixel, screenPoint, model.History.Cursor(), doc.Dirty(), erased, sampleErr)
}
interopKey(t, s, input.KeyZ, true)
restored, sampleErr := model.Session.SamplePaint(pixel)
if sampleErr != nil || restored != originalPaint || model.History.Cursor() != 0 || doc.Dirty() || !reflect.DeepEqual(interopState(t, model, nil).FrameHashes, initial) {
t.Fatal("common native Undo did not restore the exact browser-returned pixels")
}
// Only automatic selection preserves maximize. Explicit asset inspection
// (the same callback as readonly document fallback) must still reveal the
// Inspector, and explicit Inspector focus must retain its normal behavior.
interopMaximize(t, s, workspace.Assets)
ed.InspectAsset(id)
interopStep(t, s)
if s.App.Workspace().Tree.Maximized() != "" || !s.App.Workspace().Visible(workspace.Inspector) {
t.Fatal("explicit asset inspection no longer reveals the Inspector")
}
interopMaximize(t, s, workspace.Assets)
s.App.Workspace().Focus(workspace.Inspector)
interopStep(t, s)
if s.App.Workspace().Tree.Maximized() != "" || s.App.Workspace().Focused() != workspace.Inspector {
t.Fatal("explicit Inspector focus no longer restores its visible layout")
}
for path, expected := range before.ProjectFiles {
if path == before.SourcePath {
expected = web.DownloadSHA256
}
if interopSHA(interopRead(t, filepath.Join(projectRoot, filepath.FromSlash(path)))) != expected {
t.Fatal("reopening/Undo rewrote source, scene or metadata", path)
}
}
interopJSON(t, filepath.Join(root, "native-return.json"), struct {
Status string `json:"status"`
Witness artInteropWitness `json:"witness"`
CommonUndo bool `json:"commonUndo"`
}{"PASS", after, true})
t.Log("actual browser download -> same project path and .meta -> SDL Assets double-click -> native Canvas: complete document, all four channel pixels, eight frames, cel links and assigned runtime AssetRefs unchanged; real paint/Undo PASS")
}