Files
aego/modules/particles/editor/inspector_test.go
sanspie 5dd9cc91bb editor: refine agent, scene controls and curves
Improve mentions and the default Design workspace, clarify scene lighting controls, and polish editor workflows for 0.12.5-dev.
2026-11-05 20:14:53 +03:00

348 lines
11 KiB
Go

package editor
import (
"slices"
"testing"
"aego/core/curve"
"aego/core/ids"
"aego/core/mathx"
"aego/editor/core/context"
"aego/editor/core/document"
"aego/editor/core/gizmo"
"aego/engine/base"
"aego/engine/module"
"aego/modules/particles"
"aego/platform/input"
"aego/scene"
"aego/schema"
"aego/ui/core"
"aego/ui/widgets"
)
type renderSchemaFixture struct{}
func (renderSchemaFixture) Info() module.Info { return module.Info{ID: "render2d"} }
func (renderSchemaFixture) Register(*module.Registry) error { return nil }
func inspectorFixture(t *testing.T) (*document.SceneDocument, scene.NodeHandle, *schema.TypeDescriptor) {
t.Helper()
types, err := base.NewTypes()
if err != nil {
t.Fatal(err)
}
if _, err := module.Load(types, nil, renderSchemaFixture{}, particles.New()); err != nil {
t.Fatal(err)
}
if err := types.Schema().Freeze(); err != nil {
t.Fatal(err)
}
s := scene.New(types, nil, scene.Options{})
h := s.Create(s.Root(), "Dust")
*scene.Add[particles.ParticleSystem2D](s, h) = particles.DefaultParticleSystem2D()
s.Flush()
d := document.NewManager(document.Options{Types: types}).New("dust.aescene", s)
td, _ := types.Schema().ByName(particles.NameSystem)
return d, h, td
}
func TestParticleInspectorFieldsInOneGroup(t *testing.T) {
_, _, td := inspectorFixture(t)
for _, f := range td.Fields {
n := 0
for _, g := range groups {
if slices.Contains(g.fields, f.Name) {
n++
}
}
if n != 1 {
t.Errorf("field %s occurs in %d sections", f.Name, n)
}
}
}
func TestParticleNonnegativeFieldsClampMouseDrags(t *testing.T) {
_, _, td := inspectorFixture(t)
for _, name := range []string{"prewarm", "maskThreshold", "frameFPS", "reducedRate", "drag", "noiseStrength", "noiseFrequency", "rate", "duration", "radius", "rectSize", "lifetime", "speed", "size", "spread", "bursts"} {
fd, ok := td.Field(name)
if !ok || !fd.HasMin || fd.Min != 0 {
t.Errorf("%s does not declare a zero minimum", name)
}
}
for _, name := range []string{"gravity", "offset", "direction", "rotation", "rotationSpeed", "killY", "planeDistance"} {
fd, ok := td.Field(name)
if !ok || fd.HasMin {
t.Errorf("%s must remain signed", name)
}
}
fd, _ := td.Field("drag")
ui, in := core.New(nil), &input.State{}
v := float32(1)
r := mathx.Rect{X: 10, Y: 10, W: 180, H: 20}
last := mathx.Vec2{}
step := func(at mathx.Vec2, press, release bool) {
in.BeginFrame()
in.ApplyMouseMove(at, at.Sub(last))
last = at
if press {
in.ApplyMouseDown(at, input.MouseLeft)
}
if release {
in.ApplyMouseUp(at, input.MouseLeft)
}
ui.Begin(in, mathx.V2(800, 600), 1)
widgets.DragFloat32(ui, core.IDOf("particle.drag"), r, &v, widgets.NumberOpts{Min: fd.Min, HasMin: fd.HasMin, Speed: 0.05})
ui.End()
}
step(mathx.V2(100, 20), false, false)
step(mathx.V2(100, 20), true, false)
step(mathx.V2(20, 20), false, false)
step(mathx.V2(20, 20), false, true)
if v != 0 {
t.Fatalf("drag crossed the minimum: %v", v)
}
}
func TestInvalidParticleFieldsAreIdentifiedIndividually(t *testing.T) {
_, _, td := inspectorFixture(t)
c := particles.DefaultParticleSystem2D()
c.VelocityOverLife = curve.Keyed(curve.Key{T: 0, V: 1}, curve.Key{T: 1.2, V: 0})
c.Drag = -1
bad := c.InvalidFields()
if !bad["velocityOverLife"] || !bad["drag"] || bad["sizeOverLife"] || bad["gravity"] {
t.Fatalf("wrong fields marked: %v", bad)
}
p := particleInspector{invalid: bad}
velocity, _ := td.Field("velocityOverLife")
size, _ := td.Field("sizeOverLife")
if p.FieldError(nil, nil, velocity) == "" || p.FieldError(nil, nil, size) != "" || !p.NormalizedCurve(nil, nil, velocity) {
t.Fatal("the wrong curve is marked or bounded")
}
c.VelocityOverLife = curve.Constant(1)
c.Drag = 0
if len(c.InvalidFields()) != 0 || c.Validate() != nil {
t.Fatalf("corrected emitter remains invalid: %v", c.InvalidFields())
}
}
func TestShapeHandlesUndoInReflectedSpace(t *testing.T) {
for _, tc := range []struct {
part gizmo.Part
shape particles.Shape
field string
to mathx.Vec2
}{{partRadius, particles.Circle, "radius", mathx.V2(3, 4)}, {partDirection, particles.Point, "direction", mathx.V2(-2, 1)}, {partRect, particles.Rect, "rectSize", mathx.V2(2, 3)}, {partEdgeA, particles.Edge, "edgeA", mathx.V2(3, 5)}, {partEdgeB, particles.Edge, "edgeB", mathx.V2(2, 5)}} {
t.Run(tc.field, func(t *testing.T) {
d, h, td := inspectorFixture(t)
s := d.Scene()
c, _ := scene.Get[particles.ParticleSystem2D](s, h)
c.Shape = tc.shape
c.Offset = mathx.V2(.25, .1)
s.SetLocal(h, scene.Transform2D{Position: mathx.V2(-2, 3), Scale: mathx.V2(-2, 3), Rotation: .3})
s.UpdateTransforms()
id := s.ID(h)
fd, _ := td.Field(tc.field)
before, _ := s.GetProperty(h, td.ID, fd.ID)
e := gizmo.NewEdit(s, []ids.NodeID{id}, id, mathx.Vec2{}, gizmo.Snap{})
e.Tx = d.History.Begin("Move handle")
g := shapeGizmo{td}
g.Begin(e, gizmo.Hit{Part: tc.part})
m := s.World(h)
for i := 1; i <= 4; i++ {
e.Mouse = m.Apply(c.Offset.Add(tc.to.Scale(float32(i) / 4)))
g.Update(e)
}
g.End(e)
e.Tx.Commit()
if e.Err != nil {
t.Fatal(e.Err)
}
if _, n := d.History.Top(); n != 1 {
t.Fatalf("drag committed %d steps", n)
}
after, _ := s.GetProperty(h, td.ID, fd.ID)
if after.F == before.F && after.V == before.V {
t.Fatal("drag did not modify property")
}
switch tc.part {
case partRadius:
if mathx.Abs(float32(after.F)-5) > 1e-4 {
t.Fatalf("radius %g", after.F)
}
case partRect:
if after.V.Sub(tc.to.Scale(2)).Len() > 1e-4 {
t.Fatalf("extent %v", after.V)
}
case partEdgeA, partEdgeB:
if after.V.Sub(tc.to).Len() > 1e-4 {
t.Fatalf("edge %v", after.V)
}
case partDirection:
if mathx.Abs(float32(after.F)-mathx.Atan2(tc.to.Y, tc.to.X)) > 1e-4 {
t.Fatalf("angle %g", after.F)
}
}
if err := d.History.Undo(); err != nil {
t.Fatal(err)
}
undo, _ := s.GetProperty(h, td.ID, fd.ID)
if undo.F != before.F || undo.V != before.V {
t.Fatal("Undo lost original authoring field")
}
})
}
}
func TestGradientDragSingleUndoAndStaleEdit(t *testing.T) {
d, h, td := inspectorFixture(t)
s := d.Scene()
id := s.ID(h)
c, _ := scene.Get[particles.ParticleSystem2D](s, h)
c.ColorOverLife = []particles.ColorKey{{Time: 0, Color: mathx.White}, {Time: .5, Color: mathx.Color{R: 1, A: .7}}, {Time: 1, Color: mathx.White}}
var w GradientEditor
w.sel = 1
w.begin(c.ColorOverLife)
for _, at := range []float32{.2, .6, .7, .8} {
w.move(at)
}
if c.ColorOverLife[1].Time != .5 || d.History.CanUndo() {
t.Fatal("drag mutated original before commit")
}
apply := func(keys []particles.ColorKey) {
t.Helper()
if err := setGradient(d, td, []ids.NodeID{id}, keys); err != nil {
t.Fatal(err)
}
}
w.commit(c.ColorOverLife, apply)
if _, n := d.History.Top(); n != 1 || c.ColorOverLife[1].Time != .8 {
t.Fatalf("history depth %d; key %v", n, c.ColorOverLife)
}
if err := d.History.Undo(); err != nil || c.ColorOverLife[1].Time != .5 {
t.Fatal("Undo did not restore gradient")
}
w.begin(c.ColorOverLife)
w.move(.15)
next, _ := addColor(c.ColorOverLife)
apply(next)
w.commit(c.ColorOverLife, apply)
if len(c.ColorOverLife) != 4 {
t.Fatal("stale gradient drag overwrote another edit")
}
}
type inertPreview struct{}
func (inertPreview) controls(*context.Editor, ids.NodeID) {}
func TestMouseEditsGradientInParticleInspector(t *testing.T) {
fixture, h, td := inspectorFixture(t)
s := fixture.Scene()
mgr := document.NewManager(document.Options{})
doc := mgr.New("test.aescene", s)
ui := core.New(nil)
ed := &context.Editor{Docs: mgr, UI: ui}
p := particleInspector{parent: inertPreview{}, td: td}
for i := range p.closed {
p.closed[i] = i != 4
}
c, _ := scene.Get[particles.ParticleSystem2D](s, h)
c.ColorOverLife = []particles.ColorKey{{Time: 0, Color: mathx.White}, {Time: .5, Color: mathx.Color{R: 1, A: .7}}, {Time: 1, Color: mathx.White}}
nodes := []ids.NodeID{s.ID(h)}
var inputState input.State
var strip, buttons mathx.Rect
frame := func(at mathx.Vec2, press, release bool) {
inputState.BeginFrame()
inputState.ApplyMouseMove(at, mathx.Vec2{})
if press {
inputState.ApplyMouseDown(at, input.MouseLeft)
}
if release {
inputState.ApplyMouseUp(at, input.MouseLeft)
}
ui.Begin(&inputState, mathx.V2(800, 600), 1)
ui.PushCursor(core.Cursor{X: 10, Y: 12, W: 400, RowH: 18, Space: 2})
p.Draw(ed, nodes, func() {
if p.section == 4 {
layout := *ui.Cursor()
layout.Next(0)
strip = layout.Next(28)
if len(c.ColorOverLife) > 0 {
layout.Next(0)
}
buttons = layout.Next(0)
}
})
ui.PopCursor()
ui.End()
}
frame(mathx.Vec2{}, false, false)
pos := func(r mathx.Rect, f float32) mathx.Vec2 { return mathx.V2(r.X+f*r.W, r.Y+r.H*.5) }
click := func(at mathx.Vec2) { frame(at, true, false); frame(at, false, true); frame(at, false, false) }
frame(pos(strip, .5), true, false)
frame(pos(strip, .62), false, false)
frame(pos(strip, .75), false, false)
frame(pos(strip, .75), false, true)
if _, depth := doc.History.Top(); depth != 1 || c.ColorOverLife[1].Time != .75 {
t.Fatalf("mouse drag left gradient %v history depth %d", c.ColorOverLife, depth)
}
if err := doc.History.Undo(); err != nil || c.ColorOverLife[1].Time != .5 {
t.Fatal("mouse drag undo failed")
}
click(pos(buttons, .2))
if len(c.ColorOverLife) != 4 {
t.Fatal("click Add did not create a color key")
}
click(pos(buttons, .8))
if len(c.ColorOverLife) != 3 {
t.Fatal("click Delete did not remove selected key")
}
if err := doc.History.Undo(); err != nil || len(c.ColorOverLife) != 4 {
t.Fatal("delete undo lost key")
}
}
func BenchmarkParticleGradientWidget(b *testing.B) {
var widget GradientEditor
keys := []particles.ColorKey{{Time: 0, Color: mathx.White}, {Time: .5, Color: mathx.Color{R: .2, G: .4, B: .6, A: .5}}, {Time: 1, Color: mathx.Color{R: 1, A: .1}}}
ui := core.New(nil)
in := new(input.State)
onChange := func([]particles.ColorKey) { b.Fatal("idle widget wrote to document") }
draw := func() {
in.BeginFrame()
ui.Begin(in, mathx.V2(800, 600), 1)
ui.PushCursor(core.Cursor{X: 12, Y: 12, W: 360, RowH: 18, Space: 2})
widget.Draw(ui, keys, onChange)
ui.PopCursor()
ui.End()
}
draw()
b.ReportAllocs()
b.ResetTimer()
for range b.N {
draw()
}
}
func TestParticleInspectorRangeAndFoldDefaults(t *testing.T) {
_, _, td := inspectorFixture(t)
for _, name := range []string{"lifetime", "speed", "size", "rotation", "gravity", "rectSize", "offset"} {
f, ok := td.Field(name)
if !ok {
t.Fatal(name)
}
want := name == "lifetime" || name == "speed" || name == "size" || name == "rotation"
if f.Hints.MinMaxPair != want {
t.Fatalf("%s: min/max hint %v", name, f.Hints.MinMaxPair)
}
}
folds := defaultParticleFolds()
if folds[1] || folds[2] || !folds[4] || !folds[5] || !folds[7] {
t.Fatal("expected emission and shape expanded and advanced controls collapsed", folds)
}
for _, tc := range []struct {
width float32
cols int
}{{52, 1}, {124, 2}, {186, 2}, {260, 4}} {
if got := transportColumns(tc.width, 58, 4); got != tc.cols {
t.Fatalf("width %g: got %d columns; want %d", tc.width, got, tc.cols)
}
}
}