simframe 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +165 -5
- package/data/vocabulary/en.json +148 -0
- package/native/ocr.swift +13 -1
- package/native/rank.swift +87 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +43 -3
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +27 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +4 -0
- package/native/simframed/Sources/simframed/main.swift +13 -1
- package/native/supervise.swift +181 -0
- package/package.json +4 -1
- package/scripts/check-package.mjs +22 -2
- package/scripts/check-private.mjs +143 -0
- package/scripts/ci-memory.mjs +104 -20
- package/scripts/eval-perception.mjs +281 -0
- package/scripts/phase17-corpus.mjs +176 -0
- package/skills/simframe/SKILL.md +237 -5
- package/src/actions.js +1825 -38
- package/src/analyze.js +70 -0
- package/src/cli.js +214 -15
- package/src/control.js +1 -0
- package/src/fingerprint.js +19 -1
- package/src/graph.js +193 -11
- package/src/index.js +428 -16
- package/src/input.js +115 -8
- package/src/localhelper.js +155 -0
- package/src/matching.js +119 -3
- package/src/mcp.js +319 -27
- package/src/metrics.js +134 -8
- package/src/navigate.js +10 -7
- package/src/ocr.js +18 -1
- package/src/planner.js +195 -0
- package/src/platform/android.js +3 -2
- package/src/platform/ios.js +2 -1
- package/src/png.js +26 -0
- package/src/refs.js +51 -8
- package/src/regions.js +110 -1
- package/src/screenmap.js +109 -10
- package/src/supervisor.js +117 -0
- package/src/view.js +396 -7
- package/src/vocabulary.js +134 -0
- package/src/wrote.js +136 -0
package/src/input.js
CHANGED
|
@@ -248,6 +248,11 @@ export function elementToNode(e) {
|
|
|
248
248
|
type: e.role ?? null,
|
|
249
249
|
identifier: e.identifier ?? null,
|
|
250
250
|
enabled: e.state?.enabled ?? null,
|
|
251
|
+
// The daemon batches AXSelected and AXFocused alongside AXEnabled and has
|
|
252
|
+
// since 0.6.0. This converter took one of the three, and it is the one on
|
|
253
|
+
// the path that actually runs — `normalizeNode` below is the idb fallback.
|
|
254
|
+
selected: e.state?.selected ?? null,
|
|
255
|
+
focused: e.state?.focused ?? null,
|
|
251
256
|
frame: e.frame ?? null,
|
|
252
257
|
raw: e,
|
|
253
258
|
};
|
|
@@ -273,6 +278,12 @@ function normalizeNode(node) {
|
|
|
273
278
|
type: node.type ?? node.AXType ?? null,
|
|
274
279
|
identifier: node.AXUniqueId ?? node.identifier ?? null,
|
|
275
280
|
enabled: node.AXEnabled ?? node.enabled ?? null,
|
|
281
|
+
// The daemon has asked the tree for AXSelected and AXFocused since 0.6.0 —
|
|
282
|
+
// they are two of the eight attributes in its batched round trip — and this
|
|
283
|
+
// function dropped both. `view.renderRow` has printed `selected` for as
|
|
284
|
+
// long as it has existed, against a field nobody set.
|
|
285
|
+
selected: node.AXSelected ?? node.selected ?? null,
|
|
286
|
+
focused: node.AXFocused ?? node.focused ?? null,
|
|
276
287
|
frame: frame
|
|
277
288
|
? {
|
|
278
289
|
x: frame.x ?? frame.X ?? 0,
|
|
@@ -418,14 +429,77 @@ export async function typeKeys(udid, value) {
|
|
|
418
429
|
await idb(['ui', 'text', '--udid', udid, String(value)]);
|
|
419
430
|
}
|
|
420
431
|
|
|
421
|
-
|
|
432
|
+
/**
|
|
433
|
+
* Keyboard keys, by name.
|
|
434
|
+
*
|
|
435
|
+
* A peer was blocked outright for want of Return: half of mobile search fields
|
|
436
|
+
* submit on the keyboard return key, `button` covers only the hardware buttons,
|
|
437
|
+
* and `key` wanted a raw HID usage code that nobody should have to know. Typing
|
|
438
|
+
* "\n" as text is not a substitute — text goes through whatever keyboard layout
|
|
439
|
+
* iOS has active, and measured, it turned "Coke Display" into "Coke In Display".
|
|
440
|
+
*
|
|
441
|
+
* These are HID keyboard usage codes, which name a key *position* and are never
|
|
442
|
+
* translated by a layout. That property is the whole reason this path exists on
|
|
443
|
+
* a device whose own doctor warns that two extra layouts are installed.
|
|
444
|
+
*/
|
|
445
|
+
export const KEYS = {
|
|
446
|
+
return: 40, enter: 40, escape: 41, esc: 41, backspace: 42, delete: 42,
|
|
447
|
+
tab: 43, space: 44, up: 82, down: 81, left: 80, right: 79,
|
|
448
|
+
a: 4,
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
/** Modifier usage codes, held while another key is pressed. */
|
|
452
|
+
export const MODIFIERS = { control: 224, shift: 225, alt: 226, option: 226, command: 227, cmd: 227, gui: 227 };
|
|
453
|
+
|
|
454
|
+
/** The usage code for a name, a number, or null when it is neither. */
|
|
455
|
+
export function keyUsage(key) {
|
|
456
|
+
if (Number.isFinite(Number(key))) return Number(key);
|
|
457
|
+
const name = String(key ?? '').trim().toLowerCase();
|
|
458
|
+
return Object.hasOwn(KEYS, name) ? KEYS[name] : null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export async function pressKey(udid, keycode, { modifiers = [] } = {}) {
|
|
422
462
|
await ensureFreshSession(udid);
|
|
463
|
+
const usage = keyUsage(keycode);
|
|
464
|
+
const held = modifiers
|
|
465
|
+
.map((m) => (Number.isFinite(Number(m)) ? Number(m) : MODIFIERS[String(m).trim().toLowerCase()]))
|
|
466
|
+
.filter((m) => Number.isFinite(m));
|
|
467
|
+
if (usage == null) {
|
|
468
|
+
throw new Error(`unknown key ${JSON.stringify(String(keycode))} — known names: ${Object.keys(KEYS).join(', ')}`
|
|
469
|
+
+ ', or a HID usage code');
|
|
470
|
+
}
|
|
423
471
|
const own = inputDriverFor(udid);
|
|
424
472
|
if (own) {
|
|
425
|
-
await own.key(udid,
|
|
473
|
+
await own.key(udid, usage, held);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
// The daemon owns the keyboard usage path on iOS. It was implemented in the
|
|
477
|
+
// HID layer and never exposed as a verb, so this fell through to idb — which
|
|
478
|
+
// is absent on a machine using the daemon, and so there was no way to press a
|
|
479
|
+
// keyboard key at all.
|
|
480
|
+
if (control.available(udid)) {
|
|
481
|
+
await control.key(udid, usage, held);
|
|
426
482
|
return;
|
|
427
483
|
}
|
|
428
|
-
|
|
484
|
+
if (held.length) throw new Error('modifier keys need the daemon; idb cannot hold one');
|
|
485
|
+
await idb(['ui', 'key', '--udid', udid, String(usage)]);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Empty the focused field.
|
|
490
|
+
*
|
|
491
|
+
* Command-A then Delete, over HID. There is no clear primitive anywhere —
|
|
492
|
+
* XCUITest, Appium and idb all lack one, and re-typing appends — so this is the
|
|
493
|
+
* standard answer rather than a trick of ours. It is layout-independent for the
|
|
494
|
+
* reason that matters on a device with Farsi and Armenian keyboards installed:
|
|
495
|
+
* a modifier and Delete are key *positions*, and so is the `a` in Command-A, so
|
|
496
|
+
* none of the three is translated by the active layout.
|
|
497
|
+
*
|
|
498
|
+
* It clears whatever has focus, which is why every caller focuses first.
|
|
499
|
+
*/
|
|
500
|
+
export async function clearField(udid) {
|
|
501
|
+
await pressKey(udid, 'a', { modifiers: ['command'] });
|
|
502
|
+
await pressKey(udid, 'delete');
|
|
429
503
|
}
|
|
430
504
|
|
|
431
505
|
/**
|
|
@@ -506,19 +580,52 @@ export async function sessionHealth(udid) {
|
|
|
506
580
|
}
|
|
507
581
|
|
|
508
582
|
/**
|
|
509
|
-
*
|
|
583
|
+
* Should this staleness be acted on, given what has already been rebuilt?
|
|
584
|
+
*
|
|
585
|
+
* Pure, and separate from the check because the *key* is the whole bug. This
|
|
586
|
+
* gate used to be a set of udids — "once per process, per device" — and the
|
|
587
|
+
* reasoning was to avoid statting on every action. What it actually bought was
|
|
588
|
+
* that the feature could not fire in the one process that matters. A CLI
|
|
589
|
+
* command is a new process every time, so per-process is per-call there and
|
|
590
|
+
* the gate never showed; the MCP server is a single process that lives for a
|
|
591
|
+
* whole session, so it checked once, at the first action, and then never
|
|
592
|
+
* again — and a device that reboots *mid-session* is precisely the case this
|
|
593
|
+
* exists to catch. Reported from a real session: capture kept working, input
|
|
594
|
+
* died, every tap returned `ok`, and about ten calls went into two wrong
|
|
595
|
+
* conclusions about the app.
|
|
596
|
+
*
|
|
597
|
+
* The right key is the boot the rebuild was for. One attempt per device boot:
|
|
598
|
+
* enough that a failed rebuild does not retry on every tap forever, and not so
|
|
599
|
+
* much that the next boot is invisible.
|
|
600
|
+
*/
|
|
601
|
+
export function shouldRebuildSession({ stale, bootedAt }, rebuiltFor) {
|
|
602
|
+
if (!stale) return false;
|
|
603
|
+
// A boot we cannot date cannot be memoised against, and re-attempting on
|
|
604
|
+
// every action would be worse than not detecting it. sessionStaleness only
|
|
605
|
+
// reports stale with a finite bootedAt, so this is a belt, not a case.
|
|
606
|
+
if (!Number.isFinite(bootedAt)) return false;
|
|
607
|
+
return rebuiltFor !== bootedAt;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Rebuild the session if the device outlived it. Once per device boot.
|
|
510
612
|
*
|
|
511
613
|
* Rebuild, and retry nothing: this runs *before* the action, so the action is
|
|
512
614
|
* delivered on a session known to be current. Retrying afterwards is how an
|
|
513
615
|
* action fires twice, which is the hazard the verify barrier exists to
|
|
514
616
|
* prevent — and it is why the existing recovery covers hardware buttons only.
|
|
617
|
+
*
|
|
618
|
+
* The check now runs on every dispatch rather than once. It costs two small
|
|
619
|
+
* `readJson`s and, at most every three seconds, one stat — `bootedAtCached`
|
|
620
|
+
* already caps the part that was expensive, which is what made the
|
|
621
|
+
* once-per-process gate unnecessary as well as wrong.
|
|
515
622
|
*/
|
|
516
|
-
const
|
|
623
|
+
const rebuiltForBoot = new Map();
|
|
517
624
|
export async function ensureFreshSession(udid) {
|
|
518
|
-
if (!udid
|
|
519
|
-
freshened.add(udid);
|
|
625
|
+
if (!udid) return null;
|
|
520
626
|
const health = await sessionHealth(udid);
|
|
521
|
-
if (!health.
|
|
627
|
+
if (!shouldRebuildSession(health, rebuiltForBoot.get(udid))) return null;
|
|
628
|
+
rebuiltForBoot.set(udid, health.bootedAt);
|
|
522
629
|
const rebuilt = await resetSession(udid);
|
|
523
630
|
return { ...health, rebuilt };
|
|
524
631
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A warm, line-oriented local helper process.
|
|
3
|
+
*
|
|
4
|
+
* Extracted rather than duplicated, because writing this twice would mean
|
|
5
|
+
* risking the same two bugs twice — and both were subtle enough to look like
|
|
6
|
+
* something else entirely.
|
|
7
|
+
*
|
|
8
|
+
* A timed-out request left its waiter in the queue, so every later answer went
|
|
9
|
+
* to the wrong asker and the run simply never finished; it read as the model
|
|
10
|
+
* being slow. And unreferencing the child's stdout unreferenced the pipe every
|
|
11
|
+
* request waits on, so the process exited silently in the middle of an await
|
|
12
|
+
* and printed nothing at all, returning 0.
|
|
13
|
+
*
|
|
14
|
+
* The helper is kept warm because the first answer in a process pays model load
|
|
15
|
+
* — measured at ~880ms against ~560ms for every answer after it.
|
|
16
|
+
*/
|
|
17
|
+
import { spawn, execFile } from 'node:child_process';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { promisify } from 'node:util';
|
|
21
|
+
|
|
22
|
+
const run = promisify(execFile);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Compile a Swift source once and reuse the binary.
|
|
26
|
+
*
|
|
27
|
+
* `swiftc`, not `xcrun swiftc`: nothing above the platform boundary may name a
|
|
28
|
+
* platform tool, and the boundary test catches it. A compiler is not a device
|
|
29
|
+
* tool, which is the precedent `src/ocr.js` set.
|
|
30
|
+
*/
|
|
31
|
+
export function compiler({ source, binary, what }) {
|
|
32
|
+
let building = null;
|
|
33
|
+
return async function ensureBinary() {
|
|
34
|
+
if (building) return building;
|
|
35
|
+
building = (async () => {
|
|
36
|
+
try {
|
|
37
|
+
const src = fs.statSync(source).mtimeMs;
|
|
38
|
+
const bin = fs.existsSync(binary) ? fs.statSync(binary).mtimeMs : 0;
|
|
39
|
+
if (bin > src) return { available: true, binary };
|
|
40
|
+
} catch {
|
|
41
|
+
return { available: false, reason: `the ${what} source is missing from this install` };
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
fs.mkdirSync(path.dirname(binary), { recursive: true });
|
|
45
|
+
await run('swiftc', ['-O', source, '-o', binary], { timeout: 180_000 });
|
|
46
|
+
return { available: true, binary };
|
|
47
|
+
} catch (err) {
|
|
48
|
+
building = null; // let a later call retry once a toolchain is present
|
|
49
|
+
return {
|
|
50
|
+
available: false,
|
|
51
|
+
reason: err.code === 'ENOENT'
|
|
52
|
+
? `swiftc is not installed, so the ${what} cannot be built (install Xcode command line tools)`
|
|
53
|
+
: `could not build the ${what}: ${String(err.message).split('\n')[0]}`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
})();
|
|
57
|
+
return building;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Open a helper and speak JSON lines to it.
|
|
63
|
+
*
|
|
64
|
+
* @returns {{ask: (o: object, ms: number) => Promise<object|null>, close: () => void, ok: boolean, reason?: string}}
|
|
65
|
+
*/
|
|
66
|
+
export function lineServer({ ensureBinary, what }) {
|
|
67
|
+
let session = null;
|
|
68
|
+
|
|
69
|
+
async function open() {
|
|
70
|
+
if (session) return session;
|
|
71
|
+
const built = await ensureBinary();
|
|
72
|
+
if (!built.available) return { ok: false, reason: built.reason };
|
|
73
|
+
session = await new Promise((resolve) => {
|
|
74
|
+
const child = spawn(built.binary, [], { stdio: ['pipe', 'pipe', 'ignore'] });
|
|
75
|
+
// Deliberately NOT unref'd — see the note at the top of this file.
|
|
76
|
+
let buffer = '';
|
|
77
|
+
const waiters = [];
|
|
78
|
+
let settled = false;
|
|
79
|
+
const fail = (reason) => {
|
|
80
|
+
if (!settled) { settled = true; resolve({ ok: false, reason }); }
|
|
81
|
+
while (waiters.length) waiters.shift()(null);
|
|
82
|
+
};
|
|
83
|
+
child.on('error', (err) => fail(`the ${what} would not start: ${err.message}`));
|
|
84
|
+
child.on('exit', () => { session = null; fail(`the ${what} exited`); });
|
|
85
|
+
child.stdout.on('data', (chunk) => {
|
|
86
|
+
buffer += chunk;
|
|
87
|
+
let i = buffer.indexOf('\n');
|
|
88
|
+
while (i >= 0) {
|
|
89
|
+
const line = buffer.slice(0, i).trim();
|
|
90
|
+
buffer = buffer.slice(i + 1);
|
|
91
|
+
i = buffer.indexOf('\n');
|
|
92
|
+
if (!line) continue;
|
|
93
|
+
let msg;
|
|
94
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
95
|
+
if (!settled) {
|
|
96
|
+
settled = true;
|
|
97
|
+
if (msg.ready) resolve({ ok: true, child, waiters });
|
|
98
|
+
else resolve({ ok: false, reason: msg.unavailable ?? `the ${what} did not become ready` });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const next = waiters.shift();
|
|
102
|
+
if (next) next(msg);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
return session;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
async ask(question, timeoutMs = 3000) {
|
|
111
|
+
let live;
|
|
112
|
+
try {
|
|
113
|
+
live = await open();
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
if (!live?.ok) return null;
|
|
118
|
+
return new Promise((resolve) => {
|
|
119
|
+
// A timed-out waiter is retired, not merely resolved. Leaving it queued
|
|
120
|
+
// sent the next answer to it instead of to the next asker, and every
|
|
121
|
+
// call after that was off by one.
|
|
122
|
+
let done = false;
|
|
123
|
+
const waiter = (msg) => {
|
|
124
|
+
if (done) return;
|
|
125
|
+
done = true;
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
resolve(msg);
|
|
128
|
+
};
|
|
129
|
+
const timer = setTimeout(() => {
|
|
130
|
+
if (done) return;
|
|
131
|
+
done = true;
|
|
132
|
+
const i = live.waiters.indexOf(waiter);
|
|
133
|
+
if (i >= 0) live.waiters.splice(i, 1);
|
|
134
|
+
resolve(null);
|
|
135
|
+
}, timeoutMs);
|
|
136
|
+
live.waiters.push(waiter);
|
|
137
|
+
try {
|
|
138
|
+
live.child.stdin.write(`${JSON.stringify(question)}\n`);
|
|
139
|
+
} catch {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
done = true;
|
|
142
|
+
resolve(null);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
async status() {
|
|
147
|
+
const live = await open();
|
|
148
|
+
return live?.ok ? { ok: true } : { ok: false, reason: live?.reason ?? 'unavailable' };
|
|
149
|
+
},
|
|
150
|
+
close() {
|
|
151
|
+
try { session?.child?.kill(); } catch { /* already gone */ }
|
|
152
|
+
session = null;
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
package/src/matching.js
CHANGED
|
@@ -59,7 +59,25 @@ export function nameScore(name, query) {
|
|
|
59
59
|
const q = norm(query);
|
|
60
60
|
if (!n || !q) return 0;
|
|
61
61
|
if (n === q) return 1;
|
|
62
|
-
|
|
62
|
+
// A prefix match is only as good as the share it covers, and this branch had
|
|
63
|
+
// to be taught that twice.
|
|
64
|
+
//
|
|
65
|
+
// `n.startsWith(q)` — the name begins with the query, "Acce" for
|
|
66
|
+
// "Accessibility" — is the ordinary case and keeps most of its score: a
|
|
67
|
+
// prefix of a name is how people abbreviate. `q.startsWith(n)` is the
|
|
68
|
+
// opposite direction, where the *name* is a fragment of the query, and it
|
|
69
|
+
// returned the same flat 0.86 no matter how little of the query it was. So
|
|
70
|
+
// the section-index letter "S" scored 0.86 against the query "Search" and
|
|
71
|
+
// beat the search field's own label "Q Search" at 0.585 — and simframe
|
|
72
|
+
// tapped a scrubber and typed into it.
|
|
73
|
+
//
|
|
74
|
+
// The sibling branch below already carries this lesson in a comment about
|
|
75
|
+
// "back" matching a list row. Only one of the two had learned it. Both scale
|
|
76
|
+
// now, and the floor differs by direction on purpose: a query that is a
|
|
77
|
+
// prefix of a name is usually deliberate, while a name that is a fragment of
|
|
78
|
+
// the query is usually a coincidence, and one character is always one.
|
|
79
|
+
if (n.startsWith(q)) return 0.86 * Math.max(PREFIX_FLOOR, Math.min(1, q.length / n.length + 0.35));
|
|
80
|
+
if (q.startsWith(n)) return 0.8 * Math.max(0.15, n.length / q.length);
|
|
63
81
|
// A substring match is only as good as the share of the name it covers.
|
|
64
82
|
// Without this, "back" scores 0.78 against a two-hundred-character list row
|
|
65
83
|
// that happens to contain "Back of House", and beats the actual back button.
|
|
@@ -76,7 +94,31 @@ export function nameScore(name, query) {
|
|
|
76
94
|
if (distance > cap) return 0;
|
|
77
95
|
const longest = Math.max(n.length, q.length);
|
|
78
96
|
const similarity = 1 - distance / longest;
|
|
79
|
-
|
|
97
|
+
if (similarity >= 0.7) return similarity * 0.72;
|
|
98
|
+
// Last tier: OCR read a confusable character.
|
|
99
|
+
//
|
|
100
|
+
// Language correction is deliberately off, which is right for labels and
|
|
101
|
+
// wrong for exactly this. Measured on a real app, `(All)` reads back as
|
|
102
|
+
// `(AII)` and would fail an assert against the string it is; capital-I,
|
|
103
|
+
// lowercase-l, the digit one and a pipe are one shape in most UI fonts, as
|
|
104
|
+
// are capital-O and zero.
|
|
105
|
+
//
|
|
106
|
+
// Deliberately the *last* tier and discounted, not part of `norm`. Folding
|
|
107
|
+
// in `norm` would make it change what an exact match means — "Log in" and
|
|
108
|
+
// "1og in" would become the same string everywhere — and identity is not
|
|
109
|
+
// something to be fuzzy about. Here it only ever rescues a comparison that
|
|
110
|
+
// had already scored zero.
|
|
111
|
+
const foldedScore = confusableFold(n) === confusableFold(q) ? 0.62 : 0;
|
|
112
|
+
return foldedScore;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One shape per glyph family, for comparison only. Never for identity. */
|
|
116
|
+
export function confusableFold(s) {
|
|
117
|
+
return String(s ?? '')
|
|
118
|
+
.replace(/[il1|!]/gi, '1')
|
|
119
|
+
.replace(/[o0]/gi, '0')
|
|
120
|
+
.replace(/[s5]/gi, '5')
|
|
121
|
+
.replace(/[b8]/gi, '8');
|
|
80
122
|
}
|
|
81
123
|
|
|
82
124
|
function synonymGroup(query) {
|
|
@@ -159,6 +201,17 @@ export function rank(targets, intent, { screen } = {}) {
|
|
|
159
201
|
export const AMBIGUITY_MARGIN = 0.08;
|
|
160
202
|
/** Below this, no candidate is worth acting on. */
|
|
161
203
|
export const MINIMUM_SCORE = 0.45;
|
|
204
|
+
/**
|
|
205
|
+
* How much of a name's score survives scaling a prefix by its coverage.
|
|
206
|
+
*
|
|
207
|
+
* Tied to `MINIMUM_SCORE` rather than chosen: at 0.5 the shortest useful
|
|
208
|
+
* abbreviation — "Ac" for "Accessibility" — scored 0.433 and fell *below* the
|
|
209
|
+
* threshold to resolve at all, which would have turned a ranking fix into a
|
|
210
|
+
* feature removal. 0.6 puts the worst case at 0.516, comfortably resolvable and
|
|
211
|
+
* still well under a fuller match. The unit test asserts the relationship so
|
|
212
|
+
* the cliff cannot come back by someone tuning one of the two numbers.
|
|
213
|
+
*/
|
|
214
|
+
export const PREFIX_FLOOR = 0.6;
|
|
162
215
|
|
|
163
216
|
/**
|
|
164
217
|
* How close two tap points have to be to mean the same control.
|
|
@@ -284,9 +337,67 @@ function sameControl(a, b) {
|
|
|
284
337
|
// and a map built before that still resolves.
|
|
285
338
|
if (isAxTarget(a) && !isAxTarget(b) && sameElementSeenTwice(a, b)) return true;
|
|
286
339
|
if (isAxTarget(b) && !isAxTarget(a) && sameElementSeenTwice(b, a)) return true;
|
|
340
|
+
// And a caption sitting on the control it names. Containment above requires
|
|
341
|
+
// the container to be a recognised hit target, and a React Native composite
|
|
342
|
+
// is a generic element — so a form row published its caption and its select
|
|
343
|
+
// with the same label, 152 points apart, and nothing merged them. They are
|
|
344
|
+
// one control seen twice, not two candidates: answering `ambiguous` here
|
|
345
|
+
// costs a round trip to choose between a thing and its own name.
|
|
346
|
+
if (isCaptionFor(a, b) || isCaptionFor(b, a)) return true;
|
|
287
347
|
return false;
|
|
288
348
|
}
|
|
289
349
|
|
|
350
|
+
/** Does `caption` merely name `control`, overlapping it, with the same label? */
|
|
351
|
+
function isCaptionFor(caption, control) {
|
|
352
|
+
if (!namesOnly(caption) || namesOnly(control)) return false;
|
|
353
|
+
if (norm(caption.label) !== norm(control.label)) return false;
|
|
354
|
+
return overlaps(caption.frame, control.frame);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Any shared area at all — a caption's box often clears its control's by a point or two. */
|
|
358
|
+
function overlaps(a, b) {
|
|
359
|
+
if (!a || !b) return false;
|
|
360
|
+
return a.x < b.x + b.width && b.x < a.x + a.width
|
|
361
|
+
&& a.y < b.y + b.height && b.y < a.y + a.height;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Roles that only ever *name* a control, never are one.
|
|
366
|
+
*
|
|
367
|
+
* The tree publishes a caption and the control it captions with the same label,
|
|
368
|
+
* and a React Native composite (a `native-base` Select, say) surfaces as a
|
|
369
|
+
* generic element that `INTERACTIVE_ROLE` does not recognise. So `tap "Problem"`
|
|
370
|
+
* resolved to the caption at (49,486) and did nothing, while the select sat at
|
|
371
|
+
* (201,496) — reported as half of the single largest cost in a real-app run,
|
|
372
|
+
* because it means neither selector can be trusted.
|
|
373
|
+
*
|
|
374
|
+
* `collapseSamePlace` already prefers a hit target over the text printed on it,
|
|
375
|
+
* but only once `sameControl` has decided they are the same place. These two
|
|
376
|
+
* were 152 points apart with a generic role, so nothing merged them.
|
|
377
|
+
*/
|
|
378
|
+
const NAMING_ONLY_ROLE = /^(statictext|text|label|heading|image)$/i;
|
|
379
|
+
|
|
380
|
+
const namesOnly = (t) => NAMING_ONLY_ROLE.test(t?.type ?? '');
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* A caption never wins over a control that answers the same name.
|
|
384
|
+
*
|
|
385
|
+
* Applied only when the two are close enough in score to be answering the same
|
|
386
|
+
* question — a heading is still reachable when nothing else matches, which is
|
|
387
|
+
* why this promotes rather than filters. Anything further apart than
|
|
388
|
+
* `CAPTION_MARGIN` is a different match, not the same match seen twice.
|
|
389
|
+
*/
|
|
390
|
+
export const CAPTION_MARGIN = 0.2;
|
|
391
|
+
|
|
392
|
+
export function preferTheControl(ranked) {
|
|
393
|
+
if (!ranked.length || !namesOnly(ranked[0].target)) return ranked;
|
|
394
|
+
const lead = ranked[0].score;
|
|
395
|
+
const i = ranked.findIndex((c, idx) => idx > 0 && !namesOnly(c.target) && lead - c.score <= CAPTION_MARGIN);
|
|
396
|
+
if (i < 0) return ranked;
|
|
397
|
+
const promoted = { ...ranked[i], reasons: [...ranked[i].reasons, 'the control, not the caption naming it'] };
|
|
398
|
+
return [promoted, ...ranked.filter((_, idx) => idx !== i)];
|
|
399
|
+
}
|
|
400
|
+
|
|
290
401
|
function collapseSamePlace(ranked) {
|
|
291
402
|
const kept = [];
|
|
292
403
|
for (const c of ranked) {
|
|
@@ -298,6 +409,11 @@ function collapseSamePlace(ranked) {
|
|
|
298
409
|
// Prefer the real hit target: an accessibility element over OCR's reading of
|
|
299
410
|
// it, and an interactive role over a caption sitting inside it.
|
|
300
411
|
const better = (candidate, incumbent) => {
|
|
412
|
+
// A caption loses to what it names before anything else is considered:
|
|
413
|
+
// a `StaticText` is never the tap target when the control it labels is
|
|
414
|
+
// right there, whatever either one's source.
|
|
415
|
+
if (namesOnly(incumbent.target) && !namesOnly(candidate.target)) return true;
|
|
416
|
+
if (namesOnly(candidate.target) && !namesOnly(incumbent.target)) return false;
|
|
301
417
|
if (isAxTarget(candidate.target) && !isAxTarget(incumbent.target)) return true;
|
|
302
418
|
if (!isAxTarget(candidate.target) && isAxTarget(incumbent.target)) return false;
|
|
303
419
|
return INTERACTIVE_ROLE.test(candidate.target.type ?? '')
|
|
@@ -315,7 +431,7 @@ function collapseSamePlace(ranked) {
|
|
|
315
431
|
* @returns {{status: 'ok'|'ambiguous'|'none', target?, score?, reasons?, alternatives?}}
|
|
316
432
|
*/
|
|
317
433
|
export function resolve(targets, intent, options = {}) {
|
|
318
|
-
const ranked = collapseSamePlace(rank(targets, intent, options).filter((c) => c.score >= MINIMUM_SCORE));
|
|
434
|
+
const ranked = preferTheControl(collapseSamePlace(rank(targets, intent, options).filter((c) => c.score >= MINIMUM_SCORE)));
|
|
319
435
|
if (!ranked.length) return { status: 'none', alternatives: [] };
|
|
320
436
|
const [best, second] = ranked;
|
|
321
437
|
if (second && best.score - second.score < AMBIGUITY_MARGIN) {
|