Files
sanspie a46347e2c6
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 12m19s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows 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 23m57s
ci / release (push) Skipped
GitHub / windows amd64 native GitHub-hosted native CI: failure
GitHub / darwin arm64 native GitHub-hosted native CI: failure
GitHub / darwin amd64 native GitHub-hosted native CI: failure
GitHub / linux amd64 native GitHub-hosted native CI: success
editor: add art studio and stage 12.5 workflows
2026-11-08 18:47:57 +03:00

334 lines
11 KiB
Go

package editor
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"aego/art"
"aego/core/ids"
"aego/editor/core/assetfiles"
"aego/editor/core/assetops"
"aego/editor/core/command"
"aego/editor/core/context"
"aego/editor/core/document"
"aego/editor/ext"
)
func TestArtCreateCommandUsesExtensionRegistryAvailability(t *testing.T) {
t.Run("without commands", func(t *testing.T) {
r := ext.New(ext.Deps{Editor: &context.Editor{Commands: command.New()}})
if err := r.Run("render2d", registerArtCreate); err != nil {
t.Fatalf("optional command service: %v", err)
}
})
t.Run("with commands", func(t *testing.T) {
commands := command.New()
// The extension's dependencies, not Editor.Commands, own registrations.
ed := &context.Editor{}
r := ext.New(ext.Deps{Editor: ed, Commands: commands})
if err := r.Run("render2d", registerArtCreate); err != nil {
t.Fatal(err)
}
if _, ok := commands.Get("asset.new_art"); !ok {
t.Fatal("new art command not registered")
}
if commands.Enabled("asset.new_art") {
t.Fatal("command must be disabled without a pipeline and documents")
}
if len(ed.CreateActions) != 2 || ed.CreateActions[0].RunInFolder == nil || ed.CreateActions[1].RunInFolder == nil {
t.Fatal("pixel art or PNG is missing from Create or the Assets folder menu")
}
r.Disable("render2d", "test")
r.Flush()
if _, ok := commands.Get("asset.new_art"); ok {
t.Fatal("disabled extension left its command registered")
}
if len(ed.CreateActions) != 0 {
t.Fatal("disabled extension left its Create action registered")
}
})
}
func TestCreatePixelArtFromPaletteAndSelectedFolder(t *testing.T) {
ed, _, p := importTestEditor(t)
var action *context.CreateAction
for i := range ed.CreateActions {
if ed.CreateActions[i].Key == "render2d.pixel_art" {
action = &ed.CreateActions[i]
}
}
if action == nil || action.RunInFolder == nil {
t.Fatal("pixel art Create actions are missing")
}
if err := action.Run(ids.NodeID{}); err != nil {
t.Fatal(err)
}
q := ed.TakeQuestions()
if len(q) != 1 || q[0].Values[0] != "assets/sprite.aeart" {
t.Fatalf("Create palette did not open the art dialog: %+v", q)
}
if err := os.MkdirAll(filepath.Join(p.Root, "assets", "selected"), 0o755); err != nil {
t.Fatal(err)
}
if err := action.RunInFolder("assets/selected"); err != nil {
t.Fatal(err)
}
q = ed.TakeQuestions()
if len(q) != 1 || q[0].Values[0] != "assets/selected/sprite.aeart" {
t.Fatalf("folder action did not use the selected folder: %+v", q)
}
usedHistory := false
ed.CreateAsset = func(rel string, data []byte) (ids.AssetID, error) {
usedHistory = true
return assetops.CreateFile(ed, rel, data)
}
q[0].Submit([]string{"assets/selected/hero.aeart", "24", "16", "rgba"})
data, err := os.ReadFile(filepath.Join(p.Root, "assets", "selected", "hero.aeart"))
if err != nil {
t.Fatal(err)
}
d, err := art.Decode(data, art.DefaultLimits())
if err != nil {
t.Fatalf("created file is not a valid art source: %v", err)
}
defer d.Close()
if c := d.Info().Canvas; c.Width != 24 || c.Height != 16 || !usedHistory {
t.Fatalf("canvas=%+v, used shared creation history=%v", c, usedHistory)
}
id, ok := ed.Pipeline.Lookup("assets/selected/hero.aeart")
if !ok || ed.Docs.AssetDocument(id) == nil {
t.Fatal("new drawing was not imported and opened")
}
}
func TestEmptyArtImportReportsFailureAndKeepsFile(t *testing.T) {
ed, _, p := importTestEditor(t)
input := filepath.Join(t.TempDir(), "manual.aeart")
if err := os.WriteFile(input, nil, 0o600); err != nil {
t.Fatal(err)
}
created, err := assetfiles.New(ed).ImportExternal([]string{input}, "assets")
if err == nil || !strings.Contains(err.Error(), "empty .aeart") || len(created) != 1 {
t.Fatalf("empty import must report its real failure: created=%v err=%v", created, err)
}
for _, file := range []string{input, filepath.Join(p.Root, "assets", "manual.aeart")} {
if data, err := os.ReadFile(file); err != nil || len(data) != 0 {
t.Fatalf("import changed %s: %q, %v", file, data, err)
}
}
}
func TestDroppingAsepriteCreatesEditableCopyWithoutOverwriting(t *testing.T) {
ed, _, p := importTestEditor(t)
source, original := importTestSource(t, "tags3", ".aseprite")
existing := filepath.Join(p.Root, "assets", "tags3.aeart")
if err := os.WriteFile(existing, []byte("existing user file"), 0o600); err != nil {
t.Fatal(err)
}
created, err := assetfiles.New(ed).ImportExternal([]string{source}, "assets")
if err != nil || len(created) != 1 {
t.Fatalf("dropping Aseprite: ids=%v err=%v", created, err)
}
if got, err := os.ReadFile(source); err != nil || !bytes.Equal(got, original) {
t.Fatalf("original Aseprite was changed: %v", err)
}
if got, err := os.ReadFile(existing); err != nil || string(got) != "existing user file" {
t.Fatalf("existing project source was overwritten: %q, %v", got, err)
}
rel, ok := ed.Pipeline.PathOf(created[0])
if !ok || rel != "assets/tags3 2.aeart" {
t.Fatalf("editable copy path = %q", rel)
}
doc, err := ed.OpenAsset(created[0])
if err != nil || doc.Handler != artHandlerID {
t.Fatalf("converted source did not open in Art Studio: %+v, %v", doc, err)
}
data, err := os.ReadFile(filepath.Join(p.Root, filepath.FromSlash(rel)))
if err != nil {
t.Fatal(err)
}
d, err := art.Decode(data, art.DefaultLimits())
if err != nil {
t.Fatal(err)
}
defer d.Close()
if len(d.Info().Frames) != 12 || len(d.Info().Tags) != 3 {
t.Fatalf("converted source lost the expected frames or tags: %+v", d.Info())
}
}
func TestDroppingAsepriteWithWarningsDoesNotConvertSilently(t *testing.T) {
ed, _, p := importTestEditor(t)
source, original := importTestSource(t, "tags3x123reps", ".aseprite")
target := filepath.Join(p.Root, "assets", "tags3x123reps.aeart")
start := func() context.Question {
t.Helper()
created, err := assetfiles.New(ed).ImportExternal([]string{source}, "assets")
if err != nil || len(created) != 0 {
t.Fatalf("warning-bearing import must wait for consent: ids=%v err=%v", created, err)
}
q := ed.TakeQuestions()
if len(q) != 1 || q[0].Then == nil || !strings.Contains(q[0].Message, "tag-repeat") {
t.Fatalf("import did not expose the actual diagnostics: %+v", q)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatalf("warning-bearing drop created a source without confirmation: %v", err)
}
return q[0]
}
start().Then(1)
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatal("cancel created an asset")
}
q := start()
if err := os.WriteFile(source, []byte("changed after review"), 0o600); err != nil {
t.Fatal(err)
}
q.Then(0)
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatal("changed source was imported from an old snapshot")
}
if err := os.WriteFile(source, original, 0o600); err != nil {
t.Fatal(err)
}
q = start()
if err := os.WriteFile(target, []byte("someone else's work"), 0o600); err != nil {
t.Fatal(err)
}
q.Then(0)
if got, err := os.ReadFile(target); err != nil || string(got) != "someone else's work" {
t.Fatalf("confirmation overwrote a concurrently created target: %q, %v", got, err)
}
// The next unique path is confirmed explicitly; keep the existing target.
created, err := assetfiles.New(ed).ImportExternal([]string{source}, "assets")
if err != nil || len(created) != 0 {
t.Fatalf("second target must also await consent: %v, %v", created, err)
}
qs := ed.TakeQuestions()
if len(qs) != 1 || qs[0].Then == nil {
t.Fatal("second target did not ask for consent")
}
qs[0].Then(0)
if _, ok := ed.Pipeline.Lookup("assets/tags3x123reps 2.aeart"); !ok {
t.Fatal("confirmed import did not create the editable copy")
}
if got, err := os.ReadFile(source); err != nil || !bytes.Equal(got, original) {
t.Fatalf("original Aseprite was changed: %v", err)
}
}
func TestInitializeEmptyArtNeedsConfirmationAndGuardsChanges(t *testing.T) {
for _, tc := range []struct {
name string
before []byte
changed bool
collision bool
}{
{name: "empty", before: []byte{}},
{name: "newline and existing backup", before: []byte("\n"), collision: true},
{name: "changed while dialog is open", before: []byte("\n"), changed: true},
} {
t.Run(tc.name, func(t *testing.T) {
ed, _, p := importTestEditor(t)
file := filepath.Join(p.Root, "assets", "empty.aeart")
if err := os.WriteFile(file, tc.before, 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := ed.Pipeline.Scan(); err != nil {
t.Fatal(err)
}
id, ok := ed.Pipeline.Lookup("assets/empty.aeart")
if !ok {
t.Fatal("empty source was not indexed")
}
batch, err := ed.Pipeline.Import(id)
if err != nil {
t.Fatal(err)
}
if len(tc.before) == 0 && (len(batch.Retry) != 1 || batch.Retry[0] != id) {
t.Fatalf("empty source should be retried by the pipeline: %+v", batch)
}
if len(tc.before) > 0 && batch.Failed[id] == nil {
t.Fatalf("blank text should report an invalid source: %+v", batch)
}
doc, err := ed.Docs.AcquireAsset(document.AssetRequest{Asset: id, Path: "assets/empty.aeart"})
if err != nil {
t.Fatal(err)
}
panel := &artPanel{ctx: ed, doc: doc, view: &artView{}, empty: true, blankBefore: append([]byte{}, tc.before...)}
backup := filepath.Join(p.Root, ".aego", "art-blank-"+id.String()+".original")
if tc.collision {
if err := os.WriteFile(backup, []byte("existing backup"), 0o600); err != nil {
t.Fatal(err)
}
}
panel.askInitializeEmpty()
q := ed.TakeQuestions()
if len(q) != 1 || q[0].Submit == nil {
t.Fatal("initialization did not ask for confirmation")
}
if data, _ := os.ReadFile(file); !bytes.Equal(data, tc.before) {
t.Fatal("file was changed before confirmation")
}
if tc.changed {
if err := os.WriteFile(file, []byte("other work"), 0o600); err != nil {
t.Fatal(err)
}
}
q[0].Submit([]string{"20", "14", "rgba"})
data, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if tc.changed {
if !bytes.Equal(data, []byte("other work")) || panel.model != nil {
t.Fatal("initialization overwrote a concurrently changed file")
}
return
}
if tc.collision {
if original, err := os.ReadFile(backup); err != nil || string(original) != "existing backup" {
t.Fatal("initialization overwrote an existing backup")
}
backup = filepath.Join(p.Root, ".aego", "art-blank-"+id.String()+"-2.original")
}
if len(tc.before) > 0 {
if saved, err := os.ReadFile(backup); err != nil || !bytes.Equal(saved, tc.before) {
t.Fatalf("original blank source was not preserved: %q, %v", saved, err)
}
}
if panel.model == nil || panel.err != nil {
t.Fatalf("initialized file did not open: %v", panel.err)
}
if again, ok := ed.Pipeline.Lookup("assets/empty.aeart"); !ok || again != id {
t.Fatal("initialization changed the existing AssetID")
}
d, err := art.Decode(data, art.DefaultLimits())
if err != nil {
t.Fatal(err)
}
defer d.Close()
if c := d.Info().Canvas; c.Width != 20 || c.Height != 14 {
t.Fatalf("unexpected initialized canvas: %+v", c)
}
})
}
}
func TestBlankArtSourceDetection(t *testing.T) {
for _, tc := range []struct {
data []byte
want bool
}{
{nil, false}, {[]byte{}, true}, {[]byte("\n \t"), true},
{[]byte("not empty"), false}, {[]byte{0}, false}, {bytes.Repeat([]byte{' '}, 4097), false},
} {
if got := isBlankArtSource(tc.data); got != tc.want {
t.Fatalf("isBlankArtSource(%q) = %v, want %v", tc.data, got, tc.want)
}
}
}