Files
genshin-assistant/src/lib/autoScanLoop.test.ts
T
AzuTear c8ae0dd7bf fix(input): force Genshin foreground via AttachThreadInput (auto-scan no longer aborts)
Auto-scan aborted immediately with "Genshin konnte nicht in den Vordergrund
geholt werden". Root cause: the focus call runs in the background input/capture
helper process, and Windows' foreground lock silently refuses SetForegroundWindow
from a process that is neither foreground nor the last input source. When the user
clicks "Auto-Scan starten" the Electron window is foreground, so the helper's plain
SetForegroundWindow is dropped and focus stays false.

Fix (both the C# sidecar and the PowerShell fallback): before SetForegroundWindow,
attach our thread's input queue to the target (and current-foreground) window
thread with AttachThreadInput and clear SPI_..FOREGROUNDLOCKTIMEOUT, then restore.
This is the same technique Inventory Kamera and other reliable automators use; it
is what our helper was missing after the old ALT-tap workaround was removed on the
wrong assumption that equal integrity level is sufficient (that only covers UIPI
input injection, not foreground changes).

Sidecar recompiled + republished; electron build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:07:30 +02:00

149 lines
5.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { detailFingerprint, fingerprintDataUrl, isRepeatedProcessedPageFingerprint, screenFingerprint } from "./autoScanLoop";
import { runAutoScanLoop } from "./autoScanLoop";
import type { AutoScanLoopDependencies } from "./autoScanLoop";
import type { CaptureResult } from "../types/global";
type ScanTestCapture = CaptureResult & { inventoryGrid: NonNullable<CaptureResult["inventoryGrid"]> };
const sampleGrid = {
centers: [{ x: 80, y: 90, row: 0, col: 0 }],
rows: 1,
cols: 1,
confidence: 86,
source: "detected" as const,
};
const sampleParse = {
name: "A Tiara of Torrents",
slot: "Flower of Life",
level: 16,
mainStat: "HP",
mainValue: "10.7%",
substats: ["ATK+29", "DEF+10"],
setName: "Tenacity of the Millelith",
equipped: "Traveler",
confidence: 84,
notes: [],
fields: {
name: { value: "A Tiara of Torrents", confidence: 84, source: "ocr" as const },
slot: { value: "Flower of Life", confidence: 84, source: "ocr" as const },
level: { value: "16", confidence: 84, source: "ocr" as const },
mainStat: { value: "HP", confidence: 84, source: "ocr" as const },
mainValue: { value: "10.7%", confidence: 84, source: "ocr" as const },
setName: { value: "Tenacity of the Millelith", confidence: 84, source: "ocr" as const },
equipped: { value: "Traveler", confidence: 84, source: "ocr" as const },
substats: { value: "ATK+29, DEF+10", confidence: 84, source: "ocr" as const },
},
};
function capture(overrides: Partial<ScanTestCapture> = {}): ScanTestCapture {
return {
id: "source",
name: "screen-capture",
width: 1920,
height: 1080,
dataUrl: "data:image/png;base64,AA",
capturedAt: "2026-01-01T00:00:00.000Z",
captureTarget: "desktop-source",
ocr: [],
detailDataUrl: "data:image/png;base64,DETAIL-AAA",
inventoryDataUrl: "data:image/png;base64,GRID-AAA",
inventoryGrid: sampleGrid,
...overrides,
};
}
describe("autoScanLoop fingerprints", () => {
it("distinguishes captures that share the same prefix but differ later", () => {
const sharedPrefix = "data:image/png;base64," + "A".repeat(180);
const left = sharedPrefix + "LEFT-" + "B".repeat(600);
const right = sharedPrefix + "RIGHT-" + "C".repeat(600);
expect(fingerprintDataUrl(left)).not.toBe(fingerprintDataUrl(right));
});
it("stays stable for the same capture string", () => {
const dataUrl = "data:image/png;base64," + "Q".repeat(2048);
expect(fingerprintDataUrl(dataUrl)).toBe(fingerprintDataUrl(dataUrl));
});
it("uses the detail preview for detail-change verification instead of the full frame", () => {
const captureA = {
detailDataUrl: "data:image/png;base64," + "DETAIL-A".repeat(64),
dataUrl: "data:image/png;base64," + "FULL-A".repeat(256),
} as const;
const captureB = {
detailDataUrl: captureA.detailDataUrl,
dataUrl: "data:image/png;base64," + "FULL-B".repeat(256),
} as const;
expect(detailFingerprint(captureA as never)).toBe(detailFingerprint(captureB as never));
});
it("uses the inventory preview for scroll verification when available", () => {
const captureA = {
inventoryDataUrl: "data:image/png;base64," + "GRID-A".repeat(64),
dataUrl: "data:image/png;base64," + "FRAME-A".repeat(256),
} as const;
const captureB = {
inventoryDataUrl: "data:image/png;base64," + "GRID-B".repeat(64),
dataUrl: captureA.dataUrl,
} as const;
expect(screenFingerprint(captureA as never)).not.toBe(screenFingerprint(captureB as never));
});
it("treats repeated page fingerprints as a loop only after page one", () => {
const seen = new Set(["abc"]);
expect(isRepeatedProcessedPageFingerprint("abc", seen, 1)).toBe(false);
expect(isRepeatedProcessedPageFingerprint("abc", seen, 2)).toBe(true);
expect(isRepeatedProcessedPageFingerprint("", seen, 3)).toBe(false);
});
it("does not block before first click when start capture is from the primary screen", async () => {
const startCapture = capture({
captureTarget: "primary-screen",
detailDataUrl: `data:image/png;base64,${"D".repeat(600)}`,
});
let clicked = 0;
const deps: AutoScanLoopDependencies = {
api: {
clickScreen: async () => {
clicked += 1;
return {
ok: true,
x: 0,
y: 0,
cursorX: 0,
cursorY: 0,
clicked: true,
moved: true,
focused: true,
};
},
scrollScreen: async () => ({ ok: true, notchesSent: 0 }),
getAutomationGuard: async () => ({ ok: true, escapePressed: false, enterPressed: false, f9Pressed: false }),
},
captureSelectedSource: async () => capture({ detailDataUrl: `data:image/png;base64,${"E".repeat(600)}` }),
captureFastSelectedSource: async () => startCapture,
parseArtifact: () => sampleParse,
persistParsedArtifact: async () => false,
saveReviewSample: async () => ({ ok: true }),
getAutoReviewReason: () => "",
shouldFlagArtifactForReview: () => false,
appendAutomationLog: () => undefined,
appendClickDiagnostics: () => undefined,
setReviewStatus: () => undefined,
setAutoScanStats: () => undefined,
shouldStop: () => false,
};
const result = await runAutoScanLoop(deps, { scanLimit: 1, skipRows: 0, detectedInventoryCount: null });
expect(result.blockedReason).toBe("");
expect(result.status).toBe("done");
expect(clicked).toBe(1);
});
});