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:
+29
-16
@@ -3,6 +3,7 @@ import type { ParsedArtifactCandidate } from "./artifactOcrParser";
|
|||||||
import { sessionSignature } from "./artifactStore";
|
import { sessionSignature } from "./artifactStore";
|
||||||
import { buildGridModel, buildInventoryPagePlan, type GridTarget } from "./automationPlanner";
|
import { buildGridModel, buildInventoryPagePlan, type GridTarget } from "./automationPlanner";
|
||||||
import { classifyAutoScanCapture, shouldAbortAfterConsecutiveMisses } from "./autoScanController";
|
import { classifyAutoScanCapture, shouldAbortAfterConsecutiveMisses } from "./autoScanController";
|
||||||
|
import { waitForCardReady } from "./cardReadyGate";
|
||||||
import { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "./scannerCaptureQuality";
|
import { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "./scannerCaptureQuality";
|
||||||
import type { AutoScanStats, ScanSummary } from "./scannerSession";
|
import type { AutoScanStats, ScanSummary } from "./scannerSession";
|
||||||
import { clampSkipRows, emptyAutoScanStats, resolveScanTargetCount } from "./scannerSession";
|
import { clampSkipRows, emptyAutoScanStats, resolveScanTargetCount } from "./scannerSession";
|
||||||
@@ -59,7 +60,11 @@ export type AutoScanLoopResult = {
|
|||||||
targetCount: number;
|
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 MISS_ABORT_THRESHOLD = 3;
|
||||||
const UNREADABLE_ABORT_THRESHOLD = 5;
|
const UNREADABLE_ABORT_THRESHOLD = 5;
|
||||||
|
|
||||||
@@ -162,6 +167,19 @@ export async function runAutoScanLoop(
|
|||||||
if (initialParsed) lastDetailSignature = sessionSignature(initialParsed);
|
if (initialParsed) lastDetailSignature = sessionSignature(initialParsed);
|
||||||
let lastDetailViewFingerprint = detailFingerprint(currentCapture);
|
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 {
|
try {
|
||||||
while (!blockedReason && !shouldStop() && stats.clicked < maxTargets) {
|
while (!blockedReason && !shouldStop() && stats.clicked < maxTargets) {
|
||||||
page++;
|
page++;
|
||||||
@@ -224,16 +242,13 @@ export async function runAutoScanLoop(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let waitStop = await waitDuringScan(CLICK_SETTLE_MS);
|
let ready = await awaitCardReady();
|
||||||
if (waitStop) {
|
if (ready.abortReason) {
|
||||||
blockedReason = waitStop;
|
blockedReason = ready.abortReason;
|
||||||
aborted = true;
|
aborted = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
let changedDetail = ready.changed;
|
||||||
let previewCapture = await captureFastSelectedSource(0, true);
|
|
||||||
let previewFingerprint = detailFingerprint(previewCapture);
|
|
||||||
let changedDetail = Boolean(previewFingerprint && previewFingerprint !== lastDetailViewFingerprint);
|
|
||||||
|
|
||||||
if (!changedDetail) {
|
if (!changedDetail) {
|
||||||
appendAutomationLog(`retry r${target.row} c${target.col}: Detailansicht unveraendert`);
|
appendAutomationLog(`retry r${target.row} c${target.col}: Detailansicht unveraendert`);
|
||||||
@@ -244,15 +259,13 @@ export async function runAutoScanLoop(
|
|||||||
aborted = true;
|
aborted = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
waitStop = await waitDuringScan(CLICK_SETTLE_MS);
|
ready = await awaitCardReady();
|
||||||
if (waitStop) {
|
if (ready.abortReason) {
|
||||||
blockedReason = waitStop;
|
blockedReason = ready.abortReason;
|
||||||
aborted = true;
|
aborted = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
previewCapture = await captureFastSelectedSource(0, true);
|
changedDetail = ready.changed;
|
||||||
previewFingerprint = detailFingerprint(previewCapture);
|
|
||||||
changedDetail = Boolean(previewFingerprint && previewFingerprint !== lastDetailViewFingerprint);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!changedDetail) {
|
if (!changedDetail) {
|
||||||
@@ -482,6 +495,6 @@ export function fingerprintDataUrl(dataUrl: string) {
|
|||||||
return `${dataUrl.length.toString(16)}:${(hash >>> 0).toString(16)}`;
|
return `${dataUrl.length.toString(16)}:${(hash >>> 0).toString(16)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function wait(ms: number) {
|
function wait(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
return new Promise((resolve) => window.setTimeout(() => resolve(), ms));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { waitForCardReady, type CardReadyDeps } from "./cardReadyGate";
|
||||||
|
|
||||||
|
// Deterministic clock: each wait advances virtual time, each sample pops the
|
||||||
|
// next scripted fingerprint.
|
||||||
|
function harness(samples: string[], step = 90, checkAbort?: () => string) {
|
||||||
|
let clock = 0;
|
||||||
|
let index = 0;
|
||||||
|
const deps: CardReadyDeps = {
|
||||||
|
sampleFingerprint: async () => samples[Math.min(index++, samples.length - 1)],
|
||||||
|
wait: async (ms) => {
|
||||||
|
clock += ms;
|
||||||
|
},
|
||||||
|
now: () => clock,
|
||||||
|
checkAbort,
|
||||||
|
};
|
||||||
|
return { deps, sampleCount: () => index, step };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("waitForCardReady", () => {
|
||||||
|
it("returns ready once the card changed and stabilized", async () => {
|
||||||
|
const { deps } = harness(["old", "new", "new"]);
|
||||||
|
const result = await waitForCardReady(deps, "old", { minStableSamples: 2, maxWaitMs: 900, pollIntervalMs: 90 });
|
||||||
|
expect(result.ready).toBe(true);
|
||||||
|
expect(result.changed).toBe(true);
|
||||||
|
expect(result.stable).toBe(true);
|
||||||
|
expect(result.fingerprint).toBe("new");
|
||||||
|
expect(result.polls).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps polling while the detail still shows the previous artifact", async () => {
|
||||||
|
const { deps } = harness(["old", "old", "new", "new"]);
|
||||||
|
const result = await waitForCardReady(deps, "old", { minStableSamples: 2 });
|
||||||
|
expect(result.ready).toBe(true);
|
||||||
|
expect(result.fingerprint).toBe("new");
|
||||||
|
expect(result.polls).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("proceeds after the budget when content changed but never stabilizes (animation)", async () => {
|
||||||
|
// Always different (animated glow): changed but never two-in-a-row equal.
|
||||||
|
const animated = ["a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9", "a10"];
|
||||||
|
const { deps } = harness(animated, 90);
|
||||||
|
const result = await waitForCardReady(deps, "old", { minStableSamples: 2, maxWaitMs: 300, pollIntervalMs: 90 });
|
||||||
|
expect(result.changed).toBe(true);
|
||||||
|
expect(result.stable).toBe(false);
|
||||||
|
expect(result.ready).toBe(true); // budget spent, but content did change
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports not-ready when the detail never changes within budget", async () => {
|
||||||
|
const { deps } = harness(["old", "old", "old", "old", "old", "old"], 90);
|
||||||
|
const result = await waitForCardReady(deps, "old", { minStableSamples: 2, maxWaitMs: 200, pollIntervalMs: 90 });
|
||||||
|
expect(result.changed).toBe(false);
|
||||||
|
expect(result.ready).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts immediately when checkAbort returns a reason", async () => {
|
||||||
|
const { deps } = harness(["new", "new"], 90, () => "ESC gehalten");
|
||||||
|
const result = await waitForCardReady(deps, "old", {});
|
||||||
|
expect(result.abortReason).toBe("ESC gehalten");
|
||||||
|
expect(result.ready).toBe(false);
|
||||||
|
expect(result.polls).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores empty fingerprints for stability", async () => {
|
||||||
|
const { deps } = harness(["", "", "new", "new"], 90);
|
||||||
|
const result = await waitForCardReady(deps, "old", { minStableSamples: 2, maxWaitMs: 900 });
|
||||||
|
expect(result.ready).toBe(true);
|
||||||
|
expect(result.fingerprint).toBe("new");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Card-ready gating for the auto-scan loop (ADR replaces the fixed 280ms settle
|
||||||
|
// delay). After clicking a tile, instead of waiting a hardcoded interval and
|
||||||
|
// hoping the detail card has rendered, poll a cheap detail fingerprint until it
|
||||||
|
// has both (a) changed from the previously-read artifact and (b) stabilized
|
||||||
|
// across consecutive samples. This is faster on quick machines and correct on
|
||||||
|
// slow ones, and it is robust to particle/hover-glow animation: if the card
|
||||||
|
// never fully stabilizes within the budget it still proceeds once the content
|
||||||
|
// has changed, rather than looping forever on an animated frame.
|
||||||
|
//
|
||||||
|
// Pure except for the injected async sampler/clock, so it is unit testable.
|
||||||
|
|
||||||
|
export interface CardReadyOptions {
|
||||||
|
/** Consecutive equal samples required to call the card stable. */
|
||||||
|
minStableSamples?: number;
|
||||||
|
/** Total time budget before giving up on full stability. */
|
||||||
|
maxWaitMs?: number;
|
||||||
|
/** Delay between samples. */
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardReadyDeps {
|
||||||
|
/** Fast capture -> detail fingerprint. */
|
||||||
|
sampleFingerprint: () => Promise<string>;
|
||||||
|
wait: (ms: number) => Promise<void>;
|
||||||
|
now: () => number;
|
||||||
|
/** Returns a non-empty reason to abort (ESC held, stop pressed, ...). */
|
||||||
|
checkAbort?: () => Promise<string> | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardReadyResult {
|
||||||
|
/** Safe to read the full artifact: content changed (and stabilized or budget spent). */
|
||||||
|
ready: boolean;
|
||||||
|
/** The detail differs from the previously-read artifact. */
|
||||||
|
changed: boolean;
|
||||||
|
/** Reached the required number of consecutive equal samples. */
|
||||||
|
stable: boolean;
|
||||||
|
/** Latest sampled fingerprint. */
|
||||||
|
fingerprint: string;
|
||||||
|
/** Non-empty when aborted via checkAbort. */
|
||||||
|
abortReason: string;
|
||||||
|
polls: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MIN_STABLE = 2;
|
||||||
|
const DEFAULT_MAX_WAIT_MS = 900;
|
||||||
|
const DEFAULT_POLL_MS = 90;
|
||||||
|
|
||||||
|
export async function waitForCardReady(
|
||||||
|
deps: CardReadyDeps,
|
||||||
|
previousFingerprint: string,
|
||||||
|
options: CardReadyOptions = {},
|
||||||
|
): Promise<CardReadyResult> {
|
||||||
|
const minStable = Math.max(1, options.minStableSamples ?? DEFAULT_MIN_STABLE);
|
||||||
|
const maxWaitMs = Math.max(0, options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS);
|
||||||
|
const pollIntervalMs = Math.max(1, options.pollIntervalMs ?? DEFAULT_POLL_MS);
|
||||||
|
|
||||||
|
const start = deps.now();
|
||||||
|
let previousSample = "";
|
||||||
|
let stableCount = 0;
|
||||||
|
let latest = "";
|
||||||
|
let polls = 0;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
if (deps.checkAbort) {
|
||||||
|
const abortReason = await deps.checkAbort();
|
||||||
|
if (abortReason) {
|
||||||
|
return { ready: false, changed: false, stable: false, fingerprint: latest, abortReason, polls };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
latest = await deps.sampleFingerprint();
|
||||||
|
polls++;
|
||||||
|
|
||||||
|
stableCount = latest && latest === previousSample ? stableCount + 1 : 1;
|
||||||
|
previousSample = latest;
|
||||||
|
|
||||||
|
const changed = Boolean(latest) && latest !== previousFingerprint;
|
||||||
|
const stable = stableCount >= minStable;
|
||||||
|
|
||||||
|
if (changed && stable) {
|
||||||
|
return { ready: true, changed: true, stable: true, fingerprint: latest, abortReason: "", polls };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deps.now() - start >= maxWaitMs) {
|
||||||
|
// Budget spent. Proceed if the content has at least changed, even if it is
|
||||||
|
// still animating (never fully stabilizes).
|
||||||
|
return { ready: changed, changed, stable, fingerprint: latest, abortReason: "", polls };
|
||||||
|
}
|
||||||
|
|
||||||
|
await deps.wait(pollIntervalMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user