Files
aego/asset/loader/loader_test.go
2026-09-26 16:32:40 +03:00

313 lines
8.0 KiB
Go

package loader
import (
"testing"
"aego/asset"
"aego/core/ids"
"aego/core/log"
"aego/core/mathx"
"aego/gpu"
"aego/gpu/null"
"aego/render/texture"
"aego/vfs"
)
func newFS(t *testing.T, files map[string][]byte) vfs.FS {
t.Helper()
dir := t.TempDir()
fs, err := vfs.New(vfs.Mount{Scheme: "cache", Root: dir})
if err != nil {
t.Fatal(err)
}
for name, data := range files {
if err := fs.WriteFile("cache://"+name, data); err != nil {
t.Fatal(err)
}
}
return fs
}
func textureBytes(w, h int, c mathx.Color) []byte {
d := texture.Solid(w, h, c)
d.Premultiply()
return asset.EncodeTexture(&asset.TextureArtifact{
Width: w,
Height: h,
Format: gpu.TexSRGBA8,
Pixels: d.Pixels,
})
}
func newLoader(t *testing.T) (*Loader, *null.Device) {
t.Helper()
dev := null.New()
up := texture.NewUploader(dev)
l := New(dev, up, log.Nop())
t.Cleanup(l.Close)
return l, dev
}
func TestTextureLoadsLazily(t *testing.T) {
id := ids.NewAssetID()
fs := newFS(t, map[string][]byte{
"tex.aetex": textureBytes(8, 8, mathx.Color{R: 1, A: 1}),
})
idx := asset.NewIndex()
idx.Put(asset.Entry{
ID: id,
Kind: asset.ArtTexture,
Name: "tex",
Artifacts: []asset.Artifact{
{Kind: asset.ArtTexture, Path: "cache://tex.aetex"},
},
})
l, _ := newLoader(t)
if err := l.Mount(idx, fs); err != nil {
t.Fatal(err)
}
if l.Resident() != 0 {
t.Fatal("loader must not preload anything on mount")
}
reg, ok := l.Texture(id)
if !ok || reg.W != 8 || reg.H != 8 {
t.Fatalf("region = %+v ok=%v", reg, ok)
}
if l.Resident() != 1 {
t.Fatalf("resident = %d", l.Resident())
}
if _, ok := l.Texture(ids.NewAssetID()); ok {
t.Fatal("unknown asset must not resolve")
}
}
func TestSpriteRegionResolvesThroughPage(t *testing.T) {
page := ids.NewAssetID()
sprite := ids.NewAssetID()
fs := newFS(t, map[string][]byte{
"page.aetex": textureBytes(64, 64, mathx.Color{G: 1, A: 1}),
"spr.aereg": asset.EncodeRegion(&asset.RegionArtifact{
Page: page, X: 16, Y: 8, W: 32, H: 16, PageW: 64, PageH: 64,
}),
})
idx := asset.NewIndex()
idx.Put(asset.Entry{
ID: page, Kind: asset.ArtAtlasPage, Name: "page",
Artifacts: []asset.Artifact{{Kind: asset.ArtAtlasPage, Path: "cache://page.aetex"}},
})
idx.Put(asset.Entry{
ID: sprite, Kind: asset.ArtSpriteRegion, Name: "spr",
Artifacts: []asset.Artifact{{Kind: asset.ArtSpriteRegion, Path: "cache://spr.aereg"}},
})
l, _ := newLoader(t)
l.Mount(idx, fs)
reg, ok := l.Texture(sprite)
if !ok {
t.Fatal("sprite region did not resolve")
}
if reg.W != 32 || reg.H != 16 {
t.Fatalf("region size = %dx%d", reg.W, reg.H)
}
if mathx.Abs(reg.UVMin.X-0.25) > 1e-6 || mathx.Abs(reg.UVMin.Y-0.125) > 1e-6 {
t.Fatalf("uv min = %+v", reg.UVMin)
}
if mathx.Abs(reg.UVMax.X-0.75) > 1e-6 {
t.Fatalf("uv max = %+v", reg.UVMax)
}
}
func TestSwapIsAtomic(t *testing.T) {
a := ids.NewAssetID()
b := ids.NewAssetID()
fs := newFS(t, map[string][]byte{
"a.aetex": textureBytes(4, 4, mathx.Color{R: 1, A: 1}),
"b.aetex": textureBytes(4, 4, mathx.Color{B: 1, A: 1}),
})
idx := asset.NewIndex()
for _, e := range []asset.Entry{
{ID: a, Kind: asset.ArtTexture, Name: "a", Artifacts: []asset.Artifact{{Kind: asset.ArtTexture, Path: "cache://a.aetex"}}},
{ID: b, Kind: asset.ArtTexture, Name: "b", Artifacts: []asset.Artifact{{Kind: asset.ArtTexture, Path: "cache://b.aetex"}}},
} {
idx.Put(e)
}
l, _ := newLoader(t)
l.Mount(idx, fs)
before, _ := l.Texture(a)
genBefore := l.Generation()
err := l.Swap([]asset.Entry{
{ID: a, Kind: asset.ArtTexture, Name: "a", Artifacts: []asset.Artifact{{Kind: asset.ArtTexture, Path: "cache://a.aetex"}}},
{ID: b, Kind: asset.ArtTexture, Name: "b", Artifacts: []asset.Artifact{{Kind: asset.ArtTexture, Path: "cache://missing.aetex"}}},
}, nil)
if err == nil {
t.Fatal("swap with a missing artifact must fail")
}
after, ok := l.Texture(a)
if !ok || after != before {
t.Fatal("failed swap must leave the old resource in place")
}
if l.Generation() != genBefore {
t.Fatal("failed swap must not bump the generation")
}
}
func TestSwapNotifiesOnce(t *testing.T) {
id := ids.NewAssetID()
fs := newFS(t, map[string][]byte{
"a.aetex": textureBytes(4, 4, mathx.Color{R: 1, A: 1}),
})
idx := asset.NewIndex()
entry := asset.Entry{
ID: id, Kind: asset.ArtTexture, Name: "a",
Artifacts: []asset.Artifact{{Kind: asset.ArtTexture, Path: "cache://a.aetex"}},
}
idx.Put(entry)
l, _ := newLoader(t)
l.Mount(idx, fs)
l.Texture(id)
count := 0
stop := l.Subscribe(func(r asset.Reloaded) {
if r.ID != id {
t.Fatal("wrong asset reported")
}
count++
})
gen := l.Generation()
if err := l.Swap([]asset.Entry{entry}, nil); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("notified %d times", count)
}
if l.Generation() == gen {
t.Fatal("successful swap must bump the generation")
}
stop()
l.Swap([]asset.Entry{entry}, nil)
if count != 1 {
t.Fatal("unsubscribe failed")
}
}
func TestRefCachesUntilGenerationChanges(t *testing.T) {
id := ids.NewAssetID()
fs := newFS(t, map[string][]byte{
"a.aetex": textureBytes(4, 4, mathx.Color{R: 1, A: 1}),
})
idx := asset.NewIndex()
entry := asset.Entry{
ID: id, Kind: asset.ArtTexture, Name: "a",
Artifacts: []asset.Artifact{{Kind: asset.ArtTexture, Path: "cache://a.aetex"}},
}
idx.Put(entry)
l, _ := newLoader(t)
l.Mount(idx, fs)
ref := asset.NewRef[asset.Texture](id)
first, ok := ref.Region(l)
if !ok {
t.Fatal("ref did not resolve")
}
second, _ := ref.Region(l)
if first != second {
t.Fatal("cached region changed without a swap")
}
if err := l.Swap([]asset.Entry{entry}, nil); err != nil {
t.Fatal(err)
}
third, ok := ref.Region(l)
if !ok {
t.Fatal("ref lost the asset after a swap")
}
if third.Texture == first.Texture {
t.Fatal("swap must give the ref a new texture handle")
}
}
func TestBrokenArtifactFailsOnceAndIsQuiet(t *testing.T) {
id := ids.NewAssetID()
fs := newFS(t, map[string][]byte{"bad.aetex": []byte("not a texture")})
idx := asset.NewIndex()
idx.Put(asset.Entry{
ID: id, Kind: asset.ArtTexture, Name: "bad",
Artifacts: []asset.Artifact{{Kind: asset.ArtTexture, Path: "cache://bad.aetex"}},
})
sink := &log.MemorySink{}
lg := log.New(sink)
dev := null.New()
l := New(dev, texture.NewUploader(dev), lg)
defer l.Close()
l.Mount(idx, fs)
for i := 0; i < 5; i++ {
if _, ok := l.Texture(id); ok {
t.Fatal("a broken artifact must not resolve")
}
}
if n := len(sink.Records()); n != 1 {
t.Fatalf("a broken asset must be reported once, got %d records", n)
}
}
func TestSceneBlob(t *testing.T) {
id := ids.NewAssetID()
fs := newFS(t, map[string][]byte{"main.aescene": []byte("scene version=1 {}\n")})
idx := asset.NewIndex()
idx.Put(asset.Entry{
ID: id, Kind: asset.ArtScene, Name: "main",
Artifacts: []asset.Artifact{{Kind: asset.ArtScene, Path: "cache://main.aescene"}},
})
l, _ := newLoader(t)
l.Mount(idx, fs)
data, ok := l.Scene(id)
if !ok || len(data) == 0 {
t.Fatal("scene blob did not load")
}
if _, ok := l.Prefab(id); ok {
t.Fatal("a scene must not resolve as a prefab")
}
}
func TestMountRequiresBoth(t *testing.T) {
l, _ := newLoader(t)
if err := l.Mount(nil, nil); err == nil {
t.Fatal("mount without an index must fail")
}
}
func TestSwapSkipsUnchangedBlobs(t *testing.T) {
id := ids.NewAssetID()
fs := newFS(t, map[string][]byte{"main.aescene": []byte("scene version=1 {}\n")})
idx := asset.NewIndex()
entry := asset.Entry{
ID: id, Kind: asset.ArtScene, Name: "main",
Artifacts: []asset.Artifact{{Kind: asset.ArtScene, Path: "cache://main.aescene"}},
}
idx.Put(entry)
l, _ := newLoader(t)
l.Mount(idx, fs)
l.Scene(id)
count := 0
l.Subscribe(func(asset.Reloaded) { count++ })
if err := l.Swap([]asset.Entry{entry}, nil); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatal("an unchanged scene artifact must not be reported as reloaded")
}
fs.WriteFile("cache://main.aescene", []byte("scene version=1 {\n node \"x\" {}\n}\n"))
if err := l.Swap([]asset.Entry{entry}, nil); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("a changed scene must be reported once, got %d", count)
}
}
var _ = gpu.TexSRGBA8