simframe 0.12.1 → 0.13.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 +163 -9
- package/native/supervise.swift +63 -4
- package/package.json +1 -1
- package/scripts/article-md.mjs +185 -0
- package/scripts/ci-integration-local.sh +22 -1
- package/scripts/ci-memory.mjs +98 -15
- package/scripts/collect-rulings.mjs +14 -0
- package/scripts/eval-fingerprint.mjs +48 -3
- package/scripts/replay-rulings.mjs +206 -0
- package/scripts/score-rulings.mjs +19 -1
- package/src/actions.js +158 -18
- package/src/analyze.js +56 -0
- package/src/cli.js +215 -18
- package/src/fingerprint.js +10 -1
- package/src/index.js +337 -12
- package/src/input.js +4 -0
- package/src/mcp.js +7 -1
- package/src/metrics.js +68 -7
- package/src/navigate.js +28 -2
- package/src/ollama.js +232 -0
- package/src/platform/ios.js +30 -5
- package/src/refs.js +12 -1
- package/src/regions.js +54 -0
- package/src/screenmap.js +161 -6
- package/src/store.js +53 -0
- package/src/supervisor.js +108 -5
- package/src/view.js +84 -9
package/src/actions.js
CHANGED
|
@@ -260,6 +260,8 @@ export async function runScript(
|
|
|
260
260
|
expect,
|
|
261
261
|
failure,
|
|
262
262
|
outcome,
|
|
263
|
+
supervisor: supervisor.requested(options),
|
|
264
|
+
situation: ruling?.context?.situation ?? null,
|
|
263
265
|
});
|
|
264
266
|
} catch {
|
|
265
267
|
/* instrumentation must not be able to fail a flow it is only watching */
|
|
@@ -354,12 +356,19 @@ export async function runScript(
|
|
|
354
356
|
}),
|
|
355
357
|
observedMs: null,
|
|
356
358
|
};
|
|
359
|
+
// Where the hands actually went, filled in by the step that moved them.
|
|
360
|
+
//
|
|
361
|
+
// Item 120 needs the *resolved* point, not the one in the script: an
|
|
362
|
+
// image-space `tapAt` is converted inside the step, and `scroll` invents
|
|
363
|
+
// its coordinates from the screen size. Reading them back off the step
|
|
364
|
+
// would diagnose a point nothing was ever aimed at.
|
|
365
|
+
const aim = { at: null };
|
|
357
366
|
let detail;
|
|
358
367
|
// A selector that did not resolve gets the step's own alternatives before
|
|
359
368
|
// the batch is abandoned. Anything else propagates: retrying from a screen
|
|
360
369
|
// we did not expect to be on is not a retry, it is a second guess.
|
|
361
370
|
try {
|
|
362
|
-
detail = await runStep(deviceQuery, udid, step, { screen, options, frames, focus });
|
|
371
|
+
detail = await runStep(deviceQuery, udid, step, { screen, options, frames, focus, aim });
|
|
363
372
|
} catch (thrown) {
|
|
364
373
|
let err = thrown;
|
|
365
374
|
// Ask the supervisor before anything is abandoned. It sits behind the
|
|
@@ -393,7 +402,7 @@ export async function runScript(
|
|
|
393
402
|
options,
|
|
394
403
|
}).catch(() => null);
|
|
395
404
|
try {
|
|
396
|
-
detail = await runStep(deviceQuery, udid, step, { screen, options, frames, focus });
|
|
405
|
+
detail = await runStep(deviceQuery, udid, step, { screen, options, frames, focus, aim });
|
|
397
406
|
detail += ` [the local supervisor said ${ruling.decision}; it worked on the second attempt]`;
|
|
398
407
|
ruled('recovered');
|
|
399
408
|
continue;
|
|
@@ -433,7 +442,7 @@ export async function runScript(
|
|
|
433
442
|
let last = err;
|
|
434
443
|
for (const label of allowed) {
|
|
435
444
|
try {
|
|
436
|
-
detail = await runStep(deviceQuery, udid, stepWithTarget(step, label), { screen, options, frames, focus });
|
|
445
|
+
detail = await runStep(deviceQuery, udid, stepWithTarget(step, label), { screen, options, frames, focus, aim });
|
|
437
446
|
detail += ` [after ${tried.map((t) => JSON.stringify(String(t))).join(', ')} did not resolve]`;
|
|
438
447
|
last = null;
|
|
439
448
|
break;
|
|
@@ -729,10 +738,40 @@ export async function runScript(
|
|
|
729
738
|
? ' [the screen did not change, so this app was already in front — or it did not come forward]'
|
|
730
739
|
: '';
|
|
731
740
|
const filling = stillFillingIn(afterReading?.entry);
|
|
741
|
+
// Item 120: when a gesture aimed at a coordinate does nothing, say what
|
|
742
|
+
// that coordinate resolved to. The element that swallowed it is normally
|
|
743
|
+
// already in the list printed under this very verdict — the gap was
|
|
744
|
+
// never the data, it was that nobody connected "started at y=750" to
|
|
745
|
+
// "there is a banner at y=753".
|
|
746
|
+
//
|
|
747
|
+
// Diagnosed against the screen as it was *before* the action, because
|
|
748
|
+
// that is the screen the finger landed on. Using the after-reading would
|
|
749
|
+
// describe the world the gesture failed to change.
|
|
750
|
+
// Both signals, because they are not the same one and only one of them
|
|
751
|
+
// fires in the reported case. `settled.noVisibleChange` is the pixel
|
|
752
|
+
// detector saying the frame never moved; the `no-visible-change` verdict
|
|
753
|
+
// is the fingerprint saying we are on the screen we started on. A swipe
|
|
754
|
+
// into a search bar settled in 62ms and produced the second without the
|
|
755
|
+
// first — and six swipes reporting "no visible change" is what item 120
|
|
756
|
+
// was reported against. Keying on one of them would have shipped a fix
|
|
757
|
+
// that did not fire on its own bug report.
|
|
758
|
+
const wentNowhere = Boolean(settled?.noVisibleChange) || verification?.verdict === 'no-visible-change';
|
|
759
|
+
const aimNote = wentNowhere && aim.at
|
|
760
|
+
? screenmap.describePoint(
|
|
761
|
+
// `beforeScreen` is null with verification off, and that is exactly
|
|
762
|
+
// the mode someone falls back to when coordinates are misbehaving.
|
|
763
|
+
// A stored map keyed on the pre-action frame costs one file read.
|
|
764
|
+
beforeScreen?.entry ?? screenmap.recall(udid, before),
|
|
765
|
+
aim.at.point,
|
|
766
|
+
{ what: aim.at.what },
|
|
767
|
+
)
|
|
768
|
+
: null;
|
|
732
769
|
const note = launchNote
|
|
733
770
|
+ (filling ? ` [settled, but ${filling} — waitFor content, do not act on this yet]` : '')
|
|
734
771
|
+ (settled?.smallChange ? ' [a small change, in one region only]' : '')
|
|
735
772
|
+ (settled?.noVisibleChange ? ' [no visible change]' : '')
|
|
773
|
+
// After the symptom, because it is the explanation of it.
|
|
774
|
+
+ (aimNote ? ` [${aimNote}]` : '')
|
|
736
775
|
+ (settled?.staleBaseline ? ' [baseline had already settled; re-taken from the live screen]' : '')
|
|
737
776
|
+ (settled?.blackFrames
|
|
738
777
|
? ` [${settled.blackFrames} black frame(s) waited through${settled.blackMs ? `, still black after ${settled.blackMs}ms` : ''}]`
|
|
@@ -780,6 +819,22 @@ export async function runScript(
|
|
|
780
819
|
fingerprint: beforeScreen?.hash ?? null,
|
|
781
820
|
reason: 'verification_failed',
|
|
782
821
|
candidates: [],
|
|
822
|
+
// Assumed, not read. A verdict says the step did not do what was
|
|
823
|
+
// expected; it does not say which faculty would have prevented that.
|
|
824
|
+
//
|
|
825
|
+
// Measured in the field and it matters: on an app whose controls are
|
|
826
|
+
// largely unlabeled, `no-visible-change` came overwhelmingly from
|
|
827
|
+
// tapping an inert text label whose real hit target was an invisible
|
|
828
|
+
// chevron — icon semantics, Phase 15 — while this line filed every
|
|
829
|
+
// one as evidence about sense of time, Phase 11. The tester reached
|
|
830
|
+
// the right conclusion from their own notes and our instrument
|
|
831
|
+
// disagreed with them. It was wrong.
|
|
832
|
+
//
|
|
833
|
+
// Not reclassified here, because `no-visible-change` also covers a
|
|
834
|
+
// switch moving 0.1% of the screen, which is neither faculty. Saying
|
|
835
|
+
// "assumed" is the honest answer; guessing a better-sounding reason
|
|
836
|
+
// would be the same mistake in the other direction.
|
|
837
|
+
classified: false,
|
|
783
838
|
// `verification_failed` is the largest reason class in the log and it
|
|
784
839
|
// was the only one carrying no intent, which made most of the corpus
|
|
785
840
|
// useless for asking what kind of decision costs us. The step knows
|
|
@@ -815,6 +870,7 @@ export async function runScript(
|
|
|
815
870
|
// Carried from the throw site where it exists, and otherwise the step's
|
|
816
871
|
// own target — which is what was asked for either way.
|
|
817
872
|
intent: why.intent ?? goalOf(step),
|
|
873
|
+
classified: why.classified,
|
|
818
874
|
outcome: 'failed',
|
|
819
875
|
wallMs: Date.now() - stepStart,
|
|
820
876
|
detail: err.message,
|
|
@@ -1215,17 +1271,25 @@ async function superviseFailure(deviceQuery, { goal, step, expected, err, option
|
|
|
1215
1271
|
try {
|
|
1216
1272
|
timing = udid && map.identity ? graph.timingFor(udid, map.identity, step) : null;
|
|
1217
1273
|
} catch { /* an unknown edge is a fact about the graph, not a failure here */ }
|
|
1218
|
-
|
|
1219
|
-
|
|
1274
|
+
// The question, kept — not just the answer.
|
|
1275
|
+
//
|
|
1276
|
+
// Three items (101, 96, 106) want to ask "what would a different judge have
|
|
1277
|
+
// said about these same situations", and until now the log held the verdict
|
|
1278
|
+
// and threw away what was asked. So comparing two supervisors meant driving
|
|
1279
|
+
// the device once per arm, which is thirty minutes an arm and introduces
|
|
1280
|
+
// the device's own variance into a comparison that is supposed to be about
|
|
1281
|
+
// the judges. With this, one device pass produces a population every arm
|
|
1282
|
+
// can be asked, on identical inputs.
|
|
1283
|
+
const situation = {
|
|
1284
|
+
goal: goal ?? null,
|
|
1220
1285
|
step: `${step.action} ${JSON.stringify(String(step.value ?? step.target ?? step.into ?? step.seek ?? '').slice(0, 60))}`,
|
|
1221
|
-
expected,
|
|
1286
|
+
expected: expected ?? null,
|
|
1222
1287
|
failure: err.message,
|
|
1223
1288
|
screen: (map.rows ?? []).filter((r) => r.label).map((r) => r.label),
|
|
1224
1289
|
stillMs,
|
|
1225
1290
|
note: stillFillingIn(map.identity?.entry),
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
});
|
|
1291
|
+
};
|
|
1292
|
+
const ruling = await supervisor.judge({ ...situation, options, detail });
|
|
1229
1293
|
if (!ruling) return null;
|
|
1230
1294
|
return {
|
|
1231
1295
|
...ruling,
|
|
@@ -1237,6 +1301,7 @@ async function superviseFailure(deviceQuery, { goal, step, expected, err, option
|
|
|
1237
1301
|
stillMs,
|
|
1238
1302
|
p95: timing?.p95 ?? null,
|
|
1239
1303
|
samples: timing?.samples ?? null,
|
|
1304
|
+
situation,
|
|
1240
1305
|
},
|
|
1241
1306
|
};
|
|
1242
1307
|
} catch {
|
|
@@ -2166,6 +2231,7 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
2166
2231
|
pointHeight: geo.pointHeight,
|
|
2167
2232
|
}));
|
|
2168
2233
|
}
|
|
2234
|
+
if (ctx.aim) ctx.aim.at = { point: { x, y }, what: 'the tap point' };
|
|
2169
2235
|
await input.tapPoint(udid, x, y, { durationMs: step.durationMs });
|
|
2170
2236
|
return `tapped ${x},${y}`;
|
|
2171
2237
|
}
|
|
@@ -2253,6 +2319,9 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
2253
2319
|
case 'swipe': {
|
|
2254
2320
|
const from = { x: step.from?.[0] ?? step.from?.x, y: step.from?.[1] ?? step.from?.y };
|
|
2255
2321
|
const to = { x: step.to?.[0] ?? step.to?.x, y: step.to?.[1] ?? step.to?.y };
|
|
2322
|
+
// The start point only. A swipe is captured by whatever the finger goes
|
|
2323
|
+
// down on; where it lifts never decides who received it.
|
|
2324
|
+
if (ctx.aim) ctx.aim.at = { point: from, what: 'the swipe start point' };
|
|
2256
2325
|
await input.swipe(udid, from, to, { durationMs: step.durationMs });
|
|
2257
2326
|
return `swiped ${from.x},${from.y} -> ${to.x},${to.y}`;
|
|
2258
2327
|
}
|
|
@@ -2269,6 +2338,7 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
2269
2338
|
right: [{ x: midX - span, y: midY }, { x: midX + span, y: midY }],
|
|
2270
2339
|
};
|
|
2271
2340
|
if (!moves[dir]) throw new Error(`unknown scroll direction "${dir}"`);
|
|
2341
|
+
if (ctx.aim) ctx.aim.at = { point: moves[dir][0], what: 'the scroll start point' };
|
|
2272
2342
|
await input.swipe(udid, moves[dir][0], moves[dir][1], { durationMs: step.durationMs ?? 250 });
|
|
2273
2343
|
return `scrolled ${dir}`;
|
|
2274
2344
|
}
|
|
@@ -2316,8 +2386,26 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
2316
2386
|
timeoutMs: step.timeoutMs ?? 8000,
|
|
2317
2387
|
options: ctx.options,
|
|
2318
2388
|
});
|
|
2319
|
-
|
|
2320
|
-
|
|
2389
|
+
// Say where it is still moving — item 123.
|
|
2390
|
+
//
|
|
2391
|
+
// "did not settle within 25000ms" is true and unactionable, and a failed
|
|
2392
|
+
// step aborts the rest of the batch, so a spinner nobody cares about can
|
|
2393
|
+
// cost five steps that would have worked. The reporter who asked for this
|
|
2394
|
+
// had landed on `pause` plus `continueOnError` and called it *"strictly
|
|
2395
|
+
// worse than a settle that knows what to ignore"*. Knowing what to ignore
|
|
2396
|
+
// is the expensive half and is not built. Saying where is nearly free —
|
|
2397
|
+
// the signatures were already read to detect small changes — and it is
|
|
2398
|
+
// the difference between re-planning a flow and ignoring a corner of it.
|
|
2399
|
+
if (!w.satisfied) {
|
|
2400
|
+
if (w.stalled) throw new Error(w.live.note);
|
|
2401
|
+
throw new Error(`screen did not settle within ${w.waitedMs}ms — ${settleEvidence(w)}`);
|
|
2402
|
+
}
|
|
2403
|
+
// A settle that satisfied while part of the screen is still moving says
|
|
2404
|
+
// so. The daemon has boxed the moving part all along and nothing read it;
|
|
2405
|
+
// measured at 79s of claimed stillness on a screen animating at 85ms a
|
|
2406
|
+
// frame. Not treated as a failure — see the note on `animatingNow`.
|
|
2407
|
+
return `settled after ${w.waitedMs}ms`
|
|
2408
|
+
+ (w.animating ? ` (a ${w.animating.width}x${w.animating.height} region is still animating)` : '');
|
|
2321
2409
|
}
|
|
2322
2410
|
case 'waitText': {
|
|
2323
2411
|
const target = step.value ?? step.text;
|
|
@@ -2332,10 +2420,12 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
2332
2420
|
// Same rule as `waitFor`, and `matchElement` says it in its own
|
|
2333
2421
|
// words: a query that matched several elements has found them all
|
|
2334
2422
|
// already.
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2423
|
+
// Satisfies the wait, for the same reason as `waitFor` above: several
|
|
2424
|
+
// matches is an answer of "yes, it is here".
|
|
2425
|
+
const many = err.message.match(/matched (\d+) elements/);
|
|
2426
|
+
if (many) {
|
|
2427
|
+
return `"${target}" is on screen (${many[1]} matches)`
|
|
2428
|
+
+ ' — the wait is satisfied; pass an index to act on one of them';
|
|
2339
2429
|
}
|
|
2340
2430
|
}
|
|
2341
2431
|
await sleep(250);
|
|
@@ -2500,10 +2590,22 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
2500
2590
|
//
|
|
2501
2591
|
// Distinguished by the tag at the throw site rather than by reading
|
|
2502
2592
|
// the message, because "not on this screen" tags the same reason.
|
|
2593
|
+
//
|
|
2594
|
+
// And it **satisfies** the wait rather than failing it, which is the
|
|
2595
|
+
// correction. The paragraph above had the reasoning right — ambiguous
|
|
2596
|
+
// means the target is present, several times over — and then threw
|
|
2597
|
+
// anyway. A `waitFor` asks one question, *has it arrived*, and two
|
|
2598
|
+
// matches is a yes. Reported from the field: the batch was abandoned
|
|
2599
|
+
// and three queued steps discarded on a screen that was exactly where
|
|
2600
|
+
// the flow wanted to be, and the tester had to re-issue the lot with
|
|
2601
|
+
// an index. The ambiguity is real and belongs in the next step's
|
|
2602
|
+
// selector, not in this step's verdict.
|
|
2503
2603
|
if (metrics.escalationOf(err)?.ambiguous) {
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
);
|
|
2604
|
+
// The count comes from the message because the tag is a boolean —
|
|
2605
|
+
// it marks *which kind* of ambiguity, not how many.
|
|
2606
|
+
const n = err.message.match(/matches (\d+) things/)?.[1];
|
|
2607
|
+
return `${query} is on screen${n ? ` (${n} matches)` : ' more than once'}`
|
|
2608
|
+
+ ' — the wait is satisfied; pass an index or a #ref to act on one of them';
|
|
2507
2609
|
}
|
|
2508
2610
|
}
|
|
2509
2611
|
if (Date.now() >= limit) break;
|
|
@@ -2604,3 +2706,41 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
2604
2706
|
throw new Error(`unknown step "${step.action}"`);
|
|
2605
2707
|
}
|
|
2606
2708
|
}
|
|
2709
|
+
|
|
2710
|
+
/**
|
|
2711
|
+
* Why a settle gave up, in the numbers that decided it.
|
|
2712
|
+
*
|
|
2713
|
+
* `screen did not settle within 25008ms` names the one quantity that is never
|
|
2714
|
+
* the reason, and two CI cycles went into guessing what was behind it. A wait
|
|
2715
|
+
* turns on three things and the message carried none of them:
|
|
2716
|
+
*
|
|
2717
|
+
* - how long the screen had actually been still when we gave up
|
|
2718
|
+
* - how much stillness was being asked for
|
|
2719
|
+
* - whether any frame arrived during the call at all
|
|
2720
|
+
*
|
|
2721
|
+
* *"Still for 1,200 ms of the 1,400 required"* and *"still for 135,000 ms and no
|
|
2722
|
+
* frame ever arrived"* are opposite diagnoses — the first is a budget a hair too
|
|
2723
|
+
* tight, the second is a stalled pipeline wearing a calm screen — and they had
|
|
2724
|
+
* one sentence between them. So did *"something is animating in the top right"*.
|
|
2725
|
+
*
|
|
2726
|
+
* Ordered by what to do next: what is moving, then how close stillness got,
|
|
2727
|
+
* then whether anything was observed, then black frames.
|
|
2728
|
+
*/
|
|
2729
|
+
export function settleEvidence(w) {
|
|
2730
|
+
const parts = [];
|
|
2731
|
+
const m = w.motion;
|
|
2732
|
+
if (m) parts.push(`the movement is ${m.where}${m.localised ? ` (${m.share}% of it)` : ''}`);
|
|
2733
|
+
if (Number.isFinite(w.stillForMs) && Number.isFinite(w.stableMsRequired)) {
|
|
2734
|
+
parts.push(`still for ${w.stillForMs}ms of the ${w.stableMsRequired}ms required`);
|
|
2735
|
+
}
|
|
2736
|
+
// Zero frames is the loud one: a wait that observed nothing has not measured
|
|
2737
|
+
// this screen, it has measured a file on disk.
|
|
2738
|
+
if (Number.isFinite(w.framesSeen)) {
|
|
2739
|
+
parts.push(w.framesSeen > 0
|
|
2740
|
+
? `${w.framesSeen} frame(s) arrived while waiting`
|
|
2741
|
+
: 'NO frame arrived while waiting — capture, not the screen');
|
|
2742
|
+
}
|
|
2743
|
+
if (w.blackFrames > 0) parts.push(`${w.blackFrames} black frame(s) — see the capture wedge`);
|
|
2744
|
+
return (parts.length ? parts.join('; ') : 'nothing observed')
|
|
2745
|
+
+ (m ? `\n${m.map}` : '');
|
|
2746
|
+
}
|
package/src/analyze.js
CHANGED
|
@@ -65,6 +65,25 @@ export function signatureDiff(a, b) {
|
|
|
65
65
|
*/
|
|
66
66
|
export const CELL_CHANGE = 0.012;
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* How far two *capture paths* may differ and still be looking at one screen.
|
|
70
|
+
*
|
|
71
|
+
* Not the change threshold, and the distinction matters. `signatureDiff > 0.004`
|
|
72
|
+
* asks whether a screen moved between two frames from the *same* path. This asks
|
|
73
|
+
* whether the daemon's frame and an independent `simctl` screenshot show the
|
|
74
|
+
* same thing — and they never match closely, because one is downscaled by the
|
|
75
|
+
* capture loop and the other is a full-resolution PNG scaled here.
|
|
76
|
+
*
|
|
77
|
+
* Measured on this device, which is the only reason a number appears:
|
|
78
|
+
*
|
|
79
|
+
* same screen, two paths 0.00123 (three runs, identical to five places)
|
|
80
|
+
* two different screens 0.68603 (before and after a home press)
|
|
81
|
+
*
|
|
82
|
+
* A separation of 550x, so the threshold is not delicate. 0.02 is sixteen times
|
|
83
|
+
* the scaling cost and thirty-four times under the signal.
|
|
84
|
+
*/
|
|
85
|
+
export const PATHS_AGREE = 0.02;
|
|
86
|
+
|
|
68
87
|
/** The largest single-region change between two signatures, 0-1. */
|
|
69
88
|
export function maxCellDelta(a, b) {
|
|
70
89
|
const deltas = regionDeltas(a, b);
|
|
@@ -100,6 +119,43 @@ export function regionMap(deltas, cols = REGION_COLS) {
|
|
|
100
119
|
return lines.join('\n');
|
|
101
120
|
}
|
|
102
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Where the movement is, in words — item 123.
|
|
124
|
+
*
|
|
125
|
+
* A settle that gives up says only that it gave up, and the reporter who asked
|
|
126
|
+
* for this put the cost plainly: a streaming summary panel, a Lottie and a
|
|
127
|
+
* support widget never settle, so the settle fails, and because a failed step
|
|
128
|
+
* aborts the batch it takes the other five steps with it. Their workaround was
|
|
129
|
+
* `pause` plus `continueOnError`, which they called *"strictly worse than a
|
|
130
|
+
* settle that knows what to ignore"*. Knowing what to ignore is the expensive
|
|
131
|
+
* half. Saying **where** is nearly free, and it is what turns "did not settle"
|
|
132
|
+
* into "there is a spinner in the top-right and nobody cares about it".
|
|
133
|
+
*
|
|
134
|
+
* Deliberately coarse. Thirds of the screen in each axis, named the way a person
|
|
135
|
+
* would point at it, and a share so a caller can tell one spinner from a screen
|
|
136
|
+
* that is genuinely still in flight.
|
|
137
|
+
*/
|
|
138
|
+
export function describeMotion(deltas, cols = REGION_COLS) {
|
|
139
|
+
const total = deltas.reduce((a, b) => a + b, 0);
|
|
140
|
+
if (!deltas.length || total <= 0) return null;
|
|
141
|
+
const rows = Math.ceil(deltas.length / cols);
|
|
142
|
+
const bands = new Map();
|
|
143
|
+
const third = (i, n) => (i < n / 3 ? 0 : i < (2 * n) / 3 ? 1 : 2);
|
|
144
|
+
const DOWN = ['top', 'middle', 'bottom'];
|
|
145
|
+
const ACROSS = ['left', 'centre', 'right'];
|
|
146
|
+
deltas.forEach((d, i) => {
|
|
147
|
+
const key = `${DOWN[third(Math.floor(i / cols), rows)]}-${ACROSS[third(i % cols, cols)]}`;
|
|
148
|
+
bands.set(key, (bands.get(key) ?? 0) + d);
|
|
149
|
+
});
|
|
150
|
+
const ranked = [...bands.entries()].sort((a, b) => b[1] - a[1]);
|
|
151
|
+
const [where, amount] = ranked[0];
|
|
152
|
+
const share = Math.round((amount / total) * 100);
|
|
153
|
+
// A single band holding most of the movement is a localised animation; spread
|
|
154
|
+
// evenly, the whole screen is in flight and naming a corner would mislead.
|
|
155
|
+
if (share < 40) return { where: 'spread across the screen', share, localised: false };
|
|
156
|
+
return { where: where.replace('-', ' '), share, localised: true };
|
|
157
|
+
}
|
|
158
|
+
|
|
103
159
|
/** Signatures live in state.json, so they are stored as compact hex. */
|
|
104
160
|
export function signatureToHex(sig) {
|
|
105
161
|
return sig.map((v) => v.toString(16).padStart(2, '0')).join('');
|