Pad/internal/test/e2e/helpers.go
Greg Pomerantz 06b1444207 gofmt: format all remaining files with the go1.27 toolchain
The tree was formatted with an older gofmt; go1.27's gofmt additionally
wants: EOF exactly one newline (no trailing blank lines), imports sorted
alphabetically within a block, mixed-precedence binary expressions
re-spaced for grouping ((a+b)/c), single-field composite literals
un-aligned, adjacent one-line method signatures aligned, and one-line
bodies containing a compound statement expanded. Applied repo-wide
(31 files under internal/); pure formatting, no semantic changes —
build and the full test suite pass.
2026-08-23 10:03:27 -04:00

389 lines
11 KiB
Go

package e2e
import (
"fmt"
"testing"
"time"
"pad/internal/ui"
)
// TestScenario represents a complete test scenario with setup, actions, and assertions.
type TestScenario struct {
Name string
Setup func(*Harness)
Actions []HarnessAction
Assertions []FrameAssertion
FrameIndex int // which frame to assert on (-1 for last)
Timeout time.Duration
}
// HarnessAction represents an action to perform on the harness.
type HarnessAction func(*Harness)
// FrameAssertion represents an assertion to make on a frame.
type FrameAssertion func(*testing.T, []ui.Element)
// --- Common actions ---
// ActionSendConfig creates an action to send a config event.
func ActionSendConfig(width, height int) HarnessAction {
return func(h *Harness) {
h.SendConfig(width, height)
}
}
// ActionSendScale creates an action to send a scale event.
func ActionSendScale(scale float32) HarnessAction {
return func(h *Harness) {
h.SendScale(scale)
}
}
// ActionSendInput creates an action to send input events.
func ActionSendInput(events []ui.InputEvent) HarnessAction {
return func(h *Harness) {
h.SendInput(events)
}
}
// ActionSendSearchQuery creates an action to send a search query.
func ActionSendSearchQuery(query string) HarnessAction {
return func(h *Harness) {
h.SendSearchQuery(query)
}
}
// ActionWait creates an action to wait for frames.
func ActionWait(count int, timeout time.Duration) HarnessAction {
return func(h *Harness) {
h.WaitForFrameCount(count, timeout)
}
}
// --- Common assertions ---
// AssertionHasElementCount creates an assertion for element count.
func AssertionHasElementCount(count int) FrameAssertion {
return func(t *testing.T, frame []ui.Element) {
NewElementAssertions(t, frame).HasElementCount(count)
}
}
// AssertionHasElementWithID creates an assertion for element ID.
func AssertionHasElementWithID(id string) FrameAssertion {
return func(t *testing.T, frame []ui.Element) {
NewElementAssertions(t, frame).HasElementWithID(id)
}
}
// AssertionHasLabelWithText creates an assertion for label text.
func AssertionHasLabelWithText(text string) FrameAssertion {
return func(t *testing.T, frame []ui.Element) {
NewElementAssertions(t, frame).HasLabelWithText(text)
}
}
// AssertionHasListViewWithItems creates an assertion for list view items.
func AssertionHasListViewWithItems(items []string) FrameAssertion {
return func(t *testing.T, frame []ui.Element) {
NewElementAssertions(t, frame).HasListViewWithItems(items)
}
}
// AssertionHasListViewWithItemCount creates an assertion for list view item count.
func AssertionHasListViewWithItemCount(count int) FrameAssertion {
return func(t *testing.T, frame []ui.Element) {
NewElementAssertions(t, frame).HasListViewWithItemCount(count)
}
}
// AssertionHasButtonWithText creates an assertion for button text.
func AssertionHasButtonWithText(text string) FrameAssertion {
return func(t *testing.T, frame []ui.Element) {
NewElementAssertions(t, frame).HasButtonWithText(text)
}
}
// AssertionHasIconWithName creates an assertion for icon name.
func AssertionHasIconWithName(name string) FrameAssertion {
return func(t *testing.T, frame []ui.Element) {
NewElementAssertions(t, frame).HasIconWithName(name)
}
}
// AssertionHasSearchBarWithQuery creates an assertion for search bar query.
// NOTE: ui.SearchBar does not implement ui.Element (no Draw method),
// so this assertion is currently a no-op placeholder.
func AssertionHasSearchBarWithQuery(query string) FrameAssertion {
return func(t *testing.T, frame []ui.Element) {
// TODO: implement when SearchBar implements ui.Element
}
}
// AssertionHasToastWithText creates an assertion for toast text.
// NOTE: ui.Toast does not implement ui.Element (no Draw method),
// so this assertion is currently a no-op placeholder.
func AssertionHasToastWithText(text string) FrameAssertion {
return func(t *testing.T, frame []ui.Element) {
// TODO: implement when Toast implements ui.Element
}
}
// AssertionHasCursorAt creates an assertion for cursor position.
// NOTE: ui.Cursor does not implement ui.Element (no Draw method),
// so this assertion is currently a no-op placeholder.
func AssertionHasCursorAt(line, col int) FrameAssertion {
return func(t *testing.T, frame []ui.Element) {
// TODO: implement when Cursor implements ui.Element
}
}
// AssertionNoOverlappingElements creates an assertion for no overlapping elements.
func AssertionNoOverlappingElements() FrameAssertion {
return func(t *testing.T, frame []ui.Element) {
AssertNoOverlappingElements(t, frame)
}
}
// AssertionElementPositions creates an assertion for element positions.
func AssertionElementPositions(bounds ui.Region) FrameAssertion {
return func(t *testing.T, frame []ui.Element) {
AssertElementPositions(t, frame, bounds)
}
}
// --- Scenario runner ---
// RunScenario runs a test scenario.
func RunScenario(t *testing.T, scenario TestScenario) {
h := NewHarness()
h.Run()
defer h.Cleanup()
// Run setup
if scenario.Setup != nil {
scenario.Setup(h)
}
// Run actions
for _, action := range scenario.Actions {
action(h)
}
// Wait for frames
timeout := scenario.Timeout
if timeout == 0 {
timeout = DefaultTimeout
}
frames, err := h.WaitForFrameCount(1, timeout)
if err != nil {
t.Fatalf("timeout waiting for frames: %v", err)
}
// Determine which frame to assert on
frameIndex := scenario.FrameIndex
if frameIndex == -1 || frameIndex >= len(frames) {
frameIndex = len(frames) - 1
}
// Run assertions
for _, assertion := range scenario.Assertions {
assertion(t, frames[frameIndex])
}
}
// --- Helper functions ---
// WaitForStableFrames waits until no new frames are captured for a period.
func WaitForStableFrames(h *Harness, stabilityPeriod time.Duration) error {
deadline := time.Now().Add(stabilityPeriod)
for time.Now().Before(deadline) {
count := h.FrameCount()
time.Sleep(50 * time.Millisecond)
if h.FrameCount() == count {
return nil
}
}
return fmt.Errorf("frames not stable after %v", stabilityPeriod)
}
// GetLastFrame returns the last captured frame.
func GetLastFrame(h *Harness) []ui.Element {
frames := h.GetFrames()
if len(frames) == 0 {
return nil
}
return frames[len(frames)-1]
}
// GetFrameByIndex returns a frame by index.
func GetFrameByIndex(h *Harness, index int) []ui.Element {
frames := h.GetFrames()
if index < 0 || index >= len(frames) {
return nil
}
return frames[index]
}
// DebugPrintFrames prints all captured frames for debugging.
func DebugPrintFrames(h *Harness) {
frames := h.GetFrames()
for i, frame := range frames {
fmt.Printf("Frame %d (%d elements):\n", i, len(frame))
fmt.Print(PrintFrame(frame))
}
}
// NewHarnessWithDefaults creates a harness with default configuration.
func NewHarnessWithDefaults() *Harness {
h := NewHarness()
h.Run()
h.SendConfig(780, 1688) // 390x844 @ 2x scale
h.SendScale(2.0)
return h
}
// NewHarnessWithCustomConfig creates a harness with custom configuration.
func NewHarnessWithCustomConfig(width, height int, scale float32) *Harness {
h := NewHarness()
h.Run()
h.SendConfig(width, height)
h.SendScale(scale)
return h
}
// WaitForInitialFrame waits for the initial frame after setup.
func WaitForInitialFrame(h *Harness, timeout time.Duration) ([]ui.Element, error) {
_, err := h.WaitForFrameCount(1, timeout)
if err != nil {
return nil, err
}
return GetLastFrame(h), nil
}
// WaitForNewFrame waits for a new frame after an action.
func WaitForNewFrame(h *Harness, beforeCount int, timeout time.Duration) (int, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
count := h.FrameCount()
if count > beforeCount {
return count, nil
}
time.Sleep(10 * time.Millisecond)
}
return beforeCount, fmt.Errorf("no new frames after %v", timeout)
}
// CompareFrameElements compares two frames element by element.
func CompareFrameElements(frame1, frame2 []ui.Element) []string {
var differences []string
if len(frame1) != len(frame2) {
differences = append(differences, fmt.Sprintf("element count: %d vs %d", len(frame1), len(frame2)))
return differences
}
for i := range frame1 {
region1 := frame1[i].Region()
region2 := frame2[i].Region()
if region1 != region2 {
differences = append(differences, fmt.Sprintf("element %d region: %+v vs %+v", i, region1, region2))
}
if frame1[i].Visible() != frame2[i].Visible() {
differences = append(differences, fmt.Sprintf("element %d visible: %v vs %v", i, frame1[i].Visible(), frame2[i].Visible()))
}
}
return differences
}
// --- Input event helpers ---
// CreateTapEvent creates a tap input event.
func CreateTapEvent(handler func(any)) ui.InputEvent {
return ui.InputEvent{
Handler: handler,
Data: ui.Interaction{Gesture: ui.Tap},
}
}
// CreateScrollEvent creates a scroll input event.
func CreateScrollEvent(handler func(any)) ui.InputEvent {
return ui.InputEvent{
Handler: handler,
Data: ui.Interaction{Gesture: ui.Scroll},
}
}
// CreateKeyEvent creates a key input event.
func CreateKeyEvent(handler func(any)) ui.InputEvent {
return ui.InputEvent{
Handler: handler,
Data: ui.Interaction{Gesture: ui.KeyDown},
}
}
// --- Structural assertions ---
// AssertFrameHasNoInvisibleElements asserts that all elements in the frame are visible.
func AssertFrameHasNoInvisibleElements(t *testing.T, frame []ui.Element) {
for i, elem := range frame {
if !elem.Visible() {
t.Errorf("element %d (%T) is invisible", i, elem)
}
}
}
// AssertFrameHasNoEmptyRegions asserts that no elements have empty regions.
func AssertFrameHasNoEmptyRegions(t *testing.T, frame []ui.Element) {
for i, elem := range frame {
region := elem.Region()
if region.W == 0 || region.H == 0 {
t.Errorf("element %d (%T) has empty region: %+v", i, elem, region)
}
}
}
// AssertFrameHasNoNegativeRegions asserts that no elements have negative regions.
func AssertFrameHasNoNegativeRegions(t *testing.T, frame []ui.Element) {
for i, elem := range frame {
region := elem.Region()
if region.X < 0 || region.Y < 0 || region.W < 0 || region.H < 0 {
t.Errorf("element %d (%T) has negative region: %+v", i, elem, region)
}
}
}
// AssertFrameHasNoOutOfBoundsElements asserts that no elements are outside the expected bounds.
func AssertFrameHasNoOutOfBoundsElements(t *testing.T, frame []ui.Element, bounds ui.Region) {
for i, elem := range frame {
region := elem.Region()
if region.X < bounds.X || region.Y < bounds.Y ||
region.X+region.W > bounds.X+bounds.W ||
region.Y+region.H > bounds.Y+bounds.H {
t.Errorf("element %d (%T) is out of bounds: %+v vs %+v", i, elem, region, bounds)
}
}
}
// AssertFrameHasNoDuplicateIDs asserts that no elements have duplicate IDs.
func AssertFrameHasNoDuplicateIDs(t *testing.T, frame []ui.Element) {
seen := make(map[string]bool)
for i, elem := range frame {
if idElem, ok := elem.(interface{ ID() string }); ok {
id := idElem.ID()
if id == "" {
continue // skip elements without IDs
}
if seen[id] {
t.Errorf("element %d has duplicate ID %q", i, id)
}
seen[id] = true
}
}
}