simframe 0.12.2 → 0.14.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 +138 -9
- package/native/simframed/Sources/SimframeCore/Motion.swift +33 -2
- package/native/supervise.swift +63 -4
- package/package.json +1 -1
- package/scripts/article-md.mjs +185 -0
- package/scripts/ci-device-guard.mjs +82 -0
- package/scripts/ci-integration-local.sh +33 -9
- package/scripts/ci-memory.mjs +117 -15
- package/scripts/eval-fingerprint.mjs +145 -3
- package/scripts/replay-rulings.mjs +60 -1
- package/src/actions.js +213 -13
- package/src/analyze.js +56 -0
- package/src/cli.js +205 -15
- package/src/fingerprint.js +10 -1
- package/src/graph.js +15 -2
- package/src/index.js +302 -16
- package/src/input.js +4 -0
- package/src/matching.js +17 -1
- package/src/mcp.js +148 -13
- package/src/metrics.js +49 -6
- package/src/navigate.js +4 -1
- package/src/ollama.js +37 -9
- package/src/platform/android.js +34 -0
- package/src/platform/index.js +7 -0
- package/src/platform/ios.js +128 -5
- package/src/platform/plist.js +156 -0
- package/src/refs.js +12 -1
- package/src/regions.js +54 -0
- package/src/screenmap.js +85 -6
- package/src/storage.js +201 -0
- package/src/store.js +53 -0
- package/src/supervisor.js +20 -2
- package/src/view.js +84 -9
package/src/actions.js
CHANGED
|
@@ -819,6 +819,22 @@ export async function runScript(
|
|
|
819
819
|
fingerprint: beforeScreen?.hash ?? null,
|
|
820
820
|
reason: 'verification_failed',
|
|
821
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,
|
|
822
838
|
// `verification_failed` is the largest reason class in the log and it
|
|
823
839
|
// was the only one carrying no intent, which made most of the corpus
|
|
824
840
|
// useless for asking what kind of decision costs us. The step knows
|
|
@@ -854,6 +870,7 @@ export async function runScript(
|
|
|
854
870
|
// Carried from the throw site where it exists, and otherwise the step's
|
|
855
871
|
// own target — which is what was asked for either way.
|
|
856
872
|
intent: why.intent ?? goalOf(step),
|
|
873
|
+
classified: why.classified,
|
|
857
874
|
outcome: 'failed',
|
|
858
875
|
wallMs: Date.now() - stepStart,
|
|
859
876
|
detail: err.message,
|
|
@@ -2369,8 +2386,26 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
2369
2386
|
timeoutMs: step.timeoutMs ?? 8000,
|
|
2370
2387
|
options: ctx.options,
|
|
2371
2388
|
});
|
|
2372
|
-
|
|
2373
|
-
|
|
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)` : '');
|
|
2374
2409
|
}
|
|
2375
2410
|
case 'waitText': {
|
|
2376
2411
|
const target = step.value ?? step.text;
|
|
@@ -2385,10 +2420,12 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
2385
2420
|
// Same rule as `waitFor`, and `matchElement` says it in its own
|
|
2386
2421
|
// words: a query that matched several elements has found them all
|
|
2387
2422
|
// already.
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
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';
|
|
2392
2429
|
}
|
|
2393
2430
|
}
|
|
2394
2431
|
await sleep(250);
|
|
@@ -2518,7 +2555,7 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
2518
2555
|
for (let attempt = 0; ; attempt += 1) {
|
|
2519
2556
|
for (const one of alternatives) {
|
|
2520
2557
|
try {
|
|
2521
|
-
const found = await api.locate(deviceQuery, one, { refresh:
|
|
2558
|
+
const found = await api.locate(deviceQuery, one, { refresh: step.refresh !== false, options: ctx.options });
|
|
2522
2559
|
return `${JSON.stringify(one)} appeared at ${found.target.x},${found.target.y}`
|
|
2523
2560
|
+ ` (first of ${alternatives.length} awaited)`;
|
|
2524
2561
|
} catch (err) {
|
|
@@ -2526,10 +2563,12 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
2526
2563
|
}
|
|
2527
2564
|
}
|
|
2528
2565
|
if (Date.now() >= limit) {
|
|
2566
|
+
const stillAny = await stillnessNote(deviceQuery, ctx);
|
|
2529
2567
|
throw new Error(
|
|
2530
2568
|
`none of ${alternatives.length} awaited strings appeared`
|
|
2531
2569
|
+ ` (${alternatives.map((a) => JSON.stringify(a)).join(', ')}) in ${step.timeoutMs ?? 8000}ms.`
|
|
2532
|
-
+ ` Last: ${lastError}
|
|
2570
|
+
+ ` Last: ${lastError}`
|
|
2571
|
+
+ (stillAny ? ` (${stillAny.note})` : ''),
|
|
2533
2572
|
);
|
|
2534
2573
|
}
|
|
2535
2574
|
await api.waitFor(deviceQuery, { mode: 'stable', stableMs: 200, timeoutMs: 700, options: ctx.options })
|
|
@@ -2538,7 +2577,26 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
2538
2577
|
}
|
|
2539
2578
|
for (let attempt = 0; ; attempt += 1) {
|
|
2540
2579
|
try {
|
|
2541
|
-
|
|
2580
|
+
// Fresh, like `assert` — and for the same reason, found the same way.
|
|
2581
|
+
//
|
|
2582
|
+
// This read `refresh: attempt > 0`, so the FIRST look resolved against
|
|
2583
|
+
// the remembered map. A wait that can be satisfied by memory is not a
|
|
2584
|
+
// wait: if recall hands back the wrong screen's map — which it does
|
|
2585
|
+
// when two screens collide on a layout hash — the target "appears"
|
|
2586
|
+
// without ever having been on screen, instantly, and the caller
|
|
2587
|
+
// proceeds against a screen it is not on.
|
|
2588
|
+
//
|
|
2589
|
+
// Caught by the fingerprint eval on CI, which is the only place it
|
|
2590
|
+
// could show: `settings-general` read the **Settings root** and its
|
|
2591
|
+
// `waitFor "About"` had passed. The signature is in the round numbers
|
|
2592
|
+
// — r2 and r3, never r1 — because memory has to be warm before it can
|
|
2593
|
+
// lie, and round 1 is always cold.
|
|
2594
|
+
//
|
|
2595
|
+
// `assert` was made fresh by default after this exact defect cost a
|
|
2596
|
+
// reported session; `waitFor` was left as it was. One perception pass
|
|
2597
|
+
// is the price, and it is the same trade: a read is cheaper than the
|
|
2598
|
+
// round trip a wrong verdict causes.
|
|
2599
|
+
const found = await api.locate(deviceQuery, query, { index: step.index, refresh: step.refresh !== false, options: ctx.options });
|
|
2542
2600
|
return `"${found.target.label}" appeared at ${found.target.x},${found.target.y}`;
|
|
2543
2601
|
} catch (err) {
|
|
2544
2602
|
lastError = err.message;
|
|
@@ -2553,16 +2611,42 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
2553
2611
|
//
|
|
2554
2612
|
// Distinguished by the tag at the throw site rather than by reading
|
|
2555
2613
|
// the message, because "not on this screen" tags the same reason.
|
|
2614
|
+
//
|
|
2615
|
+
// And it **satisfies** the wait rather than failing it, which is the
|
|
2616
|
+
// correction. The paragraph above had the reasoning right — ambiguous
|
|
2617
|
+
// means the target is present, several times over — and then threw
|
|
2618
|
+
// anyway. A `waitFor` asks one question, *has it arrived*, and two
|
|
2619
|
+
// matches is a yes. Reported from the field: the batch was abandoned
|
|
2620
|
+
// and three queued steps discarded on a screen that was exactly where
|
|
2621
|
+
// the flow wanted to be, and the tester had to re-issue the lot with
|
|
2622
|
+
// an index. The ambiguity is real and belongs in the next step's
|
|
2623
|
+
// selector, not in this step's verdict.
|
|
2556
2624
|
if (metrics.escalationOf(err)?.ambiguous) {
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
);
|
|
2625
|
+
// The count comes from the message because the tag is a boolean —
|
|
2626
|
+
// it marks *which kind* of ambiguity, not how many.
|
|
2627
|
+
const n = err.message.match(/matches (\d+) things/)?.[1];
|
|
2628
|
+
return `${query} is on screen${n ? ` (${n} matches)` : ' more than once'}`
|
|
2629
|
+
+ ' — the wait is satisfied; pass an index or a #ref to act on one of them';
|
|
2560
2630
|
}
|
|
2561
2631
|
}
|
|
2562
2632
|
if (Date.now() >= limit) break;
|
|
2633
|
+
// Opt-in: stop early on a screen that has plainly stopped changing.
|
|
2634
|
+
if (Number.isFinite(step.failIfStillFor)) {
|
|
2635
|
+
const still = await stillnessNote(deviceQuery, ctx);
|
|
2636
|
+
if (still && still.ms >= step.failIfStillFor) {
|
|
2637
|
+
throw new Error(`gave up on ${query} after ${Date.now() - (limit - (step.timeoutMs ?? 8000))}ms:`
|
|
2638
|
+
+ ` ${still.note}, which is past the ${step.failIfStillFor}ms you said to stop at.`
|
|
2639
|
+
+ ` Last: ${lastError}`);
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2563
2642
|
await sleep(POLL_MS);
|
|
2564
2643
|
}
|
|
2565
|
-
|
|
2644
|
+
// Say how long it had been still. A wait that burned three minutes on a
|
|
2645
|
+
// screen static for the last twelve seconds should not make the reader
|
|
2646
|
+
// work that out from a second command.
|
|
2647
|
+
const still = await stillnessNote(deviceQuery, ctx);
|
|
2648
|
+
throw new Error(`waited ${step.timeoutMs ?? 8000}ms for ${query}: ${lastError}`
|
|
2649
|
+
+ (still ? ` (${still.note} — pass failIfStillFor to stop early next time)` : ''));
|
|
2566
2650
|
}
|
|
2567
2651
|
|
|
2568
2652
|
// One assert step for every condition, because `assertText` could only ask
|
|
@@ -2590,6 +2674,32 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
2590
2674
|
// is the opposite of what you want learned."*
|
|
2591
2675
|
found = await api.locate(deviceQuery, query, { index: step.index, refresh: step.refresh !== false, options: ctx.options });
|
|
2592
2676
|
} catch (err) {
|
|
2677
|
+
// Ambiguity answers "is it there", and it answers it *yes*.
|
|
2678
|
+
//
|
|
2679
|
+
// `locate` refuses a query that matches several elements, which is right
|
|
2680
|
+
// for `tap` — picking the wrong one of two taps the wrong thing — and
|
|
2681
|
+
// wrong for a question about presence. Reported from the field on
|
|
2682
|
+
// 0.13.0: `assert Administrator is visible` failed, and aborted the rest
|
|
2683
|
+
// of the batch, against a screen with **two** Administrators on it. The
|
|
2684
|
+
// assertion's own semantics were satisfied twice over. The message was
|
|
2685
|
+
// praised in the same breath — candidates, coordinates and scores — so
|
|
2686
|
+
// what was wrong was the verdict, not the diagnosis.
|
|
2687
|
+
//
|
|
2688
|
+
// The `gone` direction had the mirror-image bug and nobody had hit it
|
|
2689
|
+
// yet: any error at all returned "is gone", so a query matching two
|
|
2690
|
+
// visible elements would have reported them absent. Ambiguity is the one
|
|
2691
|
+
// error that is positive evidence of presence, and it was being read as
|
|
2692
|
+
// proof of absence.
|
|
2693
|
+
//
|
|
2694
|
+
// Strict single-match resolution stays for `enabled`, `disabled` and
|
|
2695
|
+
// `value`, where the question is about a *particular* element and
|
|
2696
|
+
// answering it from the wrong one is how a confident wrong answer gets
|
|
2697
|
+
// made.
|
|
2698
|
+
if (err.ambiguous && err.candidates?.length) {
|
|
2699
|
+
const n = err.candidates.length;
|
|
2700
|
+
if (want === 'visible') return `${query} is visible (${n} things match it here — a presence check does not have to choose)`;
|
|
2701
|
+
if (want === 'gone') throw new Error(`${query}: still here — ${n} things on this screen match it`);
|
|
2702
|
+
}
|
|
2593
2703
|
if (want === 'gone') return `${query} is gone`;
|
|
2594
2704
|
throw new Error(`${query}: ${err.message}`);
|
|
2595
2705
|
}
|
|
@@ -2657,3 +2767,93 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
2657
2767
|
throw new Error(`unknown step "${step.action}"`);
|
|
2658
2768
|
}
|
|
2659
2769
|
}
|
|
2770
|
+
|
|
2771
|
+
/**
|
|
2772
|
+
* Why a settle gave up, in the numbers that decided it.
|
|
2773
|
+
*
|
|
2774
|
+
* `screen did not settle within 25008ms` names the one quantity that is never
|
|
2775
|
+
* the reason, and two CI cycles went into guessing what was behind it. A wait
|
|
2776
|
+
* turns on three things and the message carried none of them:
|
|
2777
|
+
*
|
|
2778
|
+
* - how long the screen had actually been still when we gave up
|
|
2779
|
+
* - how much stillness was being asked for
|
|
2780
|
+
* - whether any frame arrived during the call at all
|
|
2781
|
+
*
|
|
2782
|
+
* *"Still for 1,200 ms of the 1,400 required"* and *"still for 135,000 ms and no
|
|
2783
|
+
* frame ever arrived"* are opposite diagnoses — the first is a budget a hair too
|
|
2784
|
+
* tight, the second is a stalled pipeline wearing a calm screen — and they had
|
|
2785
|
+
* one sentence between them. So did *"something is animating in the top right"*.
|
|
2786
|
+
*
|
|
2787
|
+
* Ordered by what to do next: what is moving, then how close stillness got,
|
|
2788
|
+
* then whether anything was observed, then black frames.
|
|
2789
|
+
*/
|
|
2790
|
+
export function settleEvidence(w) {
|
|
2791
|
+
const parts = [];
|
|
2792
|
+
const m = w.motion;
|
|
2793
|
+
if (m) parts.push(`the movement is ${m.where}${m.localised ? ` (${m.share}% of it)` : ''}`);
|
|
2794
|
+
if (Number.isFinite(w.stillForMs) && Number.isFinite(w.stableMsRequired)) {
|
|
2795
|
+
parts.push(`still for ${w.stillForMs}ms of the ${w.stableMsRequired}ms required`);
|
|
2796
|
+
}
|
|
2797
|
+
// Zero frames is the loud one: a wait that observed nothing has not measured
|
|
2798
|
+
// this screen, it has measured a file on disk.
|
|
2799
|
+
if (Number.isFinite(w.framesSeen)) {
|
|
2800
|
+
parts.push(w.framesSeen > 0
|
|
2801
|
+
? `${w.framesSeen} frame(s) arrived while waiting`
|
|
2802
|
+
: 'NO frame arrived while waiting — capture, not the screen');
|
|
2803
|
+
}
|
|
2804
|
+
if (w.blackFrames > 0) parts.push(`${w.blackFrames} black frame(s) — see the capture wedge`);
|
|
2805
|
+
return (parts.length ? parts.join('; ') : 'nothing observed')
|
|
2806
|
+
+ (m ? `\n${m.map}` : '');
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
/**
|
|
2810
|
+
* The one-line flow summary, so `ok` and `FAIL` each mean exactly one thing.
|
|
2811
|
+
*
|
|
2812
|
+
* `FLOW FAILED — 3/3 steps` was reported from the field as reading like a
|
|
2813
|
+
* success, and the reporter had it exactly: the denominator means *attempted*
|
|
2814
|
+
* on failure and *succeeded* on success, so the same shape carries opposite
|
|
2815
|
+
* meanings. `flow completed — 5/5 steps` and `FLOW FAILED — 3/3 steps` differ
|
|
2816
|
+
* only in a word, and the numbers argue against the word.
|
|
2817
|
+
*
|
|
2818
|
+
* On failure it says how many worked and how many did not, which is the thing a
|
|
2819
|
+
* caller has to know to decide whether to resume or re-plan.
|
|
2820
|
+
*/
|
|
2821
|
+
export function flowSummary(res, { withTime = true } = {}) {
|
|
2822
|
+
const time = withTime && Number.isFinite(res.totalMs) ? ` in ${res.totalMs}ms` : '';
|
|
2823
|
+
if (res.ok) return `flow completed — ${res.ranSteps}/${res.totalSteps} steps${time}`;
|
|
2824
|
+
const failed = (res.results ?? []).filter((r) => r.ok === false).length || 1;
|
|
2825
|
+
const worked = Math.max(0, res.ranSteps - failed);
|
|
2826
|
+
const unattempted = Math.max(0, res.totalSteps - res.ranSteps);
|
|
2827
|
+
return `FLOW FAILED — ${worked} ok, ${failed} failed`
|
|
2828
|
+
+ (unattempted ? `, ${unattempted} not attempted` : '')
|
|
2829
|
+
+ ` (of ${res.totalSteps})${time}`;
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
/**
|
|
2833
|
+
* How long the screen has been still, for a wait that is about to give up.
|
|
2834
|
+
*
|
|
2835
|
+
* A field report: a 180-second wait for a control that never appeared, on a
|
|
2836
|
+
* screen that had been static for about twelve of those seconds — the app had
|
|
2837
|
+
* logged itself out and was sitting on a login form. The failure message was
|
|
2838
|
+
* praised for listing what *was* on screen; it arrived three minutes late.
|
|
2839
|
+
*
|
|
2840
|
+
* Stillness is already tracked and already printed by other commands, so the
|
|
2841
|
+
* information existed and this wait simply never asked for it. Reported on
|
|
2842
|
+
* every timeout, and — only when the caller opts in — allowed to end the wait
|
|
2843
|
+
* early. Opt-in and not default, because a still screen is exactly what a
|
|
2844
|
+
* pending network call looks like: the target may yet arrive, and a wait that
|
|
2845
|
+
* gave up on stillness alone would break the case waits exist for.
|
|
2846
|
+
*
|
|
2847
|
+
* This is only trustworthy because of 134. Before the animation threshold was
|
|
2848
|
+
* measured, a completely static screen claimed something was animating on 54%
|
|
2849
|
+
* of its frames, and "nothing has moved" could not be said with a straight face.
|
|
2850
|
+
*/
|
|
2851
|
+
async function stillnessNote(deviceQuery, ctx) {
|
|
2852
|
+
try {
|
|
2853
|
+
const { state } = await api.getState(deviceQuery, { options: ctx.options });
|
|
2854
|
+
const ms = state?.stableForMs;
|
|
2855
|
+
return Number.isFinite(ms) ? { ms, note: `the screen has not moved for ${Math.round(ms)}ms` } : null;
|
|
2856
|
+
} catch {
|
|
2857
|
+
return null;
|
|
2858
|
+
}
|
|
2859
|
+
}
|
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('');
|