Files
aego/modules/render2d/editor/art_png_draft_test.go

343 lines
14 KiB
Go

package editor
import (
"bytes"
stdctx "context"
"errors"
"image"
"os"
"path/filepath"
"strings"
"testing"
"aego/art"
"aego/asset/importers/artsource"
"aego/core/ids"
"aego/editor/core/artedit"
"aego/editor/core/command"
"aego/editor/core/context"
"aego/editor/core/document"
"aego/editor/core/workspace"
"aego/editor/ext"
"aego/ui/core"
)
// These scenarios are deliberately retained for the deferred test pass; they
// are not evidence that the current mixed checkout or native UI has passed.
func TestPNGOpenIsInMemoryAndFirstSaveKeepsHistoryAndIDs(t *testing.T) {
ed, source, frames, original := linkedArtEditor(t)
metaBefore, err := ed.Pipeline.SourceBytes("assets/linked.png.meta")
if err != nil {
t.Fatal(err)
}
doc, err := ed.OpenAsset(frames[1])
if err != nil {
t.Fatal(err)
}
model, ok := doc.Binding.Model.(*artedit.Document)
if !ok || !model.Draft || doc.Asset != source || len(model.Session.Document.Info().Frames) != 2 || len(ed.TakeQuestions()) != 0 {
t.Fatal("PNG did not open directly as one in-memory sliced drawing")
}
before, _, err := model.Capture(model.Session.Frame, art.Albedo)
if err != nil || before.NRGBAAt(0, 0).R != 100 {
t.Fatalf("selected sliced frame lost its original pixels: %v", err)
}
if got, _ := ed.Pipeline.SourceBytes("assets/linked.png.meta"); !bytes.Equal(got, metaBefore) {
t.Fatal("opening PNG changed its metadata")
}
if _, ok := ed.Pipeline.Lookup("assets/linked.aeart"); ok {
t.Fatal("opening PNG created an editable source")
}
if _, err := model.Execute(stdctx.Background(), art.Operation{Kind: "stroke", Points: []image.Point{{1, 1}}, Brush: art.Brush{Size: 1, Paint: art.Paint{Color: art.Color{R: 255, B: 255, A: 255}}}}); err != nil {
t.Fatal(err)
}
if err := ed.SaveAsset(doc); !errors.Is(err, document.ErrSavePending) {
t.Fatalf("first Save did not wait for an explicit destination: %v", err)
}
questions := ed.TakeQuestions()
if len(questions) != 1 || questions[0].Submit == nil {
t.Fatal("first Save did not present its source path and metadata warning")
}
questions[0].Submit([]string{"assets/linked.aeart"})
if notice, ok := ed.LastNotice(); ok && notice.Level == context.LevelError {
t.Fatal(notice.Text)
}
id, ok := ed.Pipeline.Lookup("assets/linked.aeart")
if !ok {
t.Fatal("confirmed source was not indexed")
}
saved := ed.Docs.AssetDocument(id)
if saved == nil || saved.Binding.Model != model || model.Draft || saved.Dirty() || model.History.Cursor() != 1 {
t.Fatal("first Save did not transfer the same clean session and history")
}
meta, _ := ed.Pipeline.MetaOf(source)
link, err := artsource.TextureLinkOf(&meta)
if err != nil || link == nil || link.Source != id || len(link.Frames) != 2 || link.Frames[1].Asset != frames[1] {
t.Fatalf("first Save lost original frame identities: %v", err)
}
if png, _ := ed.Pipeline.SourceBytes("assets/linked.png"); !bytes.Equal(png, original) {
t.Fatal("first Save overwrote the original PNG")
}
if err := model.SessionUndo(); err != nil || !saved.Dirty() {
t.Fatal("Undo after first Save lost the pre-save history", err)
}
if err := model.SessionRedo(); err != nil || saved.Dirty() {
t.Fatal("Redo did not restore the saved generation", err)
}
if _, err := ed.OpenAsset(frames[0]); err != nil || model.Session.Frame != link.Frames[0].Frame || saved.Binding.Model != model {
t.Fatal("reopening the PNG did not focus its canonical linked document", err)
}
}
// Exercises the reported sequence without touching a real project: first save
// of an imported PNG, a later normal save, then Save As to the same source.
func TestPNGFirstSaveThenSaveAsCurrentSourceRequiresReplace(t *testing.T) {
ed, _, frames, original := linkedArtEditor(t)
doc, err := ed.OpenAsset(frames[1])
if err != nil {
t.Fatal(err)
}
model := doc.Binding.Model.(*artedit.Document)
paint := func(x int) {
t.Helper()
_, err := model.Execute(stdctx.Background(), art.Operation{Kind: "stroke", Points: []image.Point{{X: x % 2, Y: 1}}, Brush: art.Brush{Size: 1, Paint: art.Paint{Color: art.Color{G: uint8(160 + x*30), A: 255}}}})
if err != nil {
t.Fatal(err)
}
}
paint(0)
if err := ed.SaveAsset(doc); !errors.Is(err, document.ErrSavePending) {
t.Fatalf("first Save did not request a destination: %v", err)
}
first := ed.TakeQuestions()
if len(first) != 1 || first[0].Submit == nil {
t.Fatal("missing first-save prompt")
}
first[0].Submit([]string{"./assets/linked.aeart"})
id, ok := ed.Pipeline.Lookup("assets/linked.aeart")
if !ok {
t.Fatal("first Save did not index the linked source")
}
saved := ed.Docs.AssetDocument(id)
if saved == nil || saved.Binding.Model != model || model.Draft {
t.Fatal("first Save did not retain the canonical model")
}
paint(1)
if err := ed.SaveAsset(saved); err != nil {
t.Fatalf("normal subsequent Save failed: %v", err)
}
paint(2)
cursor := model.History.Cursor()
panel := &artPanel{ctx: ed, doc: saved, model: model, view: &artView{}}
panel.askSaveAs()
q := ed.TakeQuestions()
if len(q) != 1 || q[0].DestructiveWhen == nil || !q[0].DestructiveWhen([]string{"./assets/linked.aeart"}) {
t.Fatal("Save As did not show Replace for the current source")
}
before, err := ed.Pipeline.SourceBytes("assets/linked.aeart")
if err != nil {
t.Fatal(err)
}
q[0].Submit([]string{"./assets/linked.aeart"})
confirm := ed.TakeQuestions()
if len(confirm) != 1 || confirm[0].Then == nil {
t.Fatal("current source replacement did not request confirmation")
}
if got, _ := ed.Pipeline.SourceBytes("assets/linked.aeart"); !bytes.Equal(got, before) || !saved.Dirty() {
t.Fatal("the current source was saved before confirmation")
}
confirm[0].Then(0)
if panel.err != nil || saved.Dirty() || saved.Binding.Model != model || model.History.Cursor() != cursor {
t.Fatal("confirmed Save As lost its source generation or history", panel.err)
}
if png, _ := ed.Pipeline.SourceBytes("assets/linked.png"); !bytes.Equal(png, original) {
t.Fatal("subsequent saves changed the original PNG")
}
}
func TestPNGFirstSaveRejectsExternalChangeAndKeepsDirtyPixels(t *testing.T) {
for _, changed := range []string{"assets/linked.png", "assets/linked.png.meta", "assets/linked.aeart"} {
t.Run(changed, func(t *testing.T) {
ed, _, frames, _ := linkedArtEditor(t)
doc, err := ed.OpenAsset(frames[1])
if err != nil {
t.Fatal(err)
}
model := doc.Binding.Model.(*artedit.Document)
if _, err := model.Execute(stdctx.Background(), art.Operation{Kind: "stroke", Points: []image.Point{{1, 1}}, Brush: art.Brush{Size: 1, Paint: art.Paint{Color: art.Color{G: 255, A: 255}}}}); err != nil {
t.Fatal(err)
}
if err := ed.SaveAsset(doc); !errors.Is(err, document.ErrSavePending) {
t.Fatal(err)
}
prompt := ed.TakeQuestions()[0]
foreign := []byte("external owner bytes")
if err := os.WriteFile(filepath.Join(ed.Project.Root, filepath.FromSlash(changed)), foreign, 0600); err != nil {
t.Fatal(err)
}
prompt.Submit([]string{"assets/linked.aeart"})
if notice, ok := ed.LastNotice(); !ok || notice.Level != context.LevelError {
t.Fatal("concurrent change was not reported")
}
if got, err := os.ReadFile(filepath.Join(ed.Project.Root, filepath.FromSlash(changed))); err != nil || !bytes.Equal(got, foreign) {
t.Fatal("first Save overwrote the external change", err)
}
if doc.Binding.Model != model || !doc.Dirty() || model.History.Cursor() != 1 {
t.Fatal("failed Save lost the dirty session or history")
}
})
}
}
func TestPNGDraftNeverSubstitutesAnotherFrameForUnknownAsset(t *testing.T) {
ed, source, frames, _ := linkedArtEditor(t)
doc, err := ed.OpenAsset(frames[1])
if err != nil {
t.Fatal(err)
}
model := doc.Binding.Model.(*artedit.Document)
info := model.Session.Document.Info()
draft := &pngArtDraft{doc: doc, model: model, frames: []artsource.TextureLinkFrame{
{Asset: frames[0], Frame: info.Frames[0].ID},
{Asset: frames[1], Frame: info.Frames[1].ID},
}}
selected := model.Session.Frame
if err := draft.selectFrame(ids.NewAssetID()); err == nil || model.Session.Frame != selected {
t.Fatal("unknown frame silently switched to a different sprite", err)
}
if err := draft.selectFrame(source); err != nil || model.Session.Frame != info.Frames[0].ID {
t.Fatal("opening the sheet itself did not select its first frame", err)
}
if err := draft.selectFrame(frames[1]); err != nil || model.Session.Frame != selected {
t.Fatal("the original frame identity no longer selects its own pixels", err)
}
}
// A new manager represents the next editor process after an interrupted run.
// It shares the fixture pipeline only so the test does not alter real projects.
func nextPNGEditor(t *testing.T, old *context.Editor) *context.Editor {
t.Helper()
commands := command.New()
ed := &context.Editor{Project: old.Project, Pipeline: old.Pipeline, Docs: document.NewManager(document.Options{Root: old.Project.Root}), Commands: commands, UI: core.New(nil)}
registry := ext.New(ext.Deps{Editor: ed, Workspace: workspace.New(old.Project.Root, nil), Commands: commands})
if err := registry.Run("render2d", imageHooks); err != nil {
t.Fatal(err)
}
return ed
}
func makePendingSourceJournal(t *testing.T, root string) (string, string) {
t.Helper()
journal := filepath.Join(root, ".aego", "source-transactions", "active")
if err := os.MkdirAll(journal, 0o700); err != nil {
t.Fatal(err)
}
marker := filepath.Join(journal, "unresolved")
if err := os.WriteFile(marker, []byte("preserve this journal"), 0o600); err != nil {
t.Fatal(err)
}
return journal, marker
}
func TestPNGOpenRejectsPendingSourceJournal(t *testing.T) {
ed, _, frames, original := linkedArtEditor(t)
meta, err := ed.Pipeline.SourceBytes("assets/linked.png.meta")
if err != nil {
t.Fatal(err)
}
_, marker := makePendingSourceJournal(t, ed.Project.Root)
doc, err := ed.OpenAsset(frames[0])
if err == nil || !strings.Contains(err.Error(), "source transaction") {
t.Fatalf("pending journal did not block the in-memory import: %v", err)
}
if doc != nil && doc.Binding.Model != nil {
t.Fatal("a model was bound from potentially incomplete source inputs")
}
for name, want := range map[string][]byte{"assets/linked.png": original, "assets/linked.png.meta": meta} {
if got, err := ed.Pipeline.SourceBytes(name); err != nil || !bytes.Equal(got, want) {
t.Fatalf("opening changed %s: %v", name, err)
}
}
if got, err := os.ReadFile(marker); err != nil || string(got) != "preserve this journal" {
t.Fatalf("opening altered the source journal: %q, %v", got, err)
}
}
func TestPNGRecoveryAndFirstSaveRespectPendingSourceJournal(t *testing.T) {
ed, source, frames, original := linkedArtEditor(t)
doc, err := ed.OpenAsset(frames[1])
if err != nil {
t.Fatal(err)
}
model := doc.Binding.Model.(*artedit.Document)
frameID := model.Session.Frame
if _, err := model.Execute(stdctx.Background(), art.Operation{Kind: "stroke", Points: []image.Point{{1, 1}}, Brush: art.Brush{Size: 1, Paint: art.Paint{Color: art.Color{G: 255, A: 255}}}}); err != nil {
t.Fatal(err)
}
if err := ed.Docs.WriteAssetRecovery(doc); err != nil {
t.Fatal(err)
}
recoveryPath := filepath.Join(ed.Project.Root, document.RecoveryDir, "asset-"+source.String()+".recovery")
recovery, err := os.ReadFile(recoveryPath)
if err != nil {
t.Fatal(err)
}
if err := ed.SaveAsset(doc); !errors.Is(err, document.ErrSavePending) {
t.Fatal("first save did not request confirmation", err)
}
questions := ed.TakeQuestions()
if len(questions) != 1 || questions[0].Submit == nil {
t.Fatal("missing first-save prompt")
}
next := nextPNGEditor(t, ed)
reopened, err := next.OpenAsset(frames[1])
if err != nil {
t.Fatal(err)
}
recovered := reopened.Binding.Model.(*artedit.Document)
if !recovered.PendingRecovery {
t.Fatal("the existing recovery was not offered explicitly")
}
journal, marker := makePendingSourceJournal(t, ed.Project.Root)
if err := recovered.Recover(); err == nil || !strings.Contains(err.Error(), "source transaction") {
t.Fatalf("recovery was allowed while the source journal was pending: %v", err)
}
questions[0].Submit([]string{"assets/linked.aeart"})
if notice, ok := ed.LastNotice(); !ok || notice.Level != context.LevelError {
t.Fatal("a delayed save did not report the pending journal")
}
if !doc.Dirty() || doc.Binding.Model != model || model.History.Cursor() != 1 || !recovered.PendingRecovery {
t.Fatal("the rejected actions lost unsaved pixels, history, or the recovery decision")
}
if got, err := os.ReadFile(recoveryPath); err != nil || !bytes.Equal(got, recovery) {
t.Fatal("the rejected actions changed the recovery file", err)
}
if got, err := ed.Pipeline.SourceBytes("assets/linked.png"); err != nil || !bytes.Equal(got, original) {
t.Fatal("the rejected actions changed the original PNG", err)
}
if got, err := ed.Pipeline.SourceBytes("assets/linked.aeart"); err != nil || got != nil {
t.Fatal("the rejected save created an art source", err)
}
if got, err := os.ReadFile(marker); err != nil || string(got) != "preserve this journal" {
t.Fatal("the rejected actions changed the journal", err)
}
// This test-created fixture is resolved explicitly; real recovery is never
// removed or applied automatically by the editor.
if err := os.Remove(marker); err != nil {
t.Fatal(err)
}
if err := os.Remove(journal); err != nil {
t.Fatal(err)
}
if err := recovered.Recover(); err != nil || !reopened.Dirty() || recovered.PendingRecovery {
t.Fatalf("explicit recovery failed after resolving the journal: %v", err)
}
if recovered.Session.Frame != frameID {
t.Fatal("recovery lost the selected sliced frame")
}
pixels, _, err := recovered.Capture(frameID, art.Albedo)
if err != nil || pixels.NRGBAAt(1, 1).G != 255 {
t.Fatalf("recovery did not retain the edited pixels: %v", err)
}
}