Files
aego/app/edit/asset_documents_workflow_test.go
sanspie fac6fa35a4
ci-platforms / darwin arm64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin amd64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci / checks (push) Failing after 7m29s
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 14m13s
ci / release (push) Skipped
GitHub / windows amd64 native GitHub-hosted native CI: failure
GitHub / darwin arm64 native GitHub-hosted native CI: error
GitHub / darwin amd64 native GitHub-hosted native CI: failure
GitHub / linux amd64 native GitHub-hosted native CI: success
fix: stabilize CI and prepare docs release
Keep recovery copies private on Windows, make cross-platform test fixtures deterministic, and preserve the updated Art and documentation contracts.

Refresh workspace screenshot references for the intentional factory layouts and restore the earlier Russian palette reference after before/after comparison. Keep the existing screenshot tolerances.
2026-11-09 16:24:24 +03:00

457 lines
18 KiB
Go

package edit
import (
"bytes"
stdctx "context"
"errors"
"image"
"image/color"
"image/png"
"math"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"aego/app/agent"
"aego/art"
"aego/asset"
"aego/asset/importers/artsource"
textureimport "aego/asset/importers/texture"
"aego/asset/pipeline"
"aego/core/ids"
"aego/core/mathx"
"aego/editor/core/artedit"
"aego/editor/core/command"
"aego/editor/core/context"
"aego/editor/core/document"
"aego/engine/i18n"
"aego/format/aetext"
"aego/modules/render2d"
"aego/render/lighting"
"aego/scene"
"aego/scene/mutate"
"aego/scene/sceneio"
)
func TestImageAssetAuthoringWorkflowHeadless(t *testing.T) {
imageAssetAuthoringWorkflow(t, headless())
}
func imageAssetAuthoringWorkflow(t *testing.T, cfg Config) {
t.Helper()
p := newProject(t)
heightID, albedoID, agentID := ids.NewAssetID(), ids.NewAssetID(), ids.NewAssetID()
imp := textureimport.New()
for _, source := range []struct {
path string
id ids.AssetID
role string
}{{"assets/sculpted_height.png", heightID, "height"}, {"assets/sculpted.png", albedoID, "color"}, {"assets/agent_height.png", agentID, "height"}} {
im := image.NewNRGBA(image.Rect(0, 0, 32, 32))
for y := range 32 {
for x := range 32 {
v := uint8(220 * math.Max(0, 1-math.Hypot(float64(x-16), float64(y-16))/21))
c := color.NRGBA{R: v, G: v, B: v, A: 255}
if source.role == "color" {
c = color.NRGBA{R: 100 + v/3, G: 130 + v/3, B: 170 + v/3, A: 255}
}
im.SetNRGBA(x, y, c)
}
}
var data bytes.Buffer
if err := png.Encode(&data, im); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(p.Root, source.path), data.Bytes(), 0644); err != nil {
t.Fatal(err)
}
meta := asset.NewSourceMeta("texture", imp.Version(), imp.Defaults())
meta.ID = source.id
meta.Settings["role"] = aetext.String(source.role)
if source.role == "height" {
meta.Settings["generate_normal"] = aetext.Bool(true)
meta.Settings["height_strength"] = aetext.Float32(1)
} else {
meta.Settings["normal"] = aetext.AssetRef(heightID.String())
}
if err := os.WriteFile(filepath.Join(p.Root, source.path+".meta"), asset.MarshalSourceMeta(meta), 0644); err != nil {
t.Fatal(err)
}
}
const scenePath = "scenes/asset-workflow.aescene"
writeScene(t, p, scenePath, func(sc *scene.Scene) {
cam := sc.Create(sc.Root(), "Camera")
*scene.Add[render2d.Camera2D](sc, cam) = render2d.DefaultCamera2D()
surface := sc.Create(sc.Root(), "Lit Surface")
sr := scene.Add[render2d.SpriteRenderer](sc, surface)
*sr = render2d.DefaultSpriteRenderer()
sr.Sprite.ID, sr.Material.ID, sr.Size = albedoID, asset.BuiltinMaterialLit, mathx.Vec2{X: 4, Y: 4}
light := sc.Create(sc.Root(), "Point")
l := scene.Add[render2d.Light2D](sc, light)
*l = render2d.DefaultLight2D()
l.Type, l.Height, l.Range = lighting.Point, 2, 9
pose := sc.Local(light)
pose.Position = mathx.Vec2{X: -2, Y: -1}
sc.SetLocal(light, pose)
e := sc.Create(sc.Root(), "Environment")
env := scene.Add[render2d.LightingEnvironment2D](sc, e)
*env = render2d.DefaultLightingEnvironment2D()
env.AmbientIntensity = .15
})
s, err := Open(p, cfg)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.Step()
ed := s.App.Context()
sc, err := ed.Docs.Open(scenePath)
if err != nil {
t.Fatal(err)
}
s.Step()
beforeScene := sceneio.Save(sc.Scene())
readNormal := func() []byte {
t.Helper()
entry, ok := ed.Loader.Index().Lookup(heightID)
if !ok {
t.Fatal("working normal entry missing")
}
a, ok := entry.Artifact(asset.ArtTexture)
if !ok {
t.Fatal("normal artifact missing")
}
raw, err := ed.Loader.Artifacts().ReadFile(a.Path)
if err != nil {
t.Fatal(err)
}
normal, err := asset.DecodeTexture(raw)
if err != nil || normal.Semantic != asset.TextureNormal {
t.Fatal("height source did not produce a normal artifact", err)
}
return bytes.Clone(normal.Pixels)
}
initial := readNormal()
// Asset Browser and search both use the generic document location route.
ed.Open(context.AssetDocumentAt("assets/sculpted_height.png"))
d := ed.Docs.AssetDocument(heightID)
if d == nil || d.Source != nil || d.Metadata == nil || d.Capabilities.Has(document.EditSource) {
t.Fatal("PNG did not open as a metadata-owning image document")
}
panelID := "render2d.image/" + heightID.String()
ws := s.App.Workspace()
if !ws.Visible(panelID) || ws.Focused() != panelID {
t.Fatal("image open did not create/focus a normal workspace document tab")
}
shared, err := ed.DocumentForAsset(heightID)
if err != nil || shared != d {
t.Fatal("Inspector and image tab use different documents", err)
}
beforePixels, _ := os.ReadFile(ed.Docs.Abs(d.Path))
env, err := ed.TextureAuthoringEnv(heightID)
if err != nil {
t.Fatal(err)
}
for i, strength := range []float32{3, 5, 7} {
_, err := document.ApplyTextureMutation(d.Metadata, env, mutate.Tx{Label: "Height strength", Ops: []mutate.Op{mutate.TextureSetting("height_strength", aetext.Float32(strength))}}, i > 0)
if err != nil {
t.Fatal(err)
}
}
run := func(id string) {
t.Helper()
ws.Focus(panelID)
if err := ed.Commands.Run(id); err != nil {
t.Fatal(id, err)
}
}
run(command.FileSave)
s.Step()
saved := readNormal()
if bytes.Equal(initial, saved) || d.Dirty() || d.Metadata.SavedEdits == 0 {
t.Fatal("generic Save did not import the changed height settings")
}
diskSaved, _ := os.ReadFile(ed.Docs.Abs(d.Metadata.Path))
if err := s.App.Play().Start(sc); err != nil {
t.Fatal(err)
}
previous := d.WorkingPreview.Applied
run(command.EditUndo)
deadline := time.Now().Add(8 * time.Second)
for d.WorkingPreview.Applied == previous && d.WorkingPreview.Error == "" && time.Now().Before(deadline) {
s.Step()
runtime.Gosched()
time.Sleep(time.Millisecond)
}
if d.WorkingPreview.Applied == previous || d.WorkingPreview.Pending || d.WorkingPreview.Error != "" || !bytes.Equal(initial, readNormal()) {
t.Fatalf("post-Save Undo did not publish earlier normals during Play: %+v", d.WorkingPreview)
}
if current, _ := os.ReadFile(ed.Docs.Abs(d.Metadata.Path)); !bytes.Equal(current, diskSaved) || !d.Dirty() {
t.Fatal("live Undo silently wrote metadata or lost dirty state")
}
normalID, normalRegion, ok := ed.Loader.NormalFor(albedoID)
heightRegion, heightOK := ed.Loader.Texture(heightID)
if !ok || !heightOK || normalID != heightID || normalRegion.Texture != heightRegion.Texture {
t.Fatal("shared albedo consumer retained a stale normal binding")
}
previous = d.WorkingPreview.Applied
run(command.EditRedo)
deadline = time.Now().Add(8 * time.Second)
for d.WorkingPreview.Applied == previous && d.WorkingPreview.Error == "" && time.Now().Before(deadline) {
s.Step()
runtime.Gosched()
time.Sleep(time.Millisecond)
}
if d.Dirty() || d.WorkingPreview.Applied == previous || !bytes.Equal(saved, readNormal()) {
t.Fatal("generic Redo did not restore the saved generated normal")
}
s.App.Play().Stop()
if sc.Dirty() || !bytes.Equal(sceneio.Save(sc.Scene()), beforeScene) {
t.Fatal("asset metadata Save/Undo/Play dirtied the scene or prefab state")
}
if current, _ := os.ReadFile(ed.Docs.Abs(d.Path)); !bytes.Equal(current, beforePixels) {
t.Fatal("metadata authoring modified image pixels")
}
// Editor agent mutations must acquire the same journal even with no viewer
// open. The filesystem-only CLI remains a separate file transaction.
bridge, err := s.agentBridge()
if err != nil {
t.Fatal(err)
}
const agentMeta = "assets/agent_height.png.meta"
raw, err := os.ReadFile(ed.Docs.Abs(agentMeta))
if err != nil {
t.Fatal(err)
}
m, err := asset.ParseSourceMeta(raw, agentMeta)
if err != nil {
t.Fatal(err)
}
m.Settings["height_strength"] = aetext.Float32(4)
changed := asset.MarshalSourceMeta(m)
if ed.Docs.AssetDocument(agentID) != nil {
t.Fatal("agent fixture was already open")
}
if err := bridge.ApplyScript(agent.ScriptEdit{Rel: agentMeta, Before: string(raw), After: string(changed)}, "Adjust height"); err != nil {
t.Fatal(err)
}
agentDoc := ed.Docs.AssetDocument(agentID)
if agentDoc == nil || agentDoc.Metadata == nil || !agentDoc.Dirty() || !bytes.Equal(agentDoc.Metadata.Code.Bytes(), changed) {
t.Fatal("agent metadata edit bypassed the shared document")
}
if current, _ := os.ReadFile(ed.Docs.Abs(agentMeta)); !bytes.Equal(current, raw) {
t.Fatal("editor agent silently saved unopened metadata")
}
agentDoc.Metadata.Conflict = true
if err := bridge.ApplyScript(agent.ScriptEdit{Rel: agentMeta, Before: string(changed), After: string(raw)}, "Conflict"); err == nil {
t.Fatal("agent ignored metadata conflict")
}
agentDoc.Metadata.Conflict = false
agentDoc.Metadata.ReadOnly = true
if err := bridge.ApplyScript(agent.ScriptEdit{Rel: agentMeta, Before: string(changed), After: string(raw)}, "Read only"); err == nil {
t.Fatal("agent ignored metadata read-only state")
}
agentDoc.Metadata.ReadOnly = false
if err := ed.UndoAsset(agentDoc); err != nil || agentDoc.Dirty() {
t.Fatal("agent metadata edit is not one undo step", err)
}
if !cfg.Headless {
ws.Focus(panelID)
for range 4 {
s.Step()
}
if path := os.Getenv("AEGO_IMAGE_WORKFLOW_SHOT"); path != "" {
var capture *image.RGBA
var captureErr error
s.Engine.CaptureFrame(func(img *image.RGBA, err error) { capture, captureErr = img, err })
s.Step()
if capture == nil || captureErr != nil {
t.Fatal("image document screenshot", captureErr)
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
file, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
err = png.Encode(file, capture)
closeErr := file.Close()
if err != nil || closeErr != nil {
t.Fatal(err, closeErr)
}
}
}
}
func TestAssetDocumentHandlerCoverage(t *testing.T) {
s, err := Open(newProject(t), headless())
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.Step()
r := s.App.Context().Docs.Handlers()
const read = document.View | document.RevealSource
const source = read | document.EditSource
for _, tt := range []struct {
kind asset.ArtifactKind
path, role, handler string
caps document.Capabilities
}{
{asset.ArtScene, "main.aescene", "", "editor.scene", source | document.Preview},
{asset.ArtPrefab, "enemy.aeprefab", "", "editor.prefab", source | document.Preview},
{asset.ArtScript, "hero.aes", "", "editor.script", source},
{asset.ArtData, "settings.aedata", "", "editor.data", source},
{asset.ArtTable, "balance.aetable", "", "editor.table", source},
{asset.ArtAnim, "walk.aeanim", "", "editor.animation", source},
{asset.ArtCtrl, "hero.aectrl", "", "editor.controller", source},
{asset.ArtMaterial, "stone.aemat", "", "render2d.material", source | document.Preview},
{asset.ArtShader, "lit.aeshader", "", "render2d.shader", source},
{asset.ArtShaderInclude, "common.glsl", "", "render2d.shader-include", source},
{asset.ArtTexture, "stone.png", "color", "render2d.art.png", source | document.EditMetadata | document.Preview},
{asset.ArtTexture, "stone_n.png", "normal", "render2d.image", read | document.EditMetadata | document.Preview},
{asset.ArtSpriteRegion, "sheet.png", "color", "render2d.art.png", source | document.EditMetadata | document.Preview},
{asset.ArtSpriteRegion, "sheet_n.png", "normal", "render2d.image", read | document.EditMetadata | document.Preview},
{asset.ArtTexture, "drawing.aeart", "color", "render2d.art", source | document.EditMetadata | document.Preview},
{asset.ArtAtlasPage, "atlas/page", "", "render2d.image", read | document.EditMetadata | document.Preview},
{asset.ArtAudio, "click.wav", "", "audio.clip", read | document.EditMetadata | document.Preview},
{asset.ArtAudio, "music.ogg", "", "audio.clip", read | document.EditMetadata | document.Preview},
{asset.ArtAudio, "music.mp3", "", "audio.clip", read | document.EditMetadata | document.Preview},
{asset.ArtMesh, "model.obj", "", "asset.readonly", read},
{asset.ArtPreset, "style.aepreset", "", "asset.readonly", read},
{"future-binary", "bundle.bin", "", "asset.readonly", read},
} {
t.Run(tt.path+"/"+tt.role, func(t *testing.T) {
h := r.Resolve(tt.kind, tt.path, tt.role)
if h.ID != tt.handler || h.Capabilities != tt.caps || h.Name.En == "" || h.Icon == "" {
t.Fatalf("handler contract: %+v", h)
}
if (tt.kind == asset.ArtTexture || tt.kind == asset.ArtSpriteRegion || tt.kind == asset.ArtAudio || tt.kind == asset.ArtMesh) && h.TextSource {
t.Fatal("binary format was advertised as a text document")
}
})
}
}
// Color PNG editing owns the same metadata document used by the Inspector, but
// EditSource applies to an in-memory draft rather than writing the PNG pixels.
func TestColorPNGHandlerUsesDraftAndSharedMetadata(t *testing.T) {
p := newProject(t)
const pngPath = "assets/stone.png"
const artPath = "assets/stone.aeart"
imageSource := image.NewNRGBA(image.Rect(0, 0, 2, 2))
imageSource.SetNRGBA(0, 0, color.NRGBA{R: 17, G: 81, B: 144, A: 255})
var encoded bytes.Buffer
if err := png.Encode(&encoded, imageSource); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(p.Root, filepath.FromSlash(pngPath)), encoded.Bytes(), 0644); err != nil {
t.Fatal(err)
}
s, err := Open(p, headless())
if err != nil {
t.Fatal(err)
}
defer s.Close()
if !s.Step() {
t.Fatal("editor stopped during startup")
}
ed := s.App.Context()
id, ok := ed.Pipeline.Lookup(pngPath)
if !ok {
t.Fatal("PNG was not imported")
}
metaBefore, err := ed.Pipeline.SourceBytes(pngPath + ".meta")
if err != nil || len(metaBefore) == 0 {
t.Fatal("PNG metadata is missing", err)
}
doc, err := ed.DocumentForAsset(id)
if err != nil || doc == nil || doc.Handler != "render2d.art.png" || doc.Source != nil || doc.Metadata == nil ||
!doc.Capabilities.Has(document.View|document.EditSource|document.EditMetadata|document.Preview|document.RevealSource) {
t.Fatalf("PNG did not acquire its metadata-owning binary document: %+v, %v", doc, err)
}
metadata := doc.Metadata
opened, err := ed.OpenAsset(id)
if err != nil || opened != doc || doc.Metadata != metadata {
t.Fatal("opening PNG did not reuse the Inspector's metadata document", err)
}
model, ok := doc.Binding.Model.(*artedit.Document)
if !ok || !model.Draft || doc.Dirty() || len(ed.TakeQuestions()) != 0 {
t.Fatal("PNG did not open as a clean in-memory drawing without a conversion prompt")
}
pixels, _, err := model.Capture(model.Session.Frame, art.Albedo)
if err != nil || pixels.Bounds() != image.Rect(0, 0, 2, 2) || pixels.NRGBAAt(0, 0) != (color.NRGBA{R: 17, G: 81, B: 144, A: 255}) {
t.Fatalf("draft did not preserve the original pixels: %v", err)
}
_, err = model.Execute(stdctx.Background(), art.Operation{Kind: "stroke", Points: []image.Point{{X: 1, Y: 1}}, Brush: art.Brush{Size: 1, Paint: art.Paint{Color: art.Color{R: 255, A: 255}}}})
if err != nil || !doc.Dirty() || metadata.Dirty() {
t.Fatal("draft edit did not leave metadata clean", err)
}
assertUnchanged := func() {
t.Helper()
if got, err := ed.Pipeline.SourceBytes(pngPath); err != nil || !bytes.Equal(got, encoded.Bytes()) {
t.Fatal("draft editing changed the PNG source", err)
}
if got, err := ed.Pipeline.SourceBytes(pngPath + ".meta"); err != nil || !bytes.Equal(got, metaBefore) {
t.Fatal("draft editing changed the PNG metadata before confirmation", err)
}
for _, rel := range []string{artPath, artPath + ".meta"} {
if _, err := os.Stat(ed.Docs.Abs(rel)); !os.IsNotExist(err) {
t.Fatalf("draft editing created %s before confirmation: %v", rel, err)
}
}
}
assertUnchanged()
if err := ed.SaveAsset(doc); !errors.Is(err, document.ErrSavePending) {
t.Fatalf("first Save did not wait for an explicit source path: %v", err)
}
questions := ed.TakeQuestions()
if len(questions) != 1 || questions[0].Title != i18n.Tr("editor.art.save_png_draft", "Save editable image source") ||
questions[0].Message != i18n.Tr("editor.art.save_png_draft_notice", "Save the drawing as an .aeart source and link it in the PNG import metadata. Replacing an existing drawing requires confirmation. The PNG itself is not overwritten; changes affect all users of this shared image.") ||
len(questions[0].Values) != 1 || questions[0].Values[0] != artPath || questions[0].Submit == nil {
t.Fatalf("first Save did not explain the PNG metadata link: %+v", questions)
}
assertUnchanged()
batches := 0
previousBeforeSwap := ed.BeforeSwap
ed.BeforeSwap = func(batch *pipeline.Batch) func() {
batches++
if len(batch.Failed) != 0 || len(batch.Retry) != 0 {
t.Errorf("PNG draft import failed or requires retry: failed=%v retry=%v", batch.Failed, batch.Retry)
}
if previousBeforeSwap != nil {
return previousBeforeSwap(batch)
}
return nil
}
questions[0].Submit([]string{artPath})
ed.BeforeSwap = previousBeforeSwap
if batches == 0 {
t.Fatal("confirmed first Save did not publish an import batch")
}
artID, ok := ed.Pipeline.Lookup(artPath)
if !ok || artID == id {
t.Fatal("confirmed first Save did not create a separate Art source")
}
saved := ed.Docs.AssetDocument(artID)
if saved == nil || saved.Binding.Model != model || model.Draft || saved.Dirty() || model.History.Cursor() != 1 {
t.Fatal("first Save did not transfer the drawing and its history")
}
meta, ok := ed.Pipeline.MetaOf(id)
if !ok || meta.ID != id {
t.Fatal("first Save lost the original PNG identity")
}
link, err := artsource.TextureLinkOf(&meta)
if err != nil || link == nil || link.Source != artID || doc.Metadata != metadata || metadata.Dirty() {
t.Fatalf("first Save did not synchronize the shared PNG metadata link: %v", err)
}
if got, err := ed.Pipeline.SourceBytes(pngPath); err != nil || !bytes.Equal(got, encoded.Bytes()) {
t.Fatal("confirmed first Save overwrote the PNG", err)
}
}