Frame now carries view state (scale/focus/query) to the main goroutine, which reads only the frame-receiver-stored snapshot. Renderer owns Gio widget editors (registered by ID) and the draw scale. Autosave timer sends a token to the logic goroutine instead of touching state; Shutdown waits for the owner to exit before FlushAll. Tests access state only through owner-side Inspect/WithState helpers (harness + in-package). Fixed double-Harness.Run race in two e2e tests, made TestWorkerPool_PriorityPreemption deterministic, and raised the lazy-loading test timeout that was too short under -race. go test -race ./... is now green.
195 lines
4.8 KiB
Go
195 lines
4.8 KiB
Go
package e2e
|
|
|
|
import (
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"pad/internal/editor"
|
|
"pad/internal/ui"
|
|
)
|
|
|
|
var errInspectTimeout = fmt.Errorf("e2e: inspect timed out waiting for the logic goroutine")
|
|
|
|
// Harness orchestrates the test environment for e2e tests.
|
|
type Harness struct {
|
|
logic *editor.Logic
|
|
capture *FrameCapture
|
|
logicWg sync.WaitGroup
|
|
frameReceiverDone chan struct{}
|
|
wg sync.WaitGroup
|
|
started bool
|
|
}
|
|
|
|
// HarnessOption configures the test harness.
|
|
type HarnessOption func(*Harness)
|
|
|
|
// NewHarness creates a test harness with the given options.
|
|
func NewHarness(opts ...HarnessOption) *Harness {
|
|
h := &Harness{
|
|
logic: editor.NewLogic(nil, "/", func(string) {}), // no-op openfunc (matches impl_other.go)
|
|
capture: NewFrameCapture(),
|
|
frameReceiverDone: make(chan struct{}),
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(h)
|
|
}
|
|
|
|
return h
|
|
}
|
|
|
|
// Run starts the logic goroutine and frame capture.
|
|
// It must be called at most once: two Run loops on the same channels and
|
|
// state would race (NewHarnessWithDefaults already calls it).
|
|
func (h *Harness) Run() {
|
|
if h.started {
|
|
panic("e2e: Harness.Run called twice")
|
|
}
|
|
h.started = true
|
|
h.logicWg.Add(1)
|
|
h.wg.Add(1)
|
|
go func() {
|
|
defer h.wg.Done()
|
|
defer h.logicWg.Done()
|
|
h.logic.Run()
|
|
}()
|
|
|
|
h.wg.Add(1)
|
|
go func() {
|
|
defer h.wg.Done()
|
|
for {
|
|
select {
|
|
case frame := <-h.logic.FrameChan():
|
|
h.capture.CaptureFrame(frame.Elems)
|
|
case <-h.frameReceiverDone:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// SendConfig simulates a window config event (resize).
|
|
func (h *Harness) SendConfig(width, height int) {
|
|
h.logic.ConfigChan() <- editor.ConfigEvent{
|
|
PixelWidth: width,
|
|
PixelHeight: height,
|
|
}
|
|
}
|
|
|
|
// SendScale simulates a scale factor change.
|
|
func (h *Harness) SendScale(scale float32) {
|
|
h.logic.ConfigChan() <- editor.ScaleEvent{Scale: scale}
|
|
}
|
|
|
|
// SendInput simulates user input events.
|
|
func (h *Harness) SendInput(events []ui.InputEvent) {
|
|
h.logic.InputChan() <- events
|
|
}
|
|
|
|
// SendSearchQuery simulates a search query update.
|
|
func (h *Harness) SendSearchQuery(query string) {
|
|
h.logic.SearchQueryChan() <- query
|
|
}
|
|
|
|
// GetFrames returns all captured frames.
|
|
func (h *Harness) GetFrames() [][]ui.Element {
|
|
return h.capture.GetFrames()
|
|
}
|
|
|
|
// FrameCount returns the number of frames captured so far.
|
|
func (h *Harness) FrameCount() int {
|
|
return h.capture.FrameCount()
|
|
}
|
|
|
|
// Inspect runs fn on the logic goroutine and returns its result.
|
|
// This is the ONLY sanctioned way for a test to read or write state: the fn
|
|
// executes on the owner, preserving the single-owner invariant
|
|
// (architecture.md §1). fn must not block on sends to logic channels.
|
|
func (h *Harness) Inspect(fn func(st *editor.State) any) (any, error) {
|
|
v, ok := h.logic.Inspect(fn)
|
|
if !ok {
|
|
return nil, errInspectTimeout
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// WithState runs fn on the logic goroutine for state setup or mutation.
|
|
func (h *Harness) WithState(fn func(st *editor.State)) error {
|
|
_, err := h.Inspect(func(st *editor.State) any {
|
|
fn(st)
|
|
return nil
|
|
})
|
|
return err
|
|
}
|
|
|
|
// FileLoaded reports whether the active file's chunked buffer has loaded
|
|
// content (owner-side check).
|
|
func (h *Harness) FileLoaded() (bool, error) {
|
|
v, err := h.Inspect(func(st *editor.State) any {
|
|
cb := st.Editor.ChunkedBuffer
|
|
return cb != nil && cb.FileLen() > 0
|
|
})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return v.(bool), nil
|
|
}
|
|
|
|
// FullContent returns the active file's full content (owner-side).
|
|
func (h *Harness) FullContent() (string, error) {
|
|
v, err := h.Inspect(func(st *editor.State) any {
|
|
if st.Editor.ChunkedBuffer != nil {
|
|
full, err := st.Editor.ChunkedBuffer.FullContent()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return full
|
|
}
|
|
return st.Editor.Buffer
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return v.(string), nil
|
|
}
|
|
|
|
// CursorPosition returns the editor cursor position (owner-side).
|
|
func (h *Harness) CursorPosition() (int, error) {
|
|
v, err := h.Inspect(func(st *editor.State) any { return st.Editor.CursorPosition })
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return v.(int), nil
|
|
}
|
|
|
|
// WaitForFrameCount blocks until at least N frames are captured.
|
|
func (h *Harness) WaitForFrameCount(count int, timeout time.Duration) ([][]ui.Element, error) {
|
|
return h.capture.WaitForFrameCount(count, timeout)
|
|
}
|
|
|
|
// WaitForFrame blocks until at least one frame is captured.
|
|
func (h *Harness) WaitForFrame(timeout time.Duration) ([][]ui.Element, error) {
|
|
return h.capture.WaitForFrame(timeout)
|
|
}
|
|
|
|
// Cleanup stops all goroutines.
|
|
func (h *Harness) Cleanup() {
|
|
// 1. Signal logic to stop sending frames
|
|
h.logic.Done()
|
|
|
|
// 2. Wait for logic goroutine to fully exit (no more frameChan sends)
|
|
h.logicWg.Wait()
|
|
|
|
// 3. Now safe to stop the frame receiver
|
|
close(h.frameReceiverDone)
|
|
|
|
// 4. Wait for frame receiver to finish
|
|
h.wg.Wait()
|
|
|
|
h.capture.Close()
|
|
}
|
|
|
|
// DefaultTimeout is the default timeout for waiting operations.
|
|
const DefaultTimeout = 5 * time.Second
|