Pad/internal/test/e2e/sort_mode_test.go
Greg Pomerantz 58725a5e6c Make state single-owner and stabilize race detector
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.
2026-08-16 01:32:27 -04:00

410 lines
12 KiB
Go

package e2e_test
import (
"slices"
"testing"
"time"
"pad/internal/browser"
"pad/internal/editor"
"pad/internal/test/e2e"
"pad/internal/ui"
)
// sortModeOf reads the browser sort mode on the logic goroutine (owner).
func sortModeOf(t *testing.T, h *e2e.Harness) browser.SortMode {
t.Helper()
v, err := h.Inspect(func(st *editor.State) any { return st.Browser.SortMode })
if err != nil {
t.Fatalf("Inspect: %v", err)
}
return v.(browser.SortMode)
}
// pageCountOf reads the number of cached browser pages on the logic goroutine.
func pageCountOf(t *testing.T, h *e2e.Harness) int {
t.Helper()
v, err := h.Inspect(func(st *editor.State) any { return len(st.Browser.Pages) })
if err != nil {
t.Fatalf("Inspect: %v", err)
}
return v.(int)
}
// goBrowser navigates to the browser page on the logic goroutine.
func goBrowser(t *testing.T, h *e2e.Harness) {
t.Helper()
if err := h.WithState(func(st *editor.State) { editor.GoToBrowser(nil) }); err != nil {
t.Fatalf("GoToBrowser: %v", err)
}
}
// toggleSort toggles the browser sort mode on the logic goroutine.
func toggleSort(t *testing.T, h *e2e.Harness) {
t.Helper()
if err := h.WithState(func(st *editor.State) { editor.ToggleSortOrder(nil) }); err != nil {
t.Fatalf("ToggleSortOrder: %v", err)
}
}
// TestSortModeToggleCyclesThroughAllModes verifies that clicking the
// sort mode label cycles through all four sort modes.
//
// This test helps debug the issue where clicking the sort mode label
// does not change the order of entries in the browser list.
func TestSortModeToggleCyclesThroughAllModes(t *testing.T) {
h := e2e.NewHarnessWithDefaults()
defer h.Cleanup()
// Ensure we have at least one frame
_, err := h.WaitForFrameCount(1, 5*time.Second)
if err != nil {
t.Fatalf("timeout waiting for initial frames: %v", err)
}
// Navigate to browser page
goBrowser(t, h)
// Trigger a frame
h.SendConfig(780, 1688)
time.Sleep(200 * time.Millisecond)
// Get the initial sort mode label
frames := h.GetFrames()
lastFrame := frames[len(frames)-1]
// Record the initial order of list items
initialItems := getListItems(t, lastFrame)
initialSortMode := sortModeOf(t, h)
t.Logf("Initial list items: %v", initialItems)
t.Logf("Initial SortMode: %d", initialSortMode)
if len(initialItems) == 0 {
t.Skip("no list items to sort; skipping test")
}
// Tap the sort label to cycle through all 4 modes
for cycle := 0; cycle < 4; cycle++ {
// Simulate tapping the sort label by calling the editor's ToggleSortOrder
// (since SendInput doesn't route to the correct handler in e2e context)
toggleSort(t, h)
// Trigger a new frame and wait for reload after cache clear
h.SendConfig(780, 1688)
time.Sleep(300 * time.Millisecond)
frames = h.GetFrames()
lastFrame = frames[len(frames)-1]
newItems := getListItems(t, lastFrame)
newSortMode := sortModeOf(t, h)
t.Logf("Cycle %d: SortMode=%d, items=%v", cycle, newSortMode, newItems)
// After cycling through all 4 modes, we should be back to the original
if cycle == 3 {
// Skip comparison if items are still loading (show "...")
if len(newItems) > 0 && newItems[0] == "..." {
t.Log("Items still loading after 4 cycles, skipping comparison")
continue
}
if !slices.Equal(initialItems, newItems) {
t.Errorf("after 4 cycles, expected to return to original order\n initial: %v\n final: %v", initialItems, newItems)
}
}
}
// Verify the sort mode was actually changed in state
t.Logf("Final SortMode: %d", sortModeOf(t, h))
}
// TestSortModeToggleChangesEntryOrder verifies that toggling the sort mode
// actually changes the order of entries in the list, not just the label.
func TestSortModeToggleChangesEntryOrder(t *testing.T) {
h := e2e.NewHarnessWithDefaults()
defer h.Cleanup()
_, err := h.WaitForFrameCount(1, 5*time.Second)
if err != nil {
t.Fatalf("timeout waiting for initial frames: %v", err)
}
// Navigate to browser page
goBrowser(t, h)
h.SendConfig(780, 1688)
time.Sleep(200 * time.Millisecond)
frames := h.GetFrames()
lastFrame := frames[len(frames)-1]
// Record initial state
initialItems := getListItems(t, lastFrame)
initialSortMode := sortModeOf(t, h)
t.Logf("Before toggle: SortMode=%d, items=%v", initialSortMode, initialItems)
if len(initialItems) == 0 {
t.Skip("no list items to sort; skipping test")
}
// Toggle sort mode using editor's function (browser's was removed)
toggleSort(t, h)
// Trigger a new frame
h.SendConfig(780, 1688)
time.Sleep(200 * time.Millisecond)
frames = h.GetFrames()
lastFrame = frames[len(frames)-1]
newItems := getListItems(t, lastFrame)
newSortMode := sortModeOf(t, h)
t.Logf("After toggle: SortMode=%d, items=%v", newSortMode, newItems)
// The sort mode should have changed
if newSortMode == initialSortMode {
t.Errorf("SortMode did not change after toggle: still %d", newSortMode)
}
// CRITICAL: The items should have changed order when sort mode changes
// (unless all items happen to be in the same order for both modes, which
// is extremely unlikely with the mock filesystem)
if slices.Equal(initialItems, newItems) {
t.Errorf("BUG: list items did NOT change order after sort mode toggle!")
t.Errorf(" The sort mode changed from %d to %d, but entries remain in the same order.",
initialSortMode, newSortMode)
t.Errorf(" This indicates the cached pages are not being reloaded after the sort mode change.")
t.Errorf(" Fix: ToggleSortOrder should clear s.Pages so they get reloaded with the new sort order.")
}
}
// TestSortModePagesNotClearedAfterToggle demonstrates the root cause:
// after toggling sort mode, the cached pages still contain entries in the
// old sort order because ToggleSortOrder does not clear s.Pages.
func TestSortModePagesNotClearedAfterToggle(t *testing.T) {
h := e2e.NewHarnessWithDefaults()
defer h.Cleanup()
_, err := h.WaitForFrameCount(1, 5*time.Second)
if err != nil {
t.Fatalf("timeout waiting for initial frames: %v", err)
}
// Navigate to browser page
goBrowser(t, h)
h.SendConfig(780, 1688)
time.Sleep(200 * time.Millisecond)
// Record the number of cached pages before toggle
pagesBefore := pageCountOf(t, h)
sortModeBefore := sortModeOf(t, h)
t.Logf("Before toggle: SortMode=%d, Pages=%d", sortModeBefore, pagesBefore)
// Toggle sort mode using editor's function (browser's was removed)
toggleSort(t, h)
// Force a new frame to be rendered with the updated state
h.SendConfig(780, 1688)
time.Sleep(300 * time.Millisecond)
sortModeAfter := sortModeOf(t, h)
pagesAfter := pageCountOf(t, h)
t.Logf("After toggle: SortMode=%d, Pages=%d", sortModeAfter, pagesAfter)
// Verify sort mode changed
if sortModeAfter == sortModeBefore {
t.Errorf("sort mode should have changed after toggle")
}
// Verify pages were reloaded (not just cleared)
// After toggle, pages should be reloaded with the new sort order
if pagesAfter == 0 {
t.Errorf("pages should be reloaded after sort mode toggle, not just cleared")
}
// Verify the entries are in the new sort order
frames := h.GetFrames()
lastFrame := frames[len(frames)-1]
items := getListItems(t, lastFrame)
if len(items) > 0 && items[0] == "..." {
t.Skip("items still loading, skipping order verification")
}
// After toggle, sort mode should be 1 (Name Desc), so first item should NOT be ".gitignore"
if sortModeAfter == 1 && len(items) > 0 && items[0] == ".gitignore" {
t.Errorf("entries should be in new sort order after toggle, but first entry unchanged: %q", items[0])
}
}
// TestSortModeLabelInHeader verifies the sort mode label is present
// in the browser header, confirming the UI element exists.
func TestSortModeLabelInHeader(t *testing.T) {
h := e2e.NewHarnessWithDefaults()
defer h.Cleanup()
_, err := h.WaitForFrameCount(1, 5*time.Second)
if err != nil {
t.Fatalf("timeout waiting for initial frames: %v", err)
}
goBrowser(t, h)
h.SendConfig(780, 1688)
time.Sleep(200 * time.Millisecond)
frames := h.GetFrames()
if len(frames) == 0 {
t.Fatal("no frames captured")
}
lastFrame := frames[len(frames)-1]
// Print the full element tree for debugging
t.Log("Element tree:")
for i, elem := range lastFrame {
t.Logf(" [%d] %s", i, elem.String())
}
// Find a label with sort mode text (Name ↑/↓ or Date ↑/↓)
// Use the new String() method which recursively searches Container children
foundSortLabel := false
for _, elem := range lastFrame {
str := elem.String()
if containsSortLabel(str) {
foundSortLabel = true
t.Logf("Found sort mode label in element %d: %s", lastFrame[0], str)
break
}
}
if !foundSortLabel {
t.Error("sort mode label (Name ↑/↓ or Date ↑/↓) not found in browser header")
}
}
// TestSortModeLabelChangesAfterToggle verifies that the sort mode label
// text changes after toggling, confirming the UI reflects the new mode.
func TestSortModeLabelChangesAfterToggle(t *testing.T) {
h := e2e.NewHarnessWithDefaults()
defer h.Cleanup()
_, err := h.WaitForFrameCount(1, 5*time.Second)
if err != nil {
t.Fatalf("timeout waiting for initial frames: %v", err)
}
// Navigate to browser page
goBrowser(t, h)
h.SendConfig(780, 1688)
time.Sleep(200 * time.Millisecond)
// Record initial sort mode
initialSortMode := sortModeOf(t, h)
// Toggle sort mode using editor's function (browser's was removed)
toggleSort(t, h)
newSortMode := sortModeOf(t, h)
t.Logf("SortMode changed from %d to %d", initialSortMode, newSortMode)
// The sort mode should have changed
if newSortMode == initialSortMode {
t.Errorf("SortMode did not change after toggle: still %d", newSortMode)
}
// The label should reflect the new sort mode
expectedLabel := sortModeLabel(newSortMode)
t.Logf("Expected sort mode label: %q", expectedLabel)
// Trigger a new frame and check the label
h.SendConfig(780, 1688)
time.Sleep(200 * time.Millisecond)
frames := h.GetFrames()
lastFrame := frames[len(frames)-1]
// Find the sort label
foundLabel := findSortModeLabel(lastFrame)
t.Logf("Found sort mode label: %q", foundLabel)
if foundLabel == expectedLabel {
t.Logf("Sort mode label correctly shows %q for SortMode %d", foundLabel, newSortMode)
}
}
// Helper functions
// findSortModeLabel finds the sort mode label text in the frame elements.
// Uses the String() method which recursively searches Container children.
func findSortModeLabel(frame []ui.Element) string {
knownLabels := []string{"Name ↑", "Name ↓", "Date ↑", "Date ↓"}
for _, elem := range frame {
str := elem.String()
for _, known := range knownLabels {
if containsSubstring(str, known) {
return known
}
}
}
return ""
}
// containsSortLabel checks if a string contains any sort mode label text.
func containsSortLabel(s string) bool {
knownLabels := []string{"Name ↑", "Name ↓", "Date ↑", "Date ↓"}
for _, known := range knownLabels {
if containsSubstring(s, known) {
return true
}
}
return false
}
// containsSubstring checks if s contains sub, handling Unicode characters.
func containsSubstring(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(s) > len(sub) && findSubstring(s, sub))
}
func findSubstring(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
// getListItems extracts all list item texts from the first ListView in the frame.
func getListItems(t *testing.T, frame []ui.Element) []string {
for _, elem := range frame {
if lv, ok := elem.(ui.ListView); ok {
items := make([]string, len(lv.Items))
for i, item := range lv.Items {
items[i] = item.Text
}
return items
}
}
return nil
}
// sortModeLabel returns the display label for the given sort mode.
// Mirrors the browser.sortModeLabel function for test assertions.
func sortModeLabel(mode browser.SortMode) string {
switch mode {
case browser.SortModeNameAsc:
return "Name ↑"
case browser.SortModeNameDesc:
return "Name ↓"
case browser.SortModeDateAsc:
return "Date ↑"
case browser.SortModeDateDesc:
return "Date ↓"
default:
return "Name ↑"
}
}