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.
389 lines
11 KiB
Go
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
|
|
}
|
|
}
|
|
}
|