test(ioscompanion): cover input composers, paste dialog loop, and pure helpers

This commit is contained in:
pj committed 2026-06-06 23:20:03 +05:30
1 parent c2746610bf
commit 222a51cb93
2 files changed
+370

No files matched your search

+363
View File
@@ -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))
}
}
@@ -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}
]