feat(scan): card-ready gating replaces the fixed settle delay

Adds src/lib/cardReadyGate.ts: after a tile click, poll the detail fingerprint
until it has both changed from the previous artifact and stabilized across
consecutive samples, instead of waiting a hardcoded 280ms and hoping.

- Faster on quick machines (proceeds as soon as the card is stable), correct on
  slow ones (waits up to the budget).
- Robust to particle/hover-glow animation: requiring two consecutive equal
  samples ignores single-frame noise, and if the card never fully stabilizes it
  still proceeds once the content has changed rather than looping on an animated
  frame.
- ESC/stop abort is honored between polls via checkAbort.

autoScanLoop now uses waitForCardReady for both the initial read and the one
retry; CLICK_SETTLE_MS removed. 6 new unit tests; 94 total green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-07-05 21:47:03 +02:00
parent 2c6c1a8b31
commit 6ea3d9e714
3 changed files with 191 additions and 16 deletions
+29 -16
View File
@@ -3,6 +3,7 @@ import type { ParsedArtifactCandidate } from "./artifactOcrParser";
import { sessionSignature } from "./artifactStore";
import { buildGridModel, buildInventoryPagePlan, type GridTarget } from "./automationPlanner";
import { classifyAutoScanCapture, shouldAbortAfterConsecutiveMisses } from "./autoScanController";
import { waitForCardReady } from "./cardReadyGate";
import { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "./scannerCaptureQuality";
import type { AutoScanStats, ScanSummary } from "./scannerSession";
import { clampSkipRows, emptyAutoScanStats, resolveScanTargetCount } from "./scannerSession";
@@ -59,7 +60,11 @@ export type AutoScanLoopResult = {
targetCount: number;
};
const CLICK_SETTLE_MS = 280;
// Card-ready gating replaces a fixed settle delay: poll the detail fingerprint
// until it has changed and stabilized (or the budget is spent). See cardReadyGate.
const CARD_READY_MAX_MS = 900;
const CARD_READY_POLL_MS = 90;
const CARD_READY_STABLE_SAMPLES = 2;
const MISS_ABORT_THRESHOLD = 3;
const UNREADABLE_ABORT_THRESHOLD = 5;
@@ -162,6 +167,19 @@ export async function runAutoScanLoop(
if (initialParsed) lastDetailSignature = sessionSignature(initialParsed);
let lastDetailViewFingerprint = detailFingerprint(currentCapture);
async function awaitCardReady() {
return waitForCardReady(
{
sampleFingerprint: async () => detailFingerprint(await captureFastSelectedSource(0, true)),
wait,
now: () => Date.now(),
checkAbort: checkGuard,
},
lastDetailViewFingerprint,
{ minStableSamples: CARD_READY_STABLE_SAMPLES, maxWaitMs: CARD_READY_MAX_MS, pollIntervalMs: CARD_READY_POLL_MS },
);
}
try {
while (!blockedReason && !shouldStop() && stats.clicked < maxTargets) {
page++;
@@ -224,16 +242,13 @@ export async function runAutoScanLoop(
break;
}
let waitStop = await waitDuringScan(CLICK_SETTLE_MS);
if (waitStop) {
blockedReason = waitStop;
let ready = await awaitCardReady();
if (ready.abortReason) {
blockedReason = ready.abortReason;
aborted = true;
break;
}
let previewCapture = await captureFastSelectedSource(0, true);
let previewFingerprint = detailFingerprint(previewCapture);
let changedDetail = Boolean(previewFingerprint && previewFingerprint !== lastDetailViewFingerprint);
let changedDetail = ready.changed;
if (!changedDetail) {
appendAutomationLog(`retry r${target.row} c${target.col}: Detailansicht unveraendert`);
@@ -244,15 +259,13 @@ export async function runAutoScanLoop(
aborted = true;
break;
}
waitStop = await waitDuringScan(CLICK_SETTLE_MS);
if (waitStop) {
blockedReason = waitStop;
ready = await awaitCardReady();
if (ready.abortReason) {
blockedReason = ready.abortReason;
aborted = true;
break;
}
previewCapture = await captureFastSelectedSource(0, true);
previewFingerprint = detailFingerprint(previewCapture);
changedDetail = Boolean(previewFingerprint && previewFingerprint !== lastDetailViewFingerprint);
changedDetail = ready.changed;
}
if (!changedDetail) {
@@ -482,6 +495,6 @@ export function fingerprintDataUrl(dataUrl: string) {
return `${dataUrl.length.toString(16)}:${(hash >>> 0).toString(16)}`;
}
function wait(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
function wait(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(() => resolve(), ms));
}