Files
sanspie 5eb1832907
ci-platforms / darwin amd64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 15m28s
ci / release (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 7m47s
Complete Stage 12 for v0.12.0
2026-11-05 11:46:53 +03:00

366 lines
14 KiB
Go

package document
import (
"bytes"
"os"
"path/filepath"
"testing"
"time"
"aego/asset"
"aego/core/ids"
)
func imageDocument(t *testing.T) (*Manager, *AssetDocument, []byte) {
t.Helper()
m := newManager(t)
if err := m.Handlers().Register(Handler{ID: "test.image", Kinds: []asset.ArtifactKind{asset.ArtTexture, asset.ArtSpriteRegion}, Capabilities: View | EditMetadata | Preview | RevealSource}); err != nil {
t.Fatal(err)
}
pixels := []byte{0x89, 'P', 'N', 'G', 0, 0xff}
if err := WriteAtomic(m.Abs("art/hero.png"), pixels); err != nil {
t.Fatal(err)
}
if err := WriteAtomic(m.Abs("art/hero.png.meta"), []byte("meta { semantic=color unknown=17 }\n")); err != nil {
t.Fatal(err)
}
d, err := m.OpenAsset(AssetRequest{Asset: ids.AssetID{Lo: 1}, Selected: ids.AssetID{Lo: 2}, Kind: asset.ArtSpriteRegion, Path: "art/hero.png", Subasset: "idle"})
if err != nil {
t.Fatal(err)
}
return m, d, pixels
}
func TestImageDocumentIdentityHistoryAndCapabilities(t *testing.T) {
m, d, pixels := imageDocument(t)
if d.Source != nil || d.Metadata == nil || d.Capabilities.Has(EditSource) || !d.Capabilities.Has(View|EditMetadata|Preview|RevealSource) {
t.Fatal("PNG authoring capabilities are not truthful")
}
if len(m.Scripts()) != 1 || m.Script("art/hero.png.meta") != d.Metadata {
t.Fatal("metadata has a separate private document")
}
sc := m.New("scenes/main.aescene", sampleScene(m, 1))
if err := m.Save(sc.ID); err != nil {
t.Fatal(err)
}
before := bytes.Clone(d.Metadata.Code.Bytes())
for i, v := range []string{"meta { semantic=color unknown=17 strength=1 }\n", "meta { semantic=color unknown=17 strength=2 }\n", "meta { semantic=color unknown=17 strength=3 }\n"} {
d.Metadata.Code.ReplaceSnapshot([]byte(v), i > 0)
}
other, err := m.OpenAsset(AssetRequest{Asset: d.Asset, Selected: ids.AssetID{Lo: 3}, Kind: asset.ArtSpriteRegion, Path: d.Path, Subasset: "run"})
if err != nil || other != d || other.Selected.Lo != 3 || other.Subasset != "run" || len(m.AssetDocuments()) != 1 {
t.Fatal("another region duplicated the sheet document", err)
}
if !d.Dirty() || !m.Dirty() || m.DirtyCount() != 1 || sc.Dirty() {
t.Fatal("metadata history dirtied the scene or is absent from lifecycle")
}
if !d.Metadata.Code.Undo() || !bytes.Equal(d.Metadata.Code.Bytes(), before) || d.Metadata.Code.CanUndo() {
t.Fatal("metadata drag is not a single undo step")
}
if !d.Metadata.Code.Redo() {
t.Fatal("redo")
}
d.View = struct{ Zoom float32 }{4}
if err := m.SaveAll(); err != nil {
t.Fatal(err)
}
if d.Dirty() || m.Dirty() {
t.Fatal("saved metadata stayed dirty")
}
if actual, _ := os.ReadFile(m.Abs(d.Path)); !bytes.Equal(actual, pixels) {
t.Fatal("metadata save changed source pixels")
}
if _, err := m.OpenScript(d.Path); err == nil {
t.Fatal("binary image opened as text")
}
if len(m.Scripts()) != 1 {
t.Fatal("binary attempted open leaked a document")
}
}
func TestStructuredAssetDiscardUsesExistingOwner(t *testing.T) {
m, d, _ := imageDocument(t)
dirty, discarded := true, false
d.Binding = AssetBinding{Dirty: func() bool { return dirty }, Discard: func() { dirty, discarded = false, true }}
if err := m.CloseAsset(d.Asset, false); err == nil || discarded {
t.Fatal("ordinary close discarded a structured editor journal")
}
if err := m.CloseAsset(d.Asset, true); err != nil || !discarded || dirty || m.AssetDocument(d.Asset) != nil {
t.Fatal("explicit Discard left the structured owner dirty", err)
}
}
func TestAssetMetadataConflictNeverSilentlyOverwrites(t *testing.T) {
m, d, _ := imageDocument(t)
s := d.Metadata
s.Code.ReplaceSnapshot([]byte("mine\n"), false)
before := bytes.Clone(s.Code.Bytes())
st, err := os.Stat(m.Abs(s.Path))
if err != nil {
t.Fatal(err)
}
external := []byte("disk { semantic=normal extra=23 }\n")
if err := os.WriteFile(m.Abs(s.Path), external, 0o644); err != nil {
t.Fatal(err)
}
// Saving checks content, not just a cached watcher timestamp.
if err := os.Chtimes(m.Abs(s.Path), st.ModTime(), st.ModTime()); err != nil {
t.Fatal(err)
}
if err := m.SaveAsset(d); err == nil || !s.Conflict {
t.Fatal("concurrent sidecar edit was overwritten")
}
if actual, _ := os.ReadFile(m.Abs(s.Path)); !bytes.Equal(actual, external) || !bytes.Equal(s.Code.Bytes(), before) {
t.Fatal("conflict lost disk or in-memory data")
}
m.KeepScript(s)
if s.Conflict {
t.Fatal("explicit Keep mine did not acknowledge conflict")
}
if err := m.SaveAsset(d); err != nil {
t.Fatal(err)
}
if actual, _ := os.ReadFile(m.Abs(s.Path)); !bytes.Equal(actual, before) {
t.Fatal("explicit resolution was not saved")
}
s.Code.ReplaceSnapshot([]byte("unsaved second change\n"), false)
touchLater(t, m.Abs(s.Path), []byte("external second change\n"))
_, conflicts := m.CheckExternalScripts()
if len(conflicts) != 1 || conflicts[0] != s || !s.Conflict {
t.Fatal("dirty external change did not surface conflict")
}
if err := m.ReloadScript(s); err != nil {
t.Fatal(err)
}
if s.Conflict || s.Dirty() || string(s.Code.Bytes()) != "external second change\n" {
t.Fatal("Reload did not adopt the disk version")
}
touchLater(t, m.Abs(s.Path), []byte("clean external change\n"))
// Ensure the polling timestamp differs even on coarse filesystems.
later := time.Now().Add(2 * time.Hour)
if err := os.Chtimes(m.Abs(s.Path), later, later); err != nil {
t.Fatal(err)
}
reloaded, conflicts := m.CheckExternalScripts()
if len(reloaded) != 1 || len(conflicts) != 0 || s.Dirty() || string(s.Code.Bytes()) != "clean external change\n" {
t.Fatal("clean sidecar did not reload in the shared model")
}
}
func TestImageDocumentRenameRecoveryAndClose(t *testing.T) {
m, d, _ := imageDocument(t)
s := d.Metadata
s.Code.ReplaceSnapshot([]byte("meta { unknown=19 }\n"), false)
if err := m.CloseAsset(d.Asset, false); err == nil {
t.Fatal("dirty image close accepted")
}
if n, err := m.WriteRecovery(); err != nil || n != 1 {
t.Fatal("shared recovery missing", n, err)
}
for _, suffix := range []string{"", ".meta"} {
if err := os.Rename(m.Abs("art/hero.png"+suffix), m.Abs("art/runner.png"+suffix)); err != nil {
t.Fatal(err)
}
}
m.Renamed("art/hero.png", "art/runner.png")
if d.Path != "art/runner.png" || s.Path != d.Path+".meta" || m.AssetDocument(d.Asset) != d || m.Script(s.Path) != s {
t.Fatal("rename split stable identity and source metadata")
}
if err := m.SaveAsset(d); err != nil {
t.Fatal(err)
}
if err := m.CloseAsset(d.Asset, false); err != nil {
t.Fatal(err)
}
if len(m.AssetDocuments()) != 0 || len(m.Scripts()) != 0 {
t.Fatal("closed image retained its document buffers")
}
entries, _ := os.ReadDir(filepath.Join(m.opt.Root, RecoveryDir))
if len(entries) != 0 {
t.Fatal("saved/closed metadata retained stale recovery")
}
}
func TestUnknownBinaryAndMissingMetadataAreHonestFallbacks(t *testing.T) {
m := newManager(t)
d, err := m.OpenAsset(AssetRequest{Asset: ids.AssetID{Lo: 99}, Path: "assets/music.ogg", Kind: asset.ArtAudio})
if err != nil || d.Handler != "asset.readonly" || d.Capabilities != View|RevealSource || d.Source != nil || d.Metadata != nil || len(m.Scripts()) != 0 {
t.Fatal("unknown binary was treated as editable text", err)
}
if err := m.Handlers().Register(Handler{ID: "image", Kinds: []asset.ArtifactKind{asset.ArtTexture}, Capabilities: View | EditMetadata | Preview | RevealSource}); err != nil {
t.Fatal(err)
}
d, err = m.OpenAsset(AssetRequest{Asset: ids.AssetID{Lo: 100}, Path: "assets/no-meta.png", Kind: asset.ArtTexture})
if err != nil || d.Metadata != nil || d.Capabilities.Has(EditMetadata) || d.Note == "" {
t.Fatal("missing metadata was silently invented", err)
}
if _, err := os.Stat(m.Abs("assets/no-meta.png.meta")); !os.IsNotExist(err) {
t.Fatal("opening an asset wrote metadata")
}
}
func TestDocumentHandlerSemanticRoleResolution(t *testing.T) {
var r HandlerRegistry
for _, h := range []Handler{
{ID: "image", Kinds: []asset.ArtifactKind{asset.ArtTexture}, Extensions: []string{".png"}, Capabilities: View | Preview},
{ID: "normal", Kinds: []asset.ArtifactKind{asset.ArtTexture}, Roles: []string{"normal"}, Capabilities: View | Preview},
} {
if err := r.Register(h); err != nil {
t.Fatal(err)
}
}
if h := r.Resolve(asset.ArtTexture, "stone.png", "normal"); h.ID != "normal" {
t.Fatal("normal image selected by extension rather than declared semantic role")
}
if h := r.Resolve(asset.ArtTexture, "stone.png", "color"); h.ID != "image" {
t.Fatal("color fallback")
}
if h := r.Resolve("", "stone.PNG", ""); h.ID != "image" {
t.Fatal("failed importer extension fallback")
}
r.Unregister("normal")
if h := r.Resolve(asset.ArtTexture, "stone.png", "normal"); h.ID != "image" {
t.Fatal("removed handler remained live")
}
if err := r.Register(Handler{ID: "duplicate", Kinds: []asset.ArtifactKind{asset.ArtTexture}, Capabilities: View}); err == nil {
t.Fatal("ambiguous kind registration accepted")
}
}
func TestAcquireAssetMovesDirtyMetadataByStableIDWithoutOldPathSave(t *testing.T) {
m, d, _ := imageDocument(t)
metadata := d.Metadata
metadata.Code.ReplaceSnapshot([]byte("meta { unknown=31 }\n"), false)
for _, suffix := range []string{"", ".meta"} {
if err := os.Rename(m.Abs("art/hero.png"+suffix), m.Abs("art/runner.png"+suffix)); err != nil {
t.Fatal(err)
}
}
// An external rename/index refresh has not called Manager.Renamed.
again, err := m.AcquireAsset(AssetRequest{Asset: d.Asset, Path: "art/runner.png", Kind: asset.ArtTexture})
if err != nil || again != d || again.Metadata != metadata || metadata.Path != "art/runner.png.meta" || m.Script("art/hero.png.meta") != nil {
t.Fatal("stable ID rename duplicated/lost the metadata journal", err)
}
if !metadata.Code.CanUndo() || !metadata.Dirty() {
t.Fatal("rename reset history or dirty state")
}
if err := m.SaveAsset(d); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(m.Abs("art/hero.png.meta")); !os.IsNotExist(err) {
t.Fatal("Save recreated the old sidecar path")
}
if raw, _ := os.ReadFile(m.Abs(metadata.Path)); string(raw) != "meta { unknown=31 }\n" {
t.Fatal("Save did not use the renamed source identity")
}
}
func TestAcquireAssetRenameCannotStealAnotherOpenDocument(t *testing.T) {
m, d, _ := imageDocument(t)
if err := WriteAtomic(m.Abs("art/target.png.meta"), []byte("other metadata\n")); err != nil {
t.Fatal(err)
}
other, err := m.OpenScript("art/target.png.meta")
if err != nil {
t.Fatal(err)
}
other.Code.ReplaceSnapshot([]byte("other unsaved metadata\n"), false)
if _, err := m.AcquireAsset(AssetRequest{Asset: d.Asset, Path: "art/target.png", Kind: asset.ArtTexture}); err == nil {
t.Fatal("rename silently replaced the destination's open journal")
}
if d.Path != "art/hero.png" || d.Metadata.Path != "art/hero.png.meta" || m.Script("art/target.png.meta") != other || !other.Dirty() {
t.Fatal("rejected rename changed document identities")
}
}
func TestAcquireAssetMovesSharedTextSourceByStableID(t *testing.T) {
m := newManager(t)
if err := m.Handlers().Register(Handler{ID: "script", Kinds: []asset.ArtifactKind{asset.ArtScript}, TextSource: true, Capabilities: View | EditSource}); err != nil {
t.Fatal(err)
}
if err := WriteAtomic(m.Abs("scripts/old.aes"), []byte("old source\n")); err != nil {
t.Fatal(err)
}
d, err := m.OpenAsset(AssetRequest{Asset: ids.AssetID{Lo: 901}, Kind: asset.ArtScript, Path: "scripts/old.aes"})
if err != nil {
t.Fatal(err)
}
code := d.Source
code.Code.ReplaceSnapshot([]byte("working source\n"), false)
if err := os.Rename(m.Abs(d.Path), m.Abs("scripts/new.aes")); err != nil {
t.Fatal(err)
}
if _, err := m.AcquireAsset(AssetRequest{Asset: d.Asset, Kind: asset.ArtScript, Path: "scripts/new.aes"}); err != nil {
t.Fatal(err)
}
if d.Source != code || code.Path != d.Path || m.Script(d.Path) != code {
t.Fatal("renamed source did not keep one Code document")
}
if err := m.SaveAsset(d); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(m.Abs("scripts/old.aes")); !os.IsNotExist(err) {
t.Fatal("renamed source saved to its old path")
}
}
func TestAssetAndCodeViewsRetainOneTextJournalAcrossEitherCloseOrder(t *testing.T) {
m := newManager(t)
if err := m.Handlers().Register(Handler{ID: "material", Kinds: []asset.ArtifactKind{asset.ArtMaterial}, TextSource: true, Capabilities: View | EditSource}); err != nil {
t.Fatal(err)
}
const path = "assets/shared.aemat"
if err := WriteAtomic(m.Abs(path), []byte("saved material\n")); err != nil {
t.Fatal(err)
}
req := AssetRequest{Asset: ids.AssetID{Lo: 905}, Kind: asset.ArtMaterial, Path: path}
d, err := m.OpenAsset(req)
if err != nil {
t.Fatal(err)
}
source := d.Source
release := source.RetainView()
source.Code.ReplaceSnapshot([]byte("working material\n"), false)
if err := m.CloseAsset(d.Asset, true); err != nil {
t.Fatal(err)
}
if m.Script(path) != source || source.Dirty() || string(source.Code.Bytes()) != "saved material\n" {
t.Fatal("discarding asset view removed the active Code model or retained hidden edits")
}
again, err := m.OpenAsset(req)
if err != nil || again.Source != source || len(m.Scripts()) != 1 {
t.Fatal("reopening asset view duplicated the Code document", err)
}
release()
release() // duplicate disposal cannot underflow ownership.
m.CloseScript(source)
if m.Script(path) != source {
t.Fatal("closing Code first removed the asset view's journal")
}
if err := m.CloseAsset(again.Asset, false); err != nil {
t.Fatal(err)
}
if len(m.Scripts()) != 0 {
t.Fatal("last view retained a historical text journal")
}
}
func TestClosedSceneDoesNotRetainItsMutableSceneInAssetIdentity(t *testing.T) {
m := newManager(t)
sc := m.New("scenes/shared.aescene", sampleScene(m, 1))
if err := m.Save(sc.ID); err != nil {
t.Fatal(err)
}
d, err := m.AcquireAsset(AssetRequest{Asset: ids.AssetID{Lo: 906}, Kind: asset.ArtScene, Path: sc.Path})
if err != nil {
t.Fatal(err)
}
d.Scene = sc
if err := m.Close(sc.ID, false); err != nil {
t.Fatal(err)
}
if d.Scene != nil || d.Dirty() {
t.Fatal("closed Scene remained reachable as a mutable asset document")
}
}