Files
aego/app/edit/art_authoring_controls_gl_test.go

776 lines
26 KiB
Go

//go:build aego_gltest && linux && cgo && !aego_nonative
package edit
// Real native authoring controls. Fixture creation/opening is setup; every
// operation under test is sent through SDL controls. The Art model is read only
// for assertions and mapping source pixels to the actual Canvas draw rectangle.
import (
"bytes"
"encoding/json"
"fmt"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"aego/art"
"aego/core/log"
"aego/core/mathx"
"aego/editor/core/artedit"
"aego/platform/input"
"aego/platform/sdl"
"aego/ui/draw"
)
type artControlsArtUI struct {
t *testing.T
s *Session
model *artedit.Document
panel string
dir string
}
func artControlsOpenArt(t *testing.T, name string) *artControlsArtUI {
t.Helper()
t.Setenv("AEGO_LANG", "en")
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_DATA_HOME", t.TempDir())
p := newProject(t)
d, err := art.New(16, 16, art.RGBA, art.DefaultLimits())
if err != nil {
t.Fatal(err)
}
data, err := d.Encode()
d.Close()
if err != nil {
t.Fatal(err)
}
const source = "assets/controls.aeart"
if err := os.WriteFile(filepath.Join(p.Root, source), data, 0644); err != nil {
t.Fatal(err)
}
s, err := Open(p, Config{Hidden: true, NoWatch: true, Width: 1280, Height: 900, Logger: log.Nop()})
if err != nil {
t.Fatal(err)
}
interopStep(t, s)
doc, err := s.App.Context().OpenAssetPath(source)
if err != nil {
s.Close()
t.Fatal(err)
}
model, ok := doc.Binding.Model.(*artedit.Document)
if !ok {
s.Close()
t.Fatal("supplement fixture did not open as an Art document")
}
f := &artControlsArtUI{t: t, s: s, model: model, panel: "render2d.art/" + doc.Asset.String()}
if root := os.Getenv("AEGO_ART_CONTROLS_SHOTS"); root != "" {
f.dir = filepath.Join(root, name)
if err := os.MkdirAll(f.dir, 0755); err != nil {
s.Close()
t.Fatal(err)
}
}
f.maximize(f.panel)
t.Log("setup: blank RGBA16 document created/opened using existing host; subsequent tested authoring actions use SDL")
return f
}
func (f *artControlsArtUI) close() {
if f.t.Failed() && f.dir != "" {
// A useful screenshot is captured while the actual editor is still alive.
interopShot(f.t, f.s, filepath.Join(f.dir, "failure.png"))
}
f.s.Close()
}
func (f *artControlsArtUI) step() { f.t.Helper(); interopStep(f.t, f.s) }
func (f *artControlsArtUI) maximize(id string) mathx.Rect {
f.t.Helper()
return interopMaximize(f.t, f.s, id)
}
func (f *artControlsArtUI) click(p mathx.Vec2) {
f.t.Helper()
interopClick(f.t, f.s, p.X, p.Y)
}
func (f *artControlsArtUI) key(k input.Scancode, ctrl bool) {
f.t.Helper()
interopKey(f.t, f.s, k, ctrl)
}
func (f *artControlsArtUI) text(value string) {
f.t.Helper()
f.key(input.KeyA, true)
// SDL_TEXTINPUT has a bounded payload. All test strings are ASCII.
for len(value) > 0 {
n := min(20, len(value))
if !sdl.TestTextInput(value[:n]) {
f.t.Fatal("SDL text input failed")
}
f.step()
value = value[n:]
}
}
func artControlsInside(r, clip mathx.Rect) bool {
return r.X >= clip.X-.1 && r.Y >= clip.Y-.1 && r.X+r.W <= clip.X+clip.W+.1 && r.Y+r.H <= clip.Y+clip.H+.1
}
func (f *artControlsArtUI) label(text string) draw.TestDrawnItem {
f.t.Helper()
ui := f.s.App.Context().UI
screen := mathx.Rect{W: ui.Size().X, H: ui.Size().Y}
var matches []draw.TestDrawnItem
for _, item := range f.s.App.UIList().TestDrawnItems(screen, ui.Font(), text, 0, 0) {
if item.Text == text && (item.Clip != screen || ui.AnyPopup()) && artControlsInside(item.Bounds, item.Clip) {
matches = append(matches, item)
}
}
if len(matches) != 1 {
f.t.Fatalf("expected one complete real label %q, got %d: %+v", text, len(matches), matches)
}
return matches[0]
}
func (f *artControlsArtUI) clickLabel(text string) {
f.t.Helper()
f.click(f.label(text).Bounds.Center())
}
func (f *artControlsArtUI) menu(panel, title, item string) {
f.t.Helper()
f.maximize(panel)
f.clickLabel(title)
if !f.s.App.Context().UI.AnyPopup() {
f.t.Fatalf("actual menu %q did not open", title)
}
// BeginPopup deliberately uses a zero clip for its first size-probe frame.
// Wait for the next real layout before locating a visible menu item.
f.step()
f.clickLabel(item)
if f.s.App.Context().UI.AnyPopup() {
f.t.Fatalf("actual menu entry %q did not close its popup", item)
}
// A selected action can enqueue the existing Question for the next frame.
f.step()
}
func (f *artControlsArtUI) tool(name string) {
f.t.Helper()
f.maximize("art.tools")
f.clickLabel(name)
f.maximize(f.panel)
}
func (f *artControlsArtUI) brushToggle(label string) {
f.t.Helper()
f.maximize("art.tools")
f.clickLabel(label)
f.maximize(f.panel)
}
func (f *artControlsArtUI) swatch(index int) art.Color {
f.t.Helper()
if index < 0 || index > 7 {
f.t.Fatal("this artControls palette locator intentionally tests the first visible page only")
}
r := f.maximize("art.properties")
ui := f.s.App.Context().UI
channel := f.label("Albedo")
// Native properties put the real swatches one row below the surface Combo.
// Derive Y from the actual rendered label, and X from the current panel.
width := r.W - 2*ui.Theme.Padding
point := mathx.V2(r.X+ui.Theme.Padding+width*(float32(index)+1.5)/10, channel.Bounds.Center().Y+ui.Theme.RowHeight+ui.Theme.Spacing)
if !r.Contains(point) {
f.t.Fatal("swatch is outside the actual properties panel")
}
want := f.model.Session.Document.Info().Palette[index].Color
f.click(point)
f.maximize(f.panel)
return want
}
func (f *artControlsArtUI) canvasTexture() (mathx.Rect, mathx.Rect) {
f.t.Helper()
ui := f.s.App.Context().UI
panel, visible := f.s.App.Workspace().Tree.PanelRect(f.panel)
if !visible {
f.t.Fatal("the actual Canvas is not visible")
}
size := f.model.Session.Document.Info().Canvas
var found []draw.TestDrawnItem
for _, item := range f.s.App.UIList().TestDrawnItems(mathx.Rect{W: ui.Size().X, H: ui.Size().Y}, ui.Font(), "", size.Width, size.Height) {
if item.Bounds.W > float32(size.Width) && item.Bounds.H > float32(size.Height) && artControlsInside(item.Bounds, panel) {
found = append(found, item)
}
}
if len(found) != 1 {
f.t.Fatalf("expected one real source-sized Canvas texture, got %d", len(found))
}
return found[0].Bounds, found[0].Clip
}
func (f *artControlsArtUI) point(p image.Point) mathx.Vec2 {
f.t.Helper()
r, clip := f.canvasTexture()
c := f.model.Session.Document.Info().Canvas
at := mathx.V2(r.X+(float32(p.X)+.5)*r.W/float32(c.Width), r.Y+(float32(p.Y)+.5)*r.H/float32(c.Height))
if p.X < 0 || p.Y < 0 || p.X >= c.Width || p.Y >= c.Height || !clip.Contains(at) {
f.t.Fatalf("source pixel %v is not visible in actual Canvas: %v %+v", p, at, clip)
}
return at
}
func (f *artControlsArtUI) stroke(points ...image.Point) {
f.t.Helper()
if len(points) == 0 {
f.t.Fatal("empty SDL gesture")
}
at := f.point(points[0])
for _, kind := range []int{0, 1} {
if !sdl.TestPointerEvent(at.X, at.Y, kind) {
f.t.Fatal("SDL stroke start failed")
}
f.step()
}
for _, p := range points[1:] {
at = f.point(p)
if !sdl.TestPointerEvent(at.X, at.Y, 0) {
f.t.Fatal("SDL stroke motion failed")
}
f.step()
}
if !sdl.TestPointerEvent(at.X, at.Y, 2) {
f.t.Fatal("SDL stroke release failed")
}
f.step()
}
func (f *artControlsArtUI) pixels() *image.NRGBA {
f.t.Helper()
pixels, _, err := f.model.Capture(f.model.Session.Frame, art.Albedo)
if err != nil {
f.t.Fatal(err)
}
return pixels
}
func (f *artControlsArtUI) snapshot() []byte {
f.t.Helper()
data, err := f.model.Session.Document.Encode()
if err != nil {
f.t.Fatal(err)
}
return data
}
func (f *artControlsArtUI) expectPixels(want map[image.Point]art.Color) {
f.t.Helper()
pixels := f.pixels()
for y := 0; y < 16; y++ {
for x := 0; x < 16; x++ {
point := image.Pt(x, y)
c := want[point]
expected := color.NRGBA{R: c.R, G: c.G, B: c.B, A: c.A}
if got := pixels.NRGBAAt(x, y); got != expected {
f.t.Fatalf("actual SDL result pixel %v=%+v, want %+v", point, got, expected)
}
}
}
}
func (f *artControlsArtUI) undoTo(cursor int) {
f.t.Helper()
f.maximize(f.panel)
for count := 0; f.model.History.Cursor() > cursor && count < 20; count++ {
before := f.model.History.Cursor()
f.key(input.KeyZ, true)
if f.model.History.Cursor() != before-1 {
f.t.Fatal("one native Ctrl+Z did not undo exactly one operation")
}
}
if f.model.History.Cursor() != cursor {
f.t.Fatal("native Undo did not reach requested history position")
}
}
func (f *artControlsArtUI) modalField(label, current, value string) {
f.t.Helper()
ui := f.s.App.Context().UI
if !ui.ModalActive() {
f.t.Fatal("expected actual existing Question modal", label)
}
caption := f.label(label)
var field mathx.Rect
screen := mathx.Rect{W: ui.Size().X, H: ui.Size().Y}
for _, item := range f.s.App.UIList().TestDrawnItems(screen, ui.Font(), current, 0, 0) {
r := item.Clip
if item.Text == current && artControlsInside(item.Bounds, r) && r.H == ui.Theme.RowHeight && r.X >= caption.Clip.X && r.X+r.W <= caption.Clip.X+caption.Clip.W && r.Y >= caption.Bounds.Y+caption.Bounds.H && r.Y+r.H <= caption.Clip.Y+caption.Clip.H && (field.H == 0 || r.Y < field.Y) {
field = r
}
}
if field.H == 0 {
f.t.Fatalf("actual modal input below %q with value %q is not visible", label, current)
}
f.click(field.Center())
if ui.Focus() == 0 {
f.t.Fatal("actual input click did not acquire native text focus")
}
f.text(value)
}
func (f *artControlsArtUI) applyModal() {
f.t.Helper()
if !f.s.App.Context().UI.ModalActive() {
f.t.Fatal("Question closed before Apply")
}
f.clickLabel("Apply")
if f.s.App.Context().UI.ModalActive() {
f.t.Fatal("real Apply button did not submit Question")
}
}
func (f *artControlsArtUI) shot(name string) *image.RGBA {
f.t.Helper()
var img *image.RGBA
var captureErr error
f.s.Engine.CaptureFrame(func(value *image.RGBA, err error) { img, captureErr = value, err })
f.step()
if img == nil || captureErr != nil {
f.t.Fatal("actual native GPU screenshot failed", captureErr)
}
if f.dir != "" {
out, err := os.Create(filepath.Join(f.dir, name+".png"))
if err != nil {
f.t.Fatal(err)
}
writeErr := png.Encode(out, img)
closeErr := out.Close()
if writeErr != nil || closeErr != nil {
f.t.Fatal(writeErr, closeErr)
}
}
return img
}
func (f *artControlsArtUI) receipt(value any) {
f.t.Helper()
if f.dir == "" {
return
}
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(f.dir, "assertions.json"), append(data, '\n'), 0644); err != nil {
f.t.Fatal(err)
}
}
func TestArtFillPixelPerfectNative(t *testing.T) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
f := artControlsOpenArt(t, "fill-pixel-perfect")
defer f.close()
border := f.swatch(6)
f.tool("Rectangle")
f.stroke(image.Pt(2, 2), image.Pt(7, 7))
if f.model.History.Cursor() != 1 {
t.Fatal("actual rectangle was not one operation")
}
borderOnly := map[image.Point]art.Color{}
for y := 2; y <= 7; y++ {
for x := 2; x <= 7; x++ {
if x == 2 || x == 7 || y == 2 || y == 7 {
borderOnly[image.Pt(x, y)] = border
}
}
}
f.expectPixels(borderOnly)
f.shot("00-rectangle-before-fill")
fill := f.swatch(2)
f.tool("Fill")
f.stroke(image.Pt(4, 4))
want := map[image.Point]art.Color{}
for y := 2; y <= 7; y++ {
for x := 2; x <= 7; x++ {
c := fill
if x == 2 || x == 7 || y == 2 || y == 7 {
c = border
}
want[image.Pt(x, y)] = c
}
}
f.expectPixels(want)
if f.model.History.Cursor() != 2 {
t.Fatal("actual Fill did not create exactly one operation")
}
f.shot("01-closed-region-fill")
f.undoTo(0)
f.expectPixels(nil)
white := f.swatch(5)
f.tool("Pencil")
// The existing default is perfect=true. Demonstrate both actual toggle states,
// not just the default and not a direct write to artView or Brush.
f.brushToggle("Pixel perfect")
f.stroke(image.Pt(2, 2), image.Pt(3, 2), image.Pt(3, 3))
f.expectPixels(map[image.Point]art.Color{image.Pt(2, 2): white, image.Pt(3, 2): white, image.Pt(3, 3): white})
f.shot("02-ordinary-corner")
f.undoTo(0)
f.brushToggle("Pixel perfect")
f.stroke(image.Pt(2, 2), image.Pt(3, 2), image.Pt(3, 3))
f.expectPixels(map[image.Point]art.Color{image.Pt(2, 2): white, image.Pt(3, 3): white})
if f.model.History.Cursor() != 1 {
t.Fatal("pixel-perfect gesture did not commit once")
}
f.shot("03-pixel-perfect-corner")
f.undoTo(0)
f.expectPixels(nil)
f.receipt(map[string]any{"result": "PASS", "input": "SDL only after blank fixture", "fillInterior": 16, "borderPixels": 20, "ordinaryCornerPixels": 3, "perfectCornerPixels": 2, "historyAfterUndo": 0})
}
func TestArtColorModesRemapNative(t *testing.T) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
f := artControlsOpenArt(t, "color-modes-remap")
defer f.close()
f.menu("art.properties", "Palette…", "Convert to indexed palette")
if f.model.Session.Document.Info().Canvas.Mode != art.Indexed {
t.Fatal("actual native palette menu did not convert to Indexed")
}
red := f.swatch(6)
f.tool("Pencil")
f.stroke(image.Pt(3, 3))
paint, err := f.model.Session.SamplePaint(image.Pt(3, 3))
if err != nil || !paint.UseIndex || paint.Index != 6 || paint.Color != red {
t.Fatal("native indexed stroke did not store the selected palette index", paint, err)
}
before := bytes.Clone(f.pixels().Pix)
cursor := f.model.History.Cursor()
f.menu("art.properties", "Palette…", "Move swatch right (preserve pixels)")
paint, err = f.model.Session.SamplePaint(image.Pt(3, 3))
if err != nil || !paint.UseIndex || paint.Index != 7 || paint.Color != red || !bytes.Equal(before, f.pixels().Pix) || f.model.History.Cursor() != cursor+1 {
t.Fatal("native reorder did not preserve rendered pixels while remapping actual indices", paint, err)
}
f.maximize(f.panel)
f.shot("01-indexed-reorder-preserves-pixels")
f.menu("art.properties", "Palette…", "Remap palette index…")
f.modalField("Destination index", "7", "2")
f.applyModal()
paint, err = f.model.Session.SamplePaint(image.Pt(3, 3))
want := f.model.Session.Document.Info().Palette[2].Color
if err != nil || paint.Index != 2 || paint.Color != want || bytes.Equal(before, f.pixels().Pix) || f.model.History.Cursor() != cursor+2 {
t.Fatal("native remap did not intentionally change index/color once", paint, err)
}
f.maximize(f.panel)
f.shot("02-remapped-color")
f.undoTo(cursor + 1)
paint, err = f.model.Session.SamplePaint(image.Pt(3, 3))
if err != nil || paint.Index != 7 || !bytes.Equal(before, f.pixels().Pix) {
t.Fatal("common Undo did not restore the reordered indexed pixels", paint, err)
}
f.menu("art.properties", "Palette…", "Convert to grayscale")
if f.model.Session.Document.Info().Canvas.Mode != art.Grayscale || f.pixels().NRGBAAt(3, 3) != (color.NRGBA{151, 151, 151, 255}) {
t.Fatal("native grayscale conversion has incorrect mode/luminance")
}
chosen := f.swatch(2)
if chosen != (art.Color{R: 52, G: 62, B: 85, A: 255}) {
t.Fatal("fixture palette changed; review explicit grayscale expectation", chosen)
}
f.stroke(image.Pt(10, 10))
if got := f.pixels().NRGBAAt(10, 10); got != (color.NRGBA{62, 62, 62, 255}) {
t.Fatal("native grayscale painting did not apply its color-mode rules", got)
}
gray := bytes.Clone(f.pixels().Pix)
f.shot("03-grayscale-painted")
f.menu("art.properties", "Palette…", "Convert to RGBA")
if f.model.Session.Document.Info().Canvas.Mode != art.RGBA || !bytes.Equal(gray, f.pixels().Pix) {
t.Fatal("native RGBA conversion changed grayscale image")
}
f.maximize(f.panel)
f.key(input.KeyZ, true)
if f.model.Session.Document.Info().Canvas.Mode != art.Grayscale || !bytes.Equal(gray, f.pixels().Pix) {
t.Fatal("common Undo did not restore grayscale mode and pixels")
}
f.receipt(map[string]any{"result": "PASS", "input": "SDL palette menu/swatches/dialogs/Pencil/Undo", "storedIndex": 6, "reorderedIndex": 7, "remappedIndex": 2, "reorderPixelInvariant": true, "grayscaleFromRed": 151, "grayscalePaint": 62, "rgbaPreservesGray": true})
}
func TestArtSelectionTransformNative(t *testing.T) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
f := artControlsOpenArt(t, "selection-transform")
defer f.close()
white := f.swatch(5)
f.tool("Pencil")
for _, point := range []image.Point{image.Pt(2, 2), image.Pt(2, 3), image.Pt(3, 3)} {
f.stroke(point)
}
sentinel := f.swatch(6)
f.stroke(image.Pt(12, 12))
before := bytes.Clone(f.pixels().Pix)
cursor := f.model.History.Cursor()
f.tool("Selection")
f.stroke(image.Pt(2, 2), image.Pt(3, 3))
if f.model.Session.Selection == nil || f.model.Session.Selection.Bounds() != image.Rect(2, 2, 4, 4) || f.model.History.Cursor() != cursor {
t.Fatal("actual rectangular selection is not the intended 2x2 mask")
}
f.menu("art.properties", "Pixels…", "Rotate 90° clockwise")
f.expectPixels(map[image.Point]art.Color{image.Pt(2, 2): white, image.Pt(3, 2): white, image.Pt(2, 3): white, image.Pt(12, 12): sentinel})
if f.model.History.Cursor() != cursor+1 {
t.Fatal("actual selection rotation was not one operation")
}
f.maximize(f.panel)
f.shot("01-rotated-selection")
f.undoTo(cursor)
if !bytes.Equal(before, f.pixels().Pix) {
t.Fatal("native Undo lost pre-rotation pixels")
}
f.menu("art.properties", "Pixels…", "Scale pixels…")
f.modalField("Width", "2", "4")
f.modalField("Height", "2", "4")
f.applyModal()
want := map[image.Point]art.Color{image.Pt(12, 12): sentinel}
// Explicit expected 2x nearest-neighbor expansion of [white,transparent;
// white,white]. Outside the transformed area the sentinel is untouched.
for y := 2; y < 6; y++ {
for x := 2; x < 6; x++ {
if x < 4 || y >= 4 {
want[image.Pt(x, y)] = white
}
}
}
f.expectPixels(want)
if f.model.History.Cursor() != cursor+1 {
t.Fatal("actual Scale dialog was not one operation")
}
f.maximize(f.panel)
f.shot("02-scaled-selection")
f.undoTo(cursor)
if !bytes.Equal(before, f.pixels().Pix) {
t.Fatal("native Undo lost pre-scale pixels")
}
f.receipt(map[string]any{"result": "PASS", "input": "SDL selection/rotate/scale dialog/common Undo", "selection": image.Rect(2, 2, 4, 4), "scaledExtent": image.Rect(2, 2, 6, 6), "sentinelPreserved": image.Pt(12, 12), "all256PixelsChecked": true})
}
func (f *artControlsArtUI) setDuration(current, next string) {
f.t.Helper()
f.maximize("art.timeline")
info := f.model.Session.Document.Info()
f.t.Logf("duration input before editing: selected frame=%s canonical frames=%+v expected label=%s", f.model.Session.Frame, info.Frames, current)
item := f.label(current)
if item.Clip.H != f.s.App.Context().UI.Theme.RowHeight {
f.t.Fatal("duration label does not belong to a real text input", item)
}
f.click(item.Clip.Center())
f.text(next)
f.key(input.KeyEnter, false)
}
func (f *artControlsArtUI) selectFrame(index, duration int) {
f.t.Helper()
f.maximize("art.timeline")
// The real grid intentionally elides duration labels in its ~48px cells.
// Locate the rendered frame-number prefix, never an assumed old tile point.
ui := f.s.App.Context().UI
prefix := fmt.Sprintf("%d · ", index)
var headers []draw.TestDrawnItem
for _, item := range f.s.App.UIList().TestDrawnItems(mathx.Rect{W: ui.Size().X, H: ui.Size().Y}, ui.Font(), prefix, 0, 0) {
if strings.HasPrefix(item.Text, prefix) && artControlsInside(item.Bounds, item.Clip) {
headers = append(headers, item)
}
}
if len(headers) != 1 {
f.t.Fatal("expected one visible real frame-header prefix", prefix, headers)
}
f.click(headers[0].Bounds.Center())
info := f.model.Session.Document.Info()
if f.model.Session.Frame != info.Frames[index-1].ID || info.Frames[index-1].DurationMS != duration {
f.t.Fatal("actual Timeline header did not select requested frame")
}
f.maximize(f.panel)
}
type artControlsSignature [3]color.RGBA
func (f *artControlsArtUI) signature(img *image.RGBA) artControlsSignature {
f.t.Helper()
var out artControlsSignature
for i, p := range []image.Point{image.Pt(2, 2), image.Pt(4, 4), image.Pt(6, 6)} {
at := f.point(p)
if !image.Pt(int(at.X), int(at.Y)).In(img.Rect) {
f.t.Fatal("actual source-pixel position is outside GPU screenshot")
}
out[i] = img.RGBAAt(int(at.X), int(at.Y))
}
return out
}
func TestArtOnionTagsDurationNative(t *testing.T) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
f := artControlsOpenArt(t, "onion-tags-duration")
defer f.close()
f.swatch(6)
f.tool("Pencil")
f.stroke(image.Pt(2, 2))
f.setDuration("100", "240")
firstFrame := f.model.Session.Frame
durationCursor := f.model.History.Cursor()
f.maximize(f.panel)
f.key(input.KeyZ, true)
if current := f.model.Session.Document.Info().Frames[0]; current.ID != firstFrame || current.DurationMS != 100 || f.model.History.Cursor() != durationCursor-1 {
t.Fatal("common native Undo did not restore duration100 on the same frame", current)
}
f.maximize("art.timeline")
t.Log("same-frame duration Undo restored canonical100; checking the actual visible field")
f.label("100")
f.shot("00-duration-undo-100")
f.maximize(f.panel)
// The existing global EditRedo shortcut is Primary+Shift+Z.
for _, down := range []bool{true, false} {
if !sdl.TestEditKey(input.KeyZ, down, down, down) {
t.Fatal("SDL shared Redo key failed")
}
f.step()
}
if current := f.model.Session.Document.Info().Frames[0]; current.ID != firstFrame || current.DurationMS != 240 || f.model.History.Cursor() != durationCursor {
t.Fatal("common native Redo did not restore duration240 on the same frame", current)
}
f.maximize("art.timeline")
f.label("240")
f.shot("00-duration-redo-240")
// Unconfirmed typing stays local to the real input buffer across redraws.
field := f.label("240")
f.click(field.Clip.Center())
f.text("360")
for range 3 {
f.step()
}
f.label("360")
if f.model.Session.Document.Info().Frames[0].DurationMS != 240 || f.model.History.Cursor() != durationCursor {
t.Fatal("unconfirmed duration typing mutated the authoring document")
}
f.key(input.KeyEscape, false)
f.label("240")
f.clickLabel("+ Frame")
f.swatch(5)
f.tool("Pencil")
f.stroke(image.Pt(4, 4))
f.setDuration("100", "400")
f.maximize("art.timeline")
f.clickLabel("+ Frame")
f.swatch(3)
f.tool("Pencil")
f.stroke(image.Pt(6, 6))
f.setDuration("100", "240")
info := f.model.Session.Document.Info()
if len(info.Frames) != 3 || info.Frames[0].DurationMS != 240 || info.Frames[1].DurationMS != 400 || info.Frames[2].DurationMS != 240 {
t.Fatal("actual Timeline duration fields did not apply independently", info.Frames)
}
frameIDs := []art.ID{info.Frames[0].ID, info.Frames[1].ID, info.Frames[2].ID}
f.maximize(f.panel)
off := f.signature(f.shot("01-onion-off"))
snapshot, cursor := f.snapshot(), f.model.History.Cursor()
f.maximize("art.timeline")
f.clickLabel("Onion skin")
f.maximize(f.panel)
on := f.signature(f.shot("02-onion-on"))
if off[1] == on[1] || off[2] != on[2] || !bytes.Equal(snapshot, f.snapshot()) || f.model.History.Cursor() != cursor {
t.Fatal("actual Onion toggle did not add previous-frame GPU ghost without altering current pixels/history", off, on)
}
if c := f.pixels().NRGBAAt(4, 4); c.A != 0 {
t.Fatal("Onion ghost leaked into canonical current-frame pixels", c)
}
f.maximize("art.timeline")
f.clickLabel("Onion skin")
f.maximize(f.panel)
if restored := f.signature(f.shot("03-onion-off-restored")); restored != off {
t.Fatal("disabling Onion did not restore actual Canvas pixels", restored, off)
}
f.menu("art.timeline", "Frames / Tags…", "New animation tag…")
// Name starts focused in the existing Question; subsequent fields are located
// by their real rendered captions and input clips, not old dialog coordinates.
f.text("LoopProof")
f.modalField("Direction: forward / reverse / ping-pong", "forward", "ping-pong")
f.applyModal()
info = f.model.Session.Document.Info()
if len(info.Tags) != 1 || info.Tags[0].Name != "LoopProof" || info.Tags[0].From != frameIDs[0] || info.Tags[0].To != frameIDs[2] || info.Tags[0].Direction != art.PingPong || f.model.History.Cursor() != cursor+1 {
t.Fatal("actual tag dialog did not publish one exact range/direction operation", info.Tags)
}
f.maximize(f.panel)
f.shot("04-created-tag")
var signatures [3]artControlsSignature
for i, ms := range []int{240, 400, 240} {
f.selectFrame(i+1, ms)
signatures[i] = f.signature(f.shot(fmt.Sprintf("reference-frame-%d", i+1)))
}
if signatures[0] == signatures[1] || signatures[1] == signatures[2] || signatures[0] == signatures[2] {
t.Fatal("actual GPU frame references are not distinguishable")
}
beforePlayback := f.snapshot()
history := f.model.History.Cursor()
f.maximize("art.timeline")
f.clickLabel("Play")
f.maximize(f.panel)
type transition struct {
Frame int `json:"frame"`
Time float64 `json:"uiSeconds"`
}
var transitions []transition
deadline := time.Now().Add(8 * time.Second)
for time.Now().Before(deadline) && len(transitions) < 5 {
var shot *image.RGBA
var captureErr error
f.s.Engine.CaptureFrame(func(img *image.RGBA, err error) { shot, captureErr = img, err })
f.step()
if captureErr != nil || shot == nil {
t.Fatal("actual playback screenshot failed", captureErr)
}
value := f.signature(shot)
index := -1
for i, expected := range signatures {
if value == expected {
index = i + 1
}
}
if index < 0 {
t.Fatal("playback Canvas did not match any actual authored frame signature", value)
}
if len(transitions) == 0 || transitions[len(transitions)-1].Frame != index {
transitions = append(transitions, transition{Frame: index, Time: f.s.App.Context().UI.Time})
}
runtime.Gosched()
time.Sleep(5 * time.Millisecond)
}
want := []int{1, 2, 3, 2, 1}
if len(transitions) != len(want) {
t.Fatal("actual bounded ping-pong playback did not visit required frames", transitions)
}
for i, frame := range want {
if transitions[i].Frame != frame {
t.Fatal("actual tag playback direction/order is incorrect", transitions)
}
}
// Observe the complete middle-frame dwell. The 400 ms user duration must
// affect playback, rather than falling back to a fixed 100 ms interval.
dwell := transitions[2].Time - transitions[1].Time
if dwell < .30 || dwell > .60 {
t.Fatalf("actual 400 ms duration was not reflected in playback: %.6fs %+v", dwell, transitions)
}
if !bytes.Equal(beforePlayback, f.snapshot()) || f.model.History.Cursor() != history {
t.Fatal("preview playback changed canonical drawing or History")
}
f.maximize("art.timeline")
f.clickLabel("Play")
f.maximize(f.panel)
f.shot("05-playback-stopped")
f.receipt(map[string]any{"result": "PASS", "input": "SDL Timeline/frame/duration/Onion/tag/Play controls", "durationsMS": []int{240, 400, 240}, "direction": "ping-pong", "transitions": transitions, "observedMiddleDwellSeconds": dwell, "onionChangesGPUOnly": true, "canonicalBytesUnchangedByPreview": true, "durationUndoRedoUpdatesVisibleField": true, "unconfirmedDurationPreservedUntilEsc": true})
}