From 222a51cb937b82aac7a0b99a999cd295733ca343 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 6 Jun 2026 23:20:03 +0530 Subject: [PATCH] test(ioscompanion): cover input composers, paste dialog loop, and pure helpers --- internal/driver/ioscompanion/input_test.go | 363 ++++++++++++++++++ .../ioscompanion/testdata/paste-dialog.json | 7 + 2 files changed, 370 insertions(+) create mode 100644 internal/driver/ioscompanion/input_test.go create mode 100644 internal/driver/ioscompanion/testdata/paste-dialog.json diff --git a/internal/driver/ioscompanion/input_test.go b/internal/driver/ioscompanion/input_test.go new file mode 100644 index 0000000..86fa0ee --- /dev/null +++ b/internal/driver/ioscompanion/input_test.go @@ -0,0 +1,363 @@ +package ioscompanion + +import ( + "context" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport" +) + +func eventsEqual(t *testing.T, got, want []transport.HIDEvent) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("event count: got %d, want %d", len(got), len(want)) + } + for i := range want { + if !reflect.DeepEqual(got[i], want[i]) { + t.Fatalf("event %d differs", i) + } + } +} + +func TestKeyPressEvents(t *testing.T) { + tests := []struct { + name string + presses []KeyPress + want []transport.HIDEvent + }{ + { + name: "lowercase letter is down then up, no shift", + presses: []KeyPress{{Usage: usageA}}, + want: []transport.HIDEvent{transport.KeyDown(usageA), transport.KeyUp(usageA)}, + }, + { + name: "shifted letter wraps with left shift down and up", + presses: []KeyPress{{Usage: usageA, Shift: true}}, + want: []transport.HIDEvent{ + transport.KeyDown(usageLeftShift), + transport.KeyDown(usageA), + transport.KeyUp(usageA), + transport.KeyUp(usageLeftShift), + }, + }, + { + name: "empty input yields no events", + presses: nil, + want: []transport.HIDEvent{}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + eventsEqual(t, keyPressEvents(test.presses), test.want) + }) + } +} + +func TestKeyPressEventsMixedString(t *testing.T) { + presses, skipped := typeString("aB") + if len(skipped) != 0 { + t.Fatalf("unexpected skipped runes: %v", skipped) + } + got := keyPressEvents(presses) + want := []transport.HIDEvent{ + transport.KeyDown(usageA), transport.KeyUp(usageA), + transport.KeyDown(usageLeftShift), + transport.KeyDown(usageA + 1), + transport.KeyUp(usageA + 1), + transport.KeyUp(usageLeftShift), + } + eventsEqual(t, got, want) +} + +func TestPasteChordEvents(t *testing.T) { + want := []transport.HIDEvent{ + transport.KeyDown(LeftGUI), + transport.KeyDown(VKey), + transport.KeyUp(VKey), + transport.KeyUp(LeftGUI), + } + eventsEqual(t, pasteChordEvents(), want) +} + +func TestTapEvents(t *testing.T) { + want := []transport.HIDEvent{transport.TouchDown(12, 34), transport.TouchUp(12, 34)} + eventsEqual(t, tapEvents(12, 34), want) +} + +func TestDoubleTapEvents(t *testing.T) { + want := []transport.HIDEvent{ + transport.TouchDown(5, 6), transport.TouchUp(5, 6), + transport.Delay(70), + transport.TouchDown(5, 6), transport.TouchUp(5, 6), + } + eventsEqual(t, doubleTapEvents(5, 6, DefaultDoubleTapGapMilliseconds), want) +} + +func TestLongPressEvents(t *testing.T) { + want := []transport.HIDEvent{ + transport.TouchDown(8, 9), + transport.Delay(500), + transport.TouchUp(8, 9), + } + eventsEqual(t, longPressEvents(8, 9, 500), want) +} + +func loadDialogDump(t *testing.T) []byte { + t.Helper() + dump, err := os.ReadFile(filepath.Join("testdata", "paste-dialog.json")) + if err != nil { + t.Fatalf("read testdata: %v", err) + } + return dump +} + +func TestFindAllowPasteButton(t *testing.T) { + tests := []struct { + name string + dump string + wantFound bool + wantX float64 + wantY float64 + }{ + { + name: "exact Allow Paste wins over Don't Allow Paste", + dump: string(loadDialogDump(t)), + wantFound: true, + wantX: 280, // 210 + 140/2 + wantY: 465, // 440 + 50/2 + }, + { + name: "plain Allow label matches", + dump: `[{"type":"Button","AXLabel":"Allow","frame":{"x":100,"y":100,"width":40,"height":20},"enabled":true}]`, + wantFound: true, + wantX: 120, + wantY: 110, + }, + { + name: "sole enabled button fallback", + dump: `[{"type":"StaticText","AXLabel":"Paste?","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true},{"type":"Button","AXLabel":"OK","frame":{"x":50,"y":60,"width":100,"height":40},"enabled":true}]`, + wantFound: true, + wantX: 100, + wantY: 80, + }, + { + name: "no fallback when several enabled buttons present", + dump: `[{"type":"Button","AXLabel":"A","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true},{"type":"Button","AXLabel":"B","frame":{"x":20,"y":0,"width":10,"height":10},"enabled":true}]`, + wantFound: false, + }, + { + name: "reject button alone does not match", + dump: `[{"type":"Button","AXLabel":"Don’t Allow Paste","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true}]`, + wantFound: false, + }, + { + name: "empty dump finds nothing", + dump: ``, + wantFound: false, + }, + { + name: "malformed json finds nothing", + dump: `not json`, + wantFound: false, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + button, found := findAllowPasteButton([]byte(test.dump)) + if found != test.wantFound { + t.Fatalf("found: got %v, want %v", found, test.wantFound) + } + if !found { + return + } + if button.centerX != test.wantX || button.centerY != test.wantY { + t.Fatalf("center: got (%v,%v), want (%v,%v)", button.centerX, button.centerY, test.wantX, test.wantY) + } + }) + } +} + +func TestPasteLanded(t *testing.T) { + dump := `[{"type":"TextField","AXUniqueId":"NoteField","AXValue":"prefix Café ☕","frame":{"x":0,"y":0,"width":10,"height":10}}]` + tests := []struct { + name string + dump string + identifier string + expected string + want bool + }{ + {name: "substring present", dump: dump, identifier: "NoteField", expected: "Café ☕", want: true}, + {name: "value not yet landed", dump: `[{"type":"TextField","AXUniqueId":"NoteField","AXValue":"prefix"}]`, identifier: "NoteField", expected: "Café", want: false}, + {name: "field absent", dump: dump, identifier: "MissingField", expected: "Café", want: false}, + {name: "empty identifier never matches", dump: dump, identifier: "", expected: "Café", want: false}, + {name: "empty expected never matches", dump: dump, identifier: "NoteField", expected: "", want: false}, + {name: "malformed dump never matches", dump: `nope`, identifier: "NoteField", expected: "Café", want: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := pasteLanded([]byte(test.dump), test.identifier, test.expected); got != test.want { + t.Fatalf("got %v, want %v", got, test.want) + } + }) + } +} + +// fakeRunner records side effects and serves scripted describe-all dumps. +type fakeRunner struct { + pasteboard string + hidStreams [][]transport.HIDEvent + dumps [][]byte + dumpIndex int + setError error + sendError error + describeErr error + sleepCount int +} + +func (f *fakeRunner) setPasteboard(ctx context.Context, text string) error { + if f.setError != nil { + return f.setError + } + f.pasteboard = text + return nil +} + +func (f *fakeRunner) sendHID(ctx context.Context, events ...transport.HIDEvent) error { + if f.sendError != nil { + return f.sendError + } + f.hidStreams = append(f.hidStreams, events) + return nil +} + +func (f *fakeRunner) describeAll(ctx context.Context) ([]byte, error) { + if f.describeErr != nil { + return nil, f.describeErr + } + if f.dumpIndex >= len(f.dumps) { + return f.dumps[len(f.dumps)-1], nil + } + dump := f.dumps[f.dumpIndex] + f.dumpIndex++ + return dump, nil +} + +func (f *fakeRunner) sleep(ctx context.Context, duration time.Duration) error { + f.sleepCount++ + return ctx.Err() +} + +func TestInputTextFastPathUsesHardwareKeyboard(t *testing.T) { + fake := &fakeRunner{} + if err := inputText(context.Background(), fake, "hi", fieldTarget{}); err != nil { + t.Fatalf("inputText: %v", err) + } + if fake.pasteboard != "" { + t.Fatalf("fast path must not touch pasteboard, got %q", fake.pasteboard) + } + if len(fake.hidStreams) != 1 { + t.Fatalf("fast path must send one HID stream, got %d", len(fake.hidStreams)) + } + want := keyPressEvents([]KeyPress{{Usage: usageA + ('h' - 'a')}, {Usage: usageA + ('i' - 'a')}}) + eventsEqual(t, fake.hidStreams[0], want) +} + +func TestInputTextPasteLandsImmediately(t *testing.T) { + landed := `[{"type":"TextField","AXUniqueId":"F","AXValue":"Café"}]` + fake := &fakeRunner{dumps: [][]byte{[]byte(landed)}} + field := fieldTarget{identifier: "F", centerX: 100, centerY: 200} + if err := inputText(context.Background(), fake, "Café", field); err != nil { + t.Fatalf("inputText: %v", err) + } + if fake.pasteboard != "Café" { + t.Fatalf("pasteboard: got %q", fake.pasteboard) + } + if len(fake.hidStreams) != 1 { + t.Fatalf("expected one paste chord, got %d streams", len(fake.hidStreams)) + } + eventsEqual(t, fake.hidStreams[0], pasteChordEvents()) +} + +func TestInputTextPasteDismissesDialogThenLands(t *testing.T) { + dialog := string(loadDialogDump(t)) + landed := `[{"type":"TextField","AXUniqueId":"TxnNoteField","AXValue":"Café ☕ 😀"}]` + fake := &fakeRunner{dumps: [][]byte{[]byte(dialog), []byte(landed)}} + field := fieldTarget{identifier: "TxnNoteField", centerX: 195, centerY: 222} + if err := inputText(context.Background(), fake, "Café ☕ 😀", field); err != nil { + t.Fatalf("inputText: %v", err) + } + // Streams: paste chord, tap allow, refocus field, paste chord again. + if len(fake.hidStreams) != 4 { + t.Fatalf("expected 4 HID streams, got %d", len(fake.hidStreams)) + } + eventsEqual(t, fake.hidStreams[0], pasteChordEvents()) + eventsEqual(t, fake.hidStreams[1], tapEvents(280, 465)) + eventsEqual(t, fake.hidStreams[2], tapEvents(195, 222)) + eventsEqual(t, fake.hidStreams[3], pasteChordEvents()) +} + +func TestInputTextPasteFailsAfterAllAttempts(t *testing.T) { + stuck := `[{"type":"TextField","AXUniqueId":"F","AXValue":""}]` + fake := &fakeRunner{dumps: [][]byte{[]byte(stuck)}} + field := fieldTarget{identifier: "F", centerX: 1, centerY: 2} + err := inputText(context.Background(), fake, "😀", field) + if err == nil { + t.Fatal("expected error after exhausting attempts") + } + if len(fake.hidStreams) != pasteAttempts { + t.Fatalf("expected %d paste chords, got %d", pasteAttempts, len(fake.hidStreams)) + } +} + +func TestEraseTextSendsBackspaces(t *testing.T) { + fake := &fakeRunner{} + if err := eraseText(context.Background(), fake, 3); err != nil { + t.Fatalf("eraseText: %v", err) + } + if len(fake.hidStreams) != 1 { + t.Fatalf("expected one stream, got %d", len(fake.hidStreams)) + } + want := keyPressEvents(backspaces(3)) + eventsEqual(t, fake.hidStreams[0], want) +} + +func TestEraseTextZeroIsNoOp(t *testing.T) { + fake := &fakeRunner{} + if err := eraseText(context.Background(), fake, 0); err != nil { + t.Fatalf("eraseText: %v", err) + } + if len(fake.hidStreams) != 0 { + t.Fatalf("zero count must send nothing, got %d streams", len(fake.hidStreams)) + } +} + +func TestWarmUpPasteHandlesDialog(t *testing.T) { + dialog := string(loadDialogDump(t)) + fake := &fakeRunner{dumps: [][]byte{[]byte(dialog)}} + if err := warmUpPaste(context.Background(), fake); err != nil { + t.Fatalf("warmUpPaste: %v", err) + } + if fake.pasteboard != warmUpPrimer { + t.Fatalf("pasteboard: got %q, want %q", fake.pasteboard, warmUpPrimer) + } + // Streams: paste chord, tap allow. + if len(fake.hidStreams) != 2 { + t.Fatalf("expected 2 HID streams, got %d", len(fake.hidStreams)) + } + eventsEqual(t, fake.hidStreams[0], pasteChordEvents()) + eventsEqual(t, fake.hidStreams[1], tapEvents(280, 465)) +} + +func TestWarmUpPasteNoDialog(t *testing.T) { + fake := &fakeRunner{dumps: [][]byte{[]byte(`[]`)}} + if err := warmUpPaste(context.Background(), fake); err != nil { + t.Fatalf("warmUpPaste: %v", err) + } + if len(fake.hidStreams) != 1 { + t.Fatalf("expected only the paste chord, got %d streams", len(fake.hidStreams)) + } +} diff --git a/internal/driver/ioscompanion/testdata/paste-dialog.json b/internal/driver/ioscompanion/testdata/paste-dialog.json new file mode 100644 index 0000000..2149bf6 --- /dev/null +++ b/internal/driver/ioscompanion/testdata/paste-dialog.json @@ -0,0 +1,7 @@ +[ + {"type": "Window", "frame": {"x": 0, "y": 0, "width": 390, "height": 844}, "enabled": true}, + {"type": "TextField", "AXUniqueId": "TxnNoteField", "AXValue": "", "AXLabel": "Note", "frame": {"x": 20, "y": 200, "width": 350, "height": 44}, "enabled": true}, + {"type": "StaticText", "AXLabel": "Allow Pasting", "frame": {"x": 95, "y": 380, "width": 200, "height": 22}, "enabled": true}, + {"type": "Button", "AXLabel": "Don’t Allow Paste", "frame": {"x": 40, "y": 440, "width": 140, "height": 50}, "enabled": true}, + {"type": "Button", "AXLabel": "Allow Paste", "frame": {"x": 210, "y": 440, "width": 140, "height": 50}, "enabled": true} +]