mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
test(ioscompanion): cover input composers, paste dialog loop, and pure helpers
This commit is contained in:
1 parent
c2746610bf
commit
222a51cb93
2 files changed
+370
No files matched your search
@@ -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}
|
||||
]
|
||||
Reference in new issue
Block a user