Files
aego/app/edit/material_workflow_test.go
sanspie a6822156cd
ci-platforms / windows amd64 smoke (push) Skipped
ci / release (push) Successful in 8m58s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 4m46s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m28s
Finish Stage 12 editor fixes and refreshed platformer
2026-11-05 17:36:42 +03:00

290 lines
9.6 KiB
Go

package edit
import (
"bytes"
"math"
"os"
"path/filepath"
"strings"
"testing"
"unsafe"
"aego/asset"
"aego/core/ids"
"aego/editor/core/context"
"aego/editor/core/document"
"aego/editor/core/history/commands"
"aego/editor/panels/scripteditor"
"aego/modules/render2d"
"aego/modules/tween"
"aego/scene"
"aego/scene/mutate"
"aego/scene/sceneio"
"aego/schema"
)
// The headless workflow exercises the editor's commands, dirty documents, History, importer and Play world;
// native/browser integration tests separately establish driver compilation and pixel output.
func TestMaterialAuthoringWorkflowHeadless(t *testing.T) {
materialAuthoringWorkflow(t, headless())
}
func materialAuthoringWorkflow(t *testing.T, cfg Config) {
t.Helper()
p := newProject(t)
const scenePath = "scenes/material-workflow.aescene"
writeScene(t, p, scenePath, func(sc *scene.Scene) {
cam := sc.Create(sc.Root(), "Camera")
*scene.Add[render2d.Camera2D](sc, cam) = render2d.DefaultCamera2D()
for _, name := range []string{"A", "B"} {
h := sc.Create(sc.Root(), name)
*scene.Add[render2d.SpriteRenderer](sc, h) = render2d.DefaultSpriteRenderer()
}
})
s, err := Open(p, cfg)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.Step()
ed := s.App.Context()
d, err := ed.Docs.Open(scenePath)
if err != nil {
t.Fatal(err)
}
s.Step()
if cfg.Headless && ed.Renderer.Caps().Renderer != "null" {
t.Fatal("this workflow must not report headless validation as a GPU test")
}
if !cfg.Headless && ed.Renderer.Caps().Renderer == "null" {
t.Fatal("native workflow requires an actual graphics device")
}
// Invoke the same registry action as Create -> Material. Both sources must
// land in the persistent Assets history, not an abandoned local undo stack.
created := false
for _, action := range ed.CreateActions {
if action.Key == "render2d.material" {
if err = action.Run(ids.NodeID{}); err != nil {
t.Fatal(err)
}
created = true
break
}
}
if !created {
t.Fatal("Material action is missing from the Create palette registry")
}
questions := ed.TakeQuestions()
if len(questions) != 1 || questions[0].Submit == nil {
t.Fatal("Create Material did not open its creation form")
}
const materialPath = "assets/materials/workflow.aemat"
questions[0].Submit([]string{materialPath})
materialID, ok := ed.Pipeline.Lookup(materialPath)
if !ok {
t.Fatal("palette action did not create a material")
}
md, err := ed.Docs.OpenScript(materialPath)
if err != nil {
t.Fatal(err)
}
parsed, err := asset.ParseMaterial(md.Code.Bytes(), materialPath)
if err != nil {
t.Fatal(err)
}
shaderID := parsed.Shader
shaderPath, ok := ed.Pipeline.PathOf(shaderID)
if !ok {
t.Fatal("material has no created shader")
}
if s.App.Data().Inspected() != materialID {
t.Fatal("Create Material did not select the created asset in the Inspector")
}
materialMeta, _ := os.ReadFile(filepath.Join(p.Root, materialPath+".meta"))
shaderMeta, _ := os.ReadFile(filepath.Join(p.Root, shaderPath+".meta"))
for _, command := range []string{"assets.undo", "assets.redo"} {
c, found := ed.Commands.Get(command)
if !found || c.Run == nil {
t.Fatal("shared Assets history command is missing", command)
}
if err = c.Run(); err != nil {
t.Fatal(command, err)
}
if command == "assets.undo" {
for _, path := range []string{materialPath, shaderPath} {
if _, e := os.Stat(filepath.Join(p.Root, path)); !os.IsNotExist(e) {
t.Fatal("creation did not undo both sources", path, e)
}
}
}
}
for path, original := range map[string][]byte{materialPath + ".meta": materialMeta, shaderPath + ".meta": shaderMeta} {
data, e := os.ReadFile(filepath.Join(p.Root, path))
if e != nil || !bytes.Equal(data, original) {
t.Fatal("creation redo changed stable asset identity", path, e)
}
}
md, err = ed.Docs.OpenScript(materialPath)
if err != nil {
t.Fatal(err)
}
for _, source := range []string{shaderPath, materialPath} {
ed.Open(context.At(source, 1, 0))
s.Step()
if !s.App.Workspace().Has(scripteditor.PanelID(source)) {
t.Fatal("source action did not open the shared code editor", source)
}
}
beforeMaterial := append([]byte(nil), md.Code.Bytes()...)
for i, value := range []string{"0.2", "0.4", "0.6"} {
_, err = document.ApplyMaterialMutation(md, ed.Loader.Shader, mutate.Tx{Label: "Material Opacity", Ops: []mutate.Op{mutate.MaterialSet("Opacity", value)}}, i != 0)
if err != nil {
t.Fatal(err)
}
}
if !md.Code.Undo() || !bytes.Equal(md.Code.Bytes(), beforeMaterial) || md.Code.CanUndo() {
t.Fatal("one Inspector drag did not undo to the original shared source")
}
if !md.Code.Redo() {
t.Fatal("material source redo")
}
if err = ed.Docs.SaveScript(md); err != nil {
t.Fatal(err)
}
if entry, exists := ed.Pipeline.Entry(materialID); !exists || entry.Error != "" {
t.Fatal("Save did not import the material with file watching disabled", entry)
}
cache := ed.Renderer.Materials()
commit := func() { cache.Begin(); cache.End() }
commit()
live, ok := ed.Loader.Material(materialID)
if !ok {
t.Fatal("created material did not load through the runtime artifact")
}
if value, _ := live.Get("Opacity"); math.Abs(float64(value[0]-.6)) > 1e-6 {
t.Fatal("saved Inspector value did not reach the shared material", value)
}
// Scene assignment and instance edits use the normal scene History.
sc := d.Scene()
a, b := sc.Find(sc.Root(), "A"), sc.Find(sc.Root(), "B")
aID, bID := sc.ID(a), sc.ID(b)
spriteType, _ := sc.Registry().ByName("engine.SpriteRenderer")
materialField, _ := spriteType.Field("material")
if err = d.Props.Set(nil, []ids.NodeID{aID, bID}, spriteType.ID, materialField.ID, schema.Asset(materialID)); err != nil {
t.Fatal(err)
}
overrideType, _ := sc.Registry().ByName("engine.MaterialOverride")
valuesField, _ := overrideType.Field("values")
if err = d.History.Exec(commands.NewMutation("Add Material Override", commands.EnvFor(sc), commands.AddComp(sc, aID, overrideType, nil))); err != nil {
t.Fatal(err)
}
tx := d.History.Begin("Material override Opacity")
for _, value := range []float32{.2, .3, .4} {
candidate := render2d.MaterialOverride{}
if err = candidate.Set(live, "Opacity", schema.Float32(value)); err != nil {
t.Fatal(err)
}
var values schema.Value
valuesField.Get(unsafe.Pointer(&candidate), &values)
if err = d.Props.Set(tx, []ids.NodeID{aID}, overrideType.ID, valuesField.ID, values); err != nil {
t.Fatal(err)
}
}
tx.Commit()
get := func(s *scene.Scene, id ids.NodeID) float64 {
t.Helper()
v, e := render2d.GetMaterialParameter(s, s.Handle(id), "Opacity")
if e != nil {
t.Fatal(e)
}
return v.F
}
if err = d.History.Undo(); err != nil || math.Abs(get(sc, aID)-.6) > 1e-6 {
t.Fatal("node override slider did not undo in one step", err)
}
if err = d.History.Redo(); err != nil || math.Abs(get(sc, aID)-.4) > 1e-6 || math.Abs(get(sc, bID)-.6) > 1e-6 {
t.Fatal("node override was not isolated or redo failed", err)
}
d.History.MarkSaved()
beforeScene := sceneio.Save(sc)
pc := s.App.Play()
if err = pc.Start(d); err != nil {
t.Fatal(err)
}
service := tween.Of(pc.World())
if service == nil {
t.Fatal("real Play world did not attach the Tween module")
}
if _, err = service.ToFloat(pc.Scene().Handle(aID), "MaterialOverride.Opacity", .9, .5, tween.Options{}); err != nil {
t.Fatal(err)
}
for range 10 {
pc.Tick(.05)
}
if get(pc.Scene(), aID) < .85 || math.Abs(get(pc.Scene(), bID)-.6) > 1e-6 {
t.Fatal("attached Play-world Tween did not animate only its node")
}
pc.Stop()
if !bytes.Equal(sceneio.Save(sc), beforeScene) || d.Dirty() {
t.Fatal("Play animation changed or dirtied EditScene")
}
// Saving current source imports a candidate; publication is still atomic at
// the renderer boundary. A newer invalid edit must cancel an older candidate.
sd, err := ed.Docs.OpenScript(shaderPath)
if err != nil {
t.Fatal(err)
}
initialSource := string(sd.Code.Bytes())
withGain := func(value string) string {
return strings.Replace(initialSource, "#pragma end", "uniform float Gain; // @default("+value+")\n#pragma end", 1)
}
importShader := func(source string, valid bool) {
t.Helper()
sd.Code.ReplaceSnapshot([]byte(source), false)
if e := ed.Docs.SaveScript(sd); e != nil {
t.Fatal(e)
}
found := false
for _, info := range ed.Pipeline.Assets() {
if info.ID != shaderID {
continue
}
found = true
if (valid && info.Error != "") || (!valid && info.Error == "") {
t.Fatal("source Save did not publish the expected import validation state", valid, info.Error)
}
}
if !found {
t.Fatal("shader disappeared after saving its source")
}
}
old := live.Shader()
importShader(withGain("0.1"), true)
if live.Shader() != old || !cache.Pending(live) {
t.Fatal("candidate schema was published before the frame boundary")
}
commit()
if live.Shader() == old || cache.Pending(live) || math.Abs(get(sc, aID)-.4) > 1e-6 {
t.Fatal("successful reload did not commit or lost node values")
}
lastGood, generation := live.Shader(), live.Generation()
importShader(withGain("0.2"), true)
if !cache.Pending(live) {
t.Fatal("older valid source was not queued")
}
importShader("#include \"missing-workflow-include.glsl\"\n"+withGain("0.2"), false)
commit()
if cache.Pending(live) || live.Shader() != lastGood || live.Generation() != generation || math.Abs(get(sc, aID)-.4) > 1e-6 {
t.Fatal("newer invalid source allowed a stale candidate or changed last-good state")
}
importShader(withGain("0.3"), true)
commit()
value, exists := live.Get("Gain")
if !exists || math.Abs(float64(value[0]-.3)) > 1e-6 || live.Generation() == generation {
t.Fatal("fixed source did not recover through the editor's import path")
}
}