Files
aego/engine/engine_test.go
T
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

337 lines
8.4 KiB
Go

package engine
import (
"testing"
"aego/core/errs"
"aego/core/log"
"aego/platform"
"aego/platform/headless"
"aego/platform/input"
)
type testApp struct {
e *Engine
fixed int
updates int
renders int
events int
aDown bool
aUp bool
}
func (a *testApp) Init(e *Engine) error {
a.e = e
return nil
}
func (a *testApp) Event(platform.Event) {
a.events++
}
func (a *testApp) FixedUpdate(float64) {
a.fixed++
}
func (a *testApp) Update(float64) {
a.updates++
a.aDown = a.e.Input().KeyDown(input.KeyA)
a.aUp = a.e.Input().KeyReleased(input.KeyA)
}
func (a *testApp) Render(float64) {
a.renders++
}
func (a *testApp) Shutdown() {}
func newTestEngine(t *testing.T) (*Engine, *headless.Platform) {
t.Helper()
hp := headless.New(headless.Options{FrameTime: 1.0 / 60})
e, err := NewBuilder(Config{
Headless: true,
HeadlessFrameTime: 1.0 / 60,
ResRoot: t.TempDir(),
UserRoot: t.TempDir(),
AppID: "aego-test",
}).WithLogger(log.Nop()).WithPlatform(hp).Build()
if err != nil {
t.Fatal(err)
}
return e, hp
}
type resumableClock struct {
*headless.Platform
now float64
reset bool
consumes int
}
func (c *resumableClock) Now() float64 { return c.now }
func (c *resumableClock) ConsumeFrameClockReset() bool {
c.consumes++
reset := c.reset
c.reset = false
return reset
}
func TestResumeFrameRebasesOnlyOrdinaryClock(t *testing.T) {
for _, mode := range []string{"wall", "lockstep", "fixed frame"} {
t.Run(mode, func(t *testing.T) {
p := &resumableClock{Platform: headless.New(headless.Options{})}
cfg := Config{Headless: true, ResRoot: t.TempDir(), UserRoot: t.TempDir()}
const dt = 1.0 / 60
if mode == "fixed frame" {
cfg.FixedFrameTime = dt
}
e, err := NewBuilder(cfg).WithLogger(log.Nop()).WithPlatform(p).Build()
if err != nil {
t.Fatal(err)
}
defer e.Close()
if e.clockReset != p || p.consumes != 0 {
t.Fatal("backend reset capability was not cached without consuming a reset")
}
e.SetLockstep(mode == "lockstep")
app := &testApp{}
e.Step(app)
p.now += dt
e.Step(app)
before := app.fixed
p.reset = true
p.now += 2.5
p.Push(platform.Event{Kind: platform.EvKeyDown, Key: input.KeyEvent{Scan: input.KeyA}})
e.Step(app)
want := before
if mode != "wall" {
want++
}
if app.fixed != want || !app.aDown || e.MainWorld().Time.ClampedTime() != 0 || p.reset || p.consumes != 3 {
t.Fatalf("after resume: fixed=%d, want=%d, key=%v, clamped=%v", app.fixed, want, app.aDown, e.MainWorld().Time.ClampedTime())
}
p.now += dt
e.Step(app)
if app.fixed != want+1 || p.consumes != 4 {
t.Fatalf("steady frame after resume: fixed=%d, want=%d, reset calls=%d", app.fixed, want+1, p.consumes)
}
p.reset = true
p.now += 2.5
e.Step(app)
wantAgain := want + 1
if mode != "wall" {
wantAgain++
}
if app.fixed != wantAgain || p.reset || p.consumes != 5 || e.MainWorld().Time.ClampedTime() != 0 {
t.Fatalf("second resume: fixed=%d, want=%d, reset calls=%d", app.fixed, wantAgain, p.consumes)
}
})
}
}
func TestFrameClockResetCapabilityIsPerBackend(t *testing.T) {
plain, _ := newTestEngine(t)
defer plain.Close()
if plain.clockReset != nil {
t.Fatal("headless platform unexpectedly has a reset capability")
}
app := &testApp{}
if !plain.Step(app) {
t.Fatal("platform without a reset capability stopped on the first frame")
}
if !plain.Step(app) {
t.Fatal("platform without a reset capability stopped on the second frame")
}
p := &resumableClock{Platform: headless.New(headless.Options{})}
cfg := Config{Headless: true, ResRoot: t.TempDir(), UserRoot: t.TempDir()}
withReset, err := NewBuilder(cfg).WithLogger(log.Nop()).WithPlatform(p).Build()
if err != nil {
t.Fatal(err)
}
defer withReset.Close()
if withReset.clockReset != p || plain.clockReset != nil {
t.Fatal("different engines did not retain their own reset capability")
}
p.reset = true
if !withReset.Step(&testApp{}) || p.reset || p.consumes != 1 {
t.Fatalf("reset was not consumed once by the correct platform: pending=%v calls=%d", p.reset, p.consumes)
}
if !plain.Step(app) || plain.clockReset != nil || p.consumes != 1 {
t.Fatal("an engine used another backend's reset capability")
}
}
func TestFixedStepCount(t *testing.T) {
e, _ := newTestEngine(t)
defer e.Close()
app := &testApp{}
for i := 0; i < 120; i++ {
if !e.Step(app) {
t.Fatalf("stopped at frame %d: %v", i, e.Err())
}
}
if app.updates != 120 || app.renders != 120 {
t.Fatalf("updates=%d renders=%d", app.updates, app.renders)
}
if app.fixed < 118 || app.fixed > 120 {
t.Fatalf("fixed=%d", app.fixed)
}
if e.Stats().DrawCalls != 0 {
t.Fatalf("null device reported draws: %d", e.Stats().DrawCalls)
}
}
func TestEventsAndFocusReset(t *testing.T) {
e, hp := newTestEngine(t)
defer e.Close()
app := &testApp{}
e.Step(app)
hp.Push(platform.Event{Kind: platform.EvKeyDown, Key: input.KeyEvent{Scan: input.KeyA}})
e.Step(app)
if !app.aDown {
t.Fatal("key down not visible in update")
}
e.Step(app)
if !app.aDown {
t.Fatal("key must stay down across frames")
}
hp.Push(platform.Event{Kind: platform.EvWindowFocusLost})
e.Step(app)
if app.aDown || !app.aUp {
t.Fatalf("focus loss did not reset input: down=%v released=%v", app.aDown, app.aUp)
}
if app.events == 0 {
t.Fatal("app did not receive events")
}
}
func TestQuitEvent(t *testing.T) {
e, hp := newTestEngine(t)
defer e.Close()
app := &testApp{}
e.Step(app)
hp.Push(platform.Event{Kind: platform.EvQuit})
if e.Step(app) {
t.Fatal("engine kept running after quit")
}
}
func TestResizeUpdatesMetrics(t *testing.T) {
e, hp := newTestEngine(t)
defer e.Close()
app := &testApp{}
e.Step(app)
hp.Push(platform.Event{
Kind: platform.EvWindowResized,
Window: platform.WindowMetrics{
LogicalW: 640, LogicalH: 480,
PixelW: 1280, PixelH: 960,
PixelDensity: 2, DisplayScale: 2,
},
})
e.Step(app)
if m := e.Metrics(); m.PixelW != 1280 || m.LogicalW != 640 || m.PixelDensity != 2 {
t.Fatalf("metrics = %+v", m)
}
}
func TestJobsOnMain(t *testing.T) {
e, _ := newTestEngine(t)
defer e.Close()
app := &testApp{}
e.Step(app)
done := make(chan struct{})
ran := 0
e.Jobs().Submit(func() {
e.Jobs().OnMain(func() { ran++ })
close(done)
})
<-done
e.Step(app)
if ran != 1 {
t.Fatalf("main queue ran %d times", ran)
}
}
type testModule struct {
id string
log *[]string
initErr error
gpuOK bool
}
func (m *testModule) Info() ModuleInfo { return ModuleInfo{ID: m.id, Version: "1"} }
func (m *testModule) Register(*Registry) error {
*m.log = append(*m.log, "register:"+m.id)
return nil
}
func (m *testModule) Init(e *Engine) error {
*m.log = append(*m.log, "init:"+m.id)
m.gpuOK = e.GPU() != nil && e.VFS() != nil && e.Jobs() != nil
return m.initErr
}
// bareModule has no Init; Build must skip it rather than fail.
type bareModule struct{ log *[]string }
func (bareModule) Info() ModuleInfo { return ModuleInfo{ID: "bare", Version: "1"} }
func (m bareModule) Register(*Registry) error {
*m.log = append(*m.log, "register:bare")
return nil
}
func buildWithModules(t *testing.T, mods ...Module) (*Engine, error) {
t.Helper()
b := NewBuilder(Config{
Headless: true, HeadlessFrameTime: 1.0 / 60,
ResRoot: t.TempDir(), UserRoot: t.TempDir(), AppID: "aego-test",
}).WithLogger(log.Nop()).WithPlatform(headless.New(headless.Options{FrameTime: 1.0 / 60}))
for _, m := range mods {
b.Use(m)
}
return b.Build()
}
func TestModuleInitRunsAfterBackends(t *testing.T) {
var order []string
a := &testModule{id: "a", log: &order}
c := &testModule{id: "c", log: &order}
e, err := buildWithModules(t, a, bareModule{log: &order}, c)
if err != nil {
t.Fatal(err)
}
defer e.Close()
want := []string{"register:a", "register:bare", "register:c", "init:a", "init:c"}
if len(order) != len(want) {
t.Fatalf("order = %v, want %v", order, want)
}
for i := range want {
if order[i] != want[i] {
t.Fatalf("order = %v, want %v", order, want)
}
}
if !a.gpuOK || !c.gpuOK {
t.Fatal("Init ran before the device/vfs/jobs were available")
}
}
func TestModuleInitErrorFailsBuild(t *testing.T) {
var order []string
bad := &testModule{id: "bad", log: &order, initErr: errBoom}
e, err := buildWithModules(t, bad)
if err == nil {
e.Close()
t.Fatal("Build must fail when a module Init fails")
}
if !errs.Has(err, errs.EngineModule) {
t.Fatalf("want EngineModule code, got %v", err)
}
}
var errBoom = errs.New(errs.GPUShaderCompile, "boom")