diff --git a/src/lib/autoScanLoop.ts b/src/lib/autoScanLoop.ts index 30ebfdf..10c29a4 100644 --- a/src/lib/autoScanLoop.ts +++ b/src/lib/autoScanLoop.ts @@ -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 { + return new Promise((resolve) => window.setTimeout(() => resolve(), ms)); } diff --git a/src/lib/cardReadyGate.test.ts b/src/lib/cardReadyGate.test.ts new file mode 100644 index 0000000..e24fcf2 --- /dev/null +++ b/src/lib/cardReadyGate.test.ts @@ -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"); + }); +}); diff --git a/src/lib/cardReadyGate.ts b/src/lib/cardReadyGate.ts new file mode 100644 index 0000000..6dad824 --- /dev/null +++ b/src/lib/cardReadyGate.ts @@ -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; + wait: (ms: number) => Promise; + now: () => number; + /** Returns a non-empty reason to abort (ESC held, stop pressed, ...). */ + checkAbort?: () => Promise | 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 { + 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); + } +}