feat(scanner): complete localized artifact quality checkpoint

This commit is contained in:
AzuTear
2026-07-11 15:59:19 +02:00
parent 639b0b7f59
commit 8b9f948c6b
215 changed files with 35440 additions and 7273 deletions
+57
View File
@@ -0,0 +1,57 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { JsonArtifactStoreRepository } from "../../electron/repositories/artifactStoreRepository";
import type { StoredArtifactRecord } from "../types/storage";
const tempDirectories: string[] = [];
afterEach(async () => {
await Promise.all(tempDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
});
describe("artifact store repository local removal", () => {
it("removes only the explicitly requested local record", async () => {
const userDataPath = await fs.mkdtemp(path.join(os.tmpdir(), "gaa-artifact-store-"));
tempDirectories.push(userDataPath);
const repository = new JsonArtifactStoreRepository(userDataPath);
await repository.saveMany([record("remove-me"), record("keep-me", "Gladiator's Destiny")]);
const removal = await repository.removeByIds(["remove-me", "missing-record"]);
const loaded = await repository.loadAll();
expect(removal).toMatchObject({ ok: true, removed: 1, total: 1 });
expect(loaded.artifacts.map((entry) => entry.id)).toEqual(["keep-me"]);
});
it("does not remove a record for blank local ids", async () => {
const userDataPath = await fs.mkdtemp(path.join(os.tmpdir(), "gaa-artifact-store-"));
tempDirectories.push(userDataPath);
const repository = new JsonArtifactStoreRepository(userDataPath);
await repository.saveMany([record("keep-me")]);
const removal = await repository.removeByIds(["", " "]);
const loaded = await repository.loadAll();
expect(removal).toMatchObject({ ok: true, removed: 0, total: 1 });
expect(loaded.artifacts.map((entry) => entry.id)).toEqual(["keep-me"]);
});
});
function record(id: string, name = "Gladiator's Nostalgia"): StoredArtifactRecord {
return {
id,
name,
slot: "Flower of Life",
level: 20,
setName: "Gladiator's Finale",
mainStat: "HP",
mainValue: "4780",
substats: ["CRIT Rate+7.8%", "CRIT DMG+14.0%", "ATK%+5.8%", "Energy Recharge+11.7%"],
equipped: "Not detected",
confidence: 96,
needsReview: false,
source: "native-ik-scan",
};
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { isLikelyGenshinSourceName } from "../../electron/services/captureSourceClassifier";
describe("capture source classifier", () => {
it.each([
"Genshin Impact",
"GenshinImpact",
"Genshin",
"YuanShen",
"\u539f\u795e",
])("accepts the real game title %s", (title) => {
expect(isLikelyGenshinSourceName(title)).toBe(true);
});
it.each([
"Genshin Artifact Assistant",
"Genshin Artifact Assistant Codex | Working",
"docs - Genshin scanner",
"Screen 1",
"",
])("rejects non-game windows containing Genshin text: %s", (title) => {
expect(isLikelyGenshinSourceName(title)).toBe(false);
});
});
+197
View File
@@ -99,4 +99,201 @@ export const confirmedReviewCorpus: ConfirmedReviewEvalCase[] = [
note: "native-review-approved | run=20260709-223441 | sequence=76",
},
},
{
id: "native-review-minnesang-duplicated-decimal",
confirmed: true,
ocr: {
"artifact-name": "Minnesang of Love and Lament",
"artifact-slot": "Circlet of Logos",
"artifact-main-stat-label": "ATK",
"artifact-main-stat-value": "7.0%",
"artifact-level": "+0",
"artifact-substats": "CRIT Rate+2.7%\n- HP+4..1%\n- ATK+18\n- DEF+5.1%",
"artifact-set-effects": "© 2-Piece Set: ATK +18%.\n© 4-Piece Set: After a Normal\nAttack, Charged Attack,\nElemental Skill, or Elemental",
"artifact-footer": "",
},
expect: {
name: "Minnesang of Love and Lament",
slot: "Circlet of Logos",
level: 0,
mainStat: "ATK%",
mainValue: "7.0%",
setName: "A Day Carved From Rising Winds",
substats: ["CRIT Rate+2.7%", "HP%+4.1%", "ATK+18", "DEF%+5.1%"],
},
meta: {
source: "review-sample",
resolution: "492x838",
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=309 | duplicated decimal in HP percent substat",
},
},
{
id: "native-review-holy-crown-duplicated-decimal",
confirmed: true,
ocr: {
"artifact-name": "Holy Crown of the Believer",
"artifact-slot": "Circlet of Logos",
"artifact-main-stat-label": "Elemental Mastery",
"artifact-main-stat-value": "28",
"artifact-level": "+0",
"artifact-substats": "ATK+4.1%\n+ Energy Recharge+4.5%\n+ ATK+19\n+ HP+4..1%",
"artifact-set-effects": "¢2-Piece Set: Energy Recharge\n+20%.\n2 4-Piece Set: When dealing\nElemental DMG, gain the",
"artifact-footer": "Equipped: Illuga",
},
expect: {
name: "Holy Crown of the Believer",
slot: "Circlet of Logos",
level: 0,
mainStat: "Elemental Mastery",
mainValue: "28",
setName: "Silken Moon's Serenade",
equipped: "Illuga",
substats: ["ATK%+4.1%", "Energy Recharge+4.5%", "ATK+19", "HP%+4.1%"],
},
meta: {
source: "review-sample",
resolution: "492x838",
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=870 | duplicated decimal in HP percent substat",
},
},
{
id: "native-review-conductor-top-hat-duplicated-decimal",
confirmed: true,
ocr: {
"artifact-name": "Conductor's Top Hat",
"artifact-slot": "Circlet of Logos",
"artifact-main-stat-label": "DEF",
"artifact-main-stat-value": "8.1%",
"artifact-level": "+0",
"artifact-substats": "+ HP+209\n- HP+4..1%\n- CRIT DMG+5.4%\n- ATK+16 unactivated",
"artifact-set-effects": "«:2-Piece Set: Increases Elementa\nMastery by 80.\n2 4-Piece Set: Increases Charged\nAttack DMG by 35% if the",
"artifact-footer": "",
},
expect: {
name: "Conductor's Top Hat",
slot: "Circlet of Logos",
level: 0,
mainStat: "DEF%",
mainValue: "8.7%",
setName: "Wanderer's Troupe",
substats: ["HP+209", "HP%+4.1%", "CRIT DMG+5.4%", "ATK+16"],
},
meta: {
source: "review-sample",
resolution: "492x838",
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=1691 | duplicated decimal in HP percent substat",
},
},
{
id: "native-review-viridescent-arrow-feather-duplicated-decimal",
confirmed: true,
ocr: {
"artifact-name": "Viridescent Arrow Feather",
"artifact-slot": "Plume of Death",
"artifact-main-stat-label": "ATK",
"artifact-main-stat-value": "",
"artifact-level": "+0",
"artifact-substats": "+ CRIT DMG+5.4%\n- DEF+19\n+ CRIT Rate+2.7%\n+ HP+4..1%",
"artifact-set-effects": "r2-Piece Set: Anemo DMG Bonus\n+15%\n2 4-Piece Set: Increases Swirl\nDMG by 60%. Decreases",
"artifact-footer": "",
},
expect: {
name: "Viridescent Arrow Feather",
slot: "Plume of Death",
level: 0,
mainStat: "ATK",
mainValue: "47",
setName: "Viridescent Venerer",
substats: ["CRIT DMG+5.4%", "DEF+19", "CRIT Rate+2.7%", "HP%+4.1%"],
},
meta: {
source: "review-sample",
resolution: "492x838",
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=1723 | duplicated decimal in HP percent substat",
},
},
{
id: "native-review-gladiator-destiny-duplicated-decimal",
confirmed: true,
ocr: {
"artifact-name": "Gladiator's Destiny",
"artifact-slot": "Plume of Death",
"artifact-main-stat-label": "ATK",
"artifact-main-stat-value": "",
"artifact-level": "+0",
"artifact-substats": "- DEF+6.6%\n- HP+4..1%\n- Elemental Mastery+19\n- Energy Recharge+5.8%",
"artifact-set-effects": "Gladiator's Finale:\n© 2-Piece Set: ATK +18%.\n@4-Piece Set: If the wielder of\nthis artifact set uses a Sword,",
"artifact-footer": "",
},
expect: {
name: "Gladiator's Destiny",
slot: "Plume of Death",
level: 0,
mainStat: "ATK",
mainValue: "47",
setName: "Gladiator's Finale",
substats: ["DEF%+6.6%", "HP%+4.1%", "Elemental Mastery+19", "Energy Recharge+5.8%"],
},
meta: {
source: "review-sample",
resolution: "492x838",
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=1844 | duplicated decimal in HP percent substat",
},
},
{
id: "native-review-maidens-distant-love-duplicated-decimal",
confirmed: true,
ocr: {
"artifact-name": "Maiden's Distant Love",
"artifact-slot": "Flower of Life",
"artifact-main-stat-label": "HP",
"artifact-main-stat-value": "",
"artifact-level": "+0",
"artifact-substats": "- DEF+23\n+ CRIT Rate+2.7%\n+ ATK+5.3%\n+ HP+4..1%",
"artifact-set-effects": "¢2-Piece Set: Character Healing\nEffectiveness +15%\n4-Piece Set: Using an Elemental\nSkill or Burst increases healing",
"artifact-footer": "",
},
expect: {
name: "Maiden's Distant Love",
slot: "Flower of Life",
level: 0,
mainStat: "HP",
mainValue: "717",
setName: "Maiden Beloved",
substats: ["DEF+23", "CRIT Rate+2.7%", "ATK%+5.3%", "HP%+4.1%"],
},
meta: {
source: "review-sample",
resolution: "492x838",
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=2005 | duplicated decimal in HP percent substat",
},
},
{
id: "native-review-ovations-slot-safe-name",
confirmed: true,
ocr: {
"artifact-slot": "Goblet of Eonothem",
"artifact-footer": "",
"artifact-set-effects": "© 2-Piece Set: ATK +18%.\n2 4-Piece Set: Increases\nSuperconduct Reaction DMG by\n80% and Stellar-Conduct",
"artifact-main-stat-value": "8.1%",
"artifact-level": "+0",
"artifact-main-stat-label": "DEF",
"artifact-substats": "- DEF+21\n+ Energy Recharge+4.5%\n- CRIT Rate+3.1%\n- ATK+14 unactivated",
"artifact-name": "Postintty That Ceased Upon",
},
expect: {
name: "Ovations That Ceased Upon Festivity",
slot: "Goblet of Eonothem",
level: 0,
mainStat: "DEF%",
mainValue: "8.7%",
setName: "Disenchantment in Deep Shadow",
substats: ["DEF+21", "Energy Recharge+4.5%", "CRIT Rate+3.1%", "ATK+14"],
},
meta: {
source: "review-sample",
resolution: "492x838",
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=141 | direct goblet-slot evidence disambiguates the fuzzy piece name",
},
},
];
+41
View File
@@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { automationGuardFromHelperResponse } from "../../electron/services/inputHelper";
const fallbackScript = readFileSync(
path.join(process.cwd(), "electron", "services", "inputHelperPowerShellFallback.ts"),
"utf8",
);
describe("input helper guard contract", () => {
it("preserves the helper elevation signal together with stop keys", () => {
expect(automationGuardFromHelperResponse({
ok: true,
isElevated: true,
cursorX: 691,
cursorY: 540,
escapePressed: false,
enterPressed: true,
f9Pressed: false,
})).toEqual({
ok: true,
isElevated: true,
cursorX: 691,
cursorY: 540,
escapePressed: false,
enterPressed: true,
f9Pressed: false,
});
});
it("leaves elevation unknown instead of inventing a value for legacy responses", () => {
expect(automationGuardFromHelperResponse({ ok: true }).isElevated).toBeUndefined();
});
it("keeps the PowerShell fallback on the same exact game-process whitelist", () => {
expect(fallbackScript).toContain("$cachedName -in @('GenshinImpact', 'YuanShen')");
expect(fallbackScript).toContain("$_.ProcessName -in @('GenshinImpact', 'YuanShen')");
expect(fallbackScript).not.toContain("-match 'GenshinImpact|YuanShen|Genshin'");
});
});
+80
View File
@@ -0,0 +1,80 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const scriptPath = path.join(process.cwd(), "scripts", "native-live-smoke.ps1");
const script = readFileSync(scriptPath, "utf8");
describe("native live smoke script contract", () => {
it("accepts the click probe only when input delivery and the detail change are both proven", () => {
expect(script).toContain("$ProbePayload.ok");
expect(script).toContain("$ProbePayload.changed");
expect(script).toContain("$ProbePayload.click.ok");
expect(script).toContain("$ProbePayload.click.clicked");
expect(script).toContain("$ProbePayload.click.moved");
expect(script).toContain("-and -not $ProbePayload.click.inputBlocked");
});
it("requires exact capture, queue, click, process, and result counts", () => {
expect(script).toContain("[int]$finalScanner.target -ne $safeLimit");
expect(script).toContain("[int]$finalScanner.captured -ne $safeLimit");
expect(script).toContain("[int]$finalScanner.queued -ne $safeLimit");
expect(script).toContain("[int]$finalScanner.clicked -ne $safeLimit");
expect(script).toContain("[int]$process.status.processed -ne $safeLimit");
expect(script).toContain("[int]$process.status.parsed -ne $safeLimit");
expect(script).toContain("$processResults.Count -ne $safeLimit");
expect(script).toContain("[int]$results.status.total -ne $safeLimit");
expect(script).toContain("$loadedResults.Count -ne $safeLimit");
});
it("requires a completed bounded inventory-top reset and coherent timings", () => {
expect(script).toContain("initialTopResetCompleted = [bool]$finalScanner.initialTopResetCompleted");
expect(script).toContain("-not [bool]$finalScanner.initialTopResetCompleted");
expect(script).toContain("[int]$finalScanner.initialTopResetMs -le 0");
expect(script).toContain("[int]$finalScanner.totalMs -lt [int]$finalScanner.activeMs");
expect(script).toContain("[int]$finalScanner.totalMs -lt [int]$finalScanner.initialTopResetMs");
expect(script).toContain("$results.status.timing");
expect(script).toContain("$requiredTimingFields");
expect(script).toContain("requestToResultsReconciledMs");
expect(script).toContain("Native run timing contract is incomplete");
});
it("reconciles review and error counts and enforces the fifteen-percent review gate", () => {
expect(script).toContain("$actualReview -ne [int]$process.status.review");
expect(script).toContain("$actualErrors -ne [int]$process.status.errors");
expect(script).toContain("[int]$process.status.review -lt 0");
expect(script).toContain("[int]$process.status.review -gt $safeLimit");
expect(script).toContain("if ($reviewRate -gt 0.15)");
});
it("rejects dry-run persistence in both processing and loaded result rows", () => {
expect(script).toContain("$persistedProcessRows");
expect(script).toContain("[bool]$process.status.persisted");
expect(script).toContain("[int]$process.status.stored -ne 0");
expect(script).toContain("[bool]$_.persistedArtifact");
expect(script).toContain("[string]$_.artifactRecordId");
expect(script).toContain("-not $Persist -and $persistedLoadedRows.Count -ne 0");
});
it("binds each job to one expected non-empty PNG and exactly two click events", () => {
expect(script).toContain('"artifact-{0:D4}.png" -f $_');
expect(script).toContain("Compare-Object -ReferenceObject $expectedPngNames -DifferenceObject $actualPngNames");
expect(script).toContain("$duplicateSequenceJobs");
expect(script).toContain("[int]$_.clickEventsSent -ne 2");
expect(script).toContain("$badPathJobs");
expect(script).toContain("$emptyPngFiles");
});
it("requests cleanup after every start attempt and waits for a confirmed stopped state", () => {
const attemptIndex = script.indexOf("$scannerStartAttempted = $true");
const startIndex = script.indexOf('$start = Invoke-DevJson "/scanner/start');
expect(attemptIndex).toBeGreaterThan(-1);
expect(startIndex).toBeGreaterThan(attemptIndex);
expect(script).toContain("function Stop-NativeScannerAfterError");
expect(script).toContain('Invoke-DevJson "/scanner/stop"');
expect(script).toContain("while (($null -eq $lastScanner -or [bool]$lastScanner.running)");
expect(script).toContain("$stopped = $null -ne $lastScanner -and -not [bool]$lastScanner.running");
expect(script).toContain("if ($scannerStartAttempted -and -not $scannerFinished)");
expect(script).toContain("Native scanner cleanup stop did not reach a confirmed stopped state.");
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { replayNativeScanResults } from "./nativeScanReplay";
import type { StoredScanResultEntry } from "../types/storage";
describe("native scan offline replay", () => {
it("replays evaluation and projection deterministically", () => {
const report = replayNativeScanResults({
entries: [entry()],
repeats: 4,
runId: "run-1",
sourcePath: "scan-results.json",
});
expect(report).toMatchObject({
deterministic: true,
repeats: 4,
total: 1,
values: { evaluated: 1, review: 0, unknown: 0, deferred: 0, excluded: 0 },
projections: { available: 1, complete: 0, review: 0, unavailable: 0 },
cleanUnevaluated: [],
});
expect(new Set(report.replayHashes).size).toBe(1);
});
it("reports clean data that cannot be valued instead of hiding it", () => {
const report = replayNativeScanResults({
entries: [entry({ artifact: { ...entry().artifact!, substats: ["CRIT DMG+8.1%"] } })],
runId: "run-2",
sourcePath: "scan-results.json",
});
expect(report.values.unknown).toBe(1);
expect(report.cleanUnevaluated).toHaveLength(1);
});
it("keeps confirmed non-5-star results visible without treating them as missing evaluations", () => {
const report = replayNativeScanResults({
entries: [entry({ artifact: { ...entry().artifact!, rarity: 4 } })],
runId: "run-3",
sourcePath: "scan-results.json",
});
expect(report.values).toEqual({ deferred: 0, review: 0, unknown: 0, excluded: 1, evaluated: 0 });
expect(report.projections).toEqual({ available: 0, complete: 0, review: 0, unavailable: 1 });
expect(report.cleanUnevaluated).toEqual([]);
});
});
function entry(overrides: Partial<StoredScanResultEntry> = {}): StoredScanResultEntry {
return {
id: "entry-1",
runId: "run-1",
sequence: 1,
category: "artifact",
source: "native-ik-scan",
imagePath: "cards/artifact-0001.png",
capturedAt: "2026-07-09T00:00:00.000Z",
extractionStatus: "parsed",
extractionConfidence: 96,
needsReview: false,
valueStatus: "deferred",
valueScore: null,
artifact: {
name: "Gladiator's Intoxication",
slot: "Goblet of Eonothem",
level: 16,
rarity: 5,
rarityConfidence: 0.99,
raritySource: "native",
setName: "Gladiator's Finale",
mainStat: "Elemental Mastery",
mainValue: "155",
substats: ["CRIT DMG+14.0%", "DEF+39", "ATK+37", "Energy Recharge+6.5%"],
equipped: "Not detected",
},
persistedArtifact: false,
notes: [],
...overrides,
};
}
+125
View File
@@ -0,0 +1,125 @@
import { createHash } from "node:crypto";
import { evaluateStoredScanResult, projectArtifactUpgrade } from "../lib/artifactEvaluation.js";
import type { ArtifactUpgradeProjection, ArtifactValueStatus, StoredScanResultEntry } from "../types/storage.js";
export interface NativeScanReplayReport {
version: "native-scan-replay-v1";
runId: string;
sourcePath: string;
sourceSha256: string;
repeats: number;
deterministic: boolean;
replayHashes: string[];
total: number;
extraction: Record<string, number>;
values: Record<ArtifactValueStatus, number>;
projections: Record<ArtifactUpgradeProjection["status"], number>;
score: { min: number | null; average: number | null; max: number | null };
cleanUnevaluated: Array<{ id: string; sequence: number; status: ArtifactValueStatus; summary: string }>;
}
export function replayNativeScanResults(input: {
entries: readonly StoredScanResultEntry[];
repeats?: number;
runId: string;
sourcePath: string;
sourceSha256?: string;
}): NativeScanReplayReport {
const repeats = Math.max(2, Math.min(10, Math.round(input.repeats ?? 3)));
const replayPayloads = Array.from({ length: repeats }, () => input.entries.map((entry) => {
const evaluated = evaluateStoredScanResult(entry);
return {
id: evaluated.id,
sequence: evaluated.sequence,
extractionStatus: evaluated.extractionStatus,
needsReview: evaluated.needsReview,
valueStatus: evaluated.valueStatus,
valueScore: evaluated.valueScore,
valueEvaluation: evaluated.valueEvaluation,
projection: projectArtifactUpgrade(evaluated.artifact, evaluated.valueEvaluation ?? {
version: "roll-efficiency-v1",
status: evaluated.valueStatus,
score: evaluated.valueScore,
minScore: evaluated.valueScore,
maxScore: evaluated.valueScore,
label: "Legacy",
summary: "Legacy result without evaluation payload.",
rarity: null,
rollCount: null,
buildFitStatus: "deferred",
reasons: [],
substats: [],
}),
};
}));
const replayHashes = replayPayloads.map(stableHash);
const deterministic = replayHashes.every((hash) => hash === replayHashes[0]);
const first = replayPayloads[0] ?? [];
const values = valueStatusCounts(first);
const projections = projectionStatusCounts(first);
const scores = first.map((entry) => entry.valueScore).filter((score): score is number => typeof score === "number");
const cleanUnevaluated = first
// A confirmed non-5-star result is intentionally terminal: it is retained
// for the read-only inventory view, but must not make an otherwise clean
// native run look like it silently lost an evaluation.
.filter((entry) => entry.extractionStatus === "parsed" && !entry.needsReview
&& entry.valueStatus !== "evaluated" && entry.valueStatus !== "excluded")
.map((entry) => ({
id: entry.id,
sequence: entry.sequence,
status: entry.valueStatus,
summary: entry.valueEvaluation?.summary ?? "No evaluation payload.",
}));
return {
version: "native-scan-replay-v1",
runId: input.runId,
sourcePath: input.sourcePath,
sourceSha256: input.sourceSha256 ?? stableHash(input.entries),
repeats,
deterministic,
replayHashes,
total: first.length,
extraction: countBy(first.map((entry) => entry.extractionStatus)),
values,
projections,
score: scores.length > 0 ? {
min: Math.min(...scores),
average: Math.round((scores.reduce((sum, score) => sum + score, 0) / scores.length) * 10) / 10,
max: Math.max(...scores),
} : { min: null, average: null, max: null },
cleanUnevaluated,
};
}
function valueStatusCounts(entries: Array<{ valueStatus: ArtifactValueStatus }>): Record<ArtifactValueStatus, number> {
return {
deferred: entries.filter((entry) => entry.valueStatus === "deferred").length,
review: entries.filter((entry) => entry.valueStatus === "review").length,
unknown: entries.filter((entry) => entry.valueStatus === "unknown").length,
excluded: entries.filter((entry) => entry.valueStatus === "excluded").length,
evaluated: entries.filter((entry) => entry.valueStatus === "evaluated").length,
};
}
function projectionStatusCounts(
entries: Array<{ projection: ArtifactUpgradeProjection }>,
): Record<ArtifactUpgradeProjection["status"], number> {
return {
available: entries.filter((entry) => entry.projection.status === "available").length,
complete: entries.filter((entry) => entry.projection.status === "complete").length,
review: entries.filter((entry) => entry.projection.status === "review").length,
unavailable: entries.filter((entry) => entry.projection.status === "unavailable").length,
};
}
function countBy(values: string[]) {
return values.reduce<Record<string, number>>((counts, value) => {
counts[value] = (counts[value] ?? 0) + 1;
return counts;
}, {});
}
function stableHash(value: unknown) {
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
}
+578 -16
View File
@@ -4,8 +4,14 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createNativeScannerProcessingService } from "../../electron/services/nativeScannerProcessingService";
import type { NativeCaptureJobPayload } from "../../electron/services/nativeScannerProcessingService";
import {
readNativeScannerRunTiming,
recordNativeScannerRunTiming,
validateNativeScannerRunTiming,
} from "../../electron/services/nativeScannerRunTiming";
import type { IkArtifactCatalog } from "../lib/ikArtifactMatcher";
import type { StoredArtifactRecord, StoredScanResultEntry } from "../types/storage";
import type { NativeScannerProcessingProgressStatus } from "../types/global";
import { captureFromOcr } from "./ocrEvalHarness";
const tempDirs: string[] = [];
@@ -21,6 +27,27 @@ async function writeJobs(runDir: string, jobs: NativeCaptureJobPayload[]) {
await fs.writeFile(path.join(runDir, "capture-jobs.jsonl"), `${lines}\n`, "utf8");
}
async function appendJob(runDir: string, job: NativeCaptureJobPayload) {
await fs.appendFile(path.join(runDir, "capture-jobs.jsonl"), `${JSON.stringify(job)}\n`, "utf8");
}
async function writeProducerStatus(
runDir: string,
status: { running: boolean; status: string; target: number; captured: number },
) {
await fs.writeFile(path.join(runDir, "status.json"), JSON.stringify({ scanner: status }), "utf8");
}
async function waitFor<T>(read: () => Promise<T | null>, timeoutMs = 2_000): Promise<T> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const value = await read();
if (value !== null) return value;
await new Promise((resolve) => setTimeout(resolve, 15));
}
throw new Error(`Timed out after ${timeoutMs} ms.`);
}
async function writeCrop(runDir: string, relativePath = "cards/artifact-0001.png") {
const absolutePath = path.join(runDir, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
@@ -38,13 +65,25 @@ async function writePreviewPng(runDir: string, relativePath = "cards/preview.png
return { relativePath, absolutePath };
}
function safePlumeCapture() {
function safePlumeCapture(level = 20) {
return captureFromOcr({
"artifact-name": "Pristine Plume of the Blessed",
"artifact-slot": "Plume of Death",
"artifact-main-stat-label": "ATK",
"artifact-level": `+${level}`,
"artifact-substats": "+ CRIT DMG+7.0%\n+ DEF+30.6%\n+ Elemental Mastery+40\n+ ATK+5.8%",
"artifact-set-effects": "Silken Moon's Serenade:\n2-Piece Set: Energy Recharge +20%.",
"artifact-footer": "Equipped: Aino",
});
}
function illegalTotalPlumeCapture() {
return captureFromOcr({
"artifact-name": "Pristine Plume of the Blessed",
"artifact-slot": "Plume of Death",
"artifact-main-stat-label": "ATK",
"artifact-level": "+20",
"artifact-substats": "+ CRIT DMG+7.0%\n+ DEF+30.6%\n+ Elemental Mastery+40\n+ ATK+5.8%",
"artifact-substats": "+ CRIT Rate+3.1%\n+ CRIT DMG+7.0%\n+ ATK+5.8%\n+ HP+239",
"artifact-set-effects": "Silken Moon's Serenade:\n2-Piece Set: Energy Recharge +20%.",
"artifact-footer": "Equipped: Aino",
});
@@ -92,6 +131,7 @@ describe("native scanner processing service", () => {
await writeJobs(runDir, [{ sequence: 7, page: 2, row: 1, col: 3, relativePath }]);
const savedRecords: StoredArtifactRecord[] = [];
const progress: NativeScannerProcessingProgressStatus[] = [];
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async (imagePath, job) => {
@@ -103,6 +143,7 @@ describe("native scanner processing service", () => {
savedRecords.push(...records);
return { added: records.length, updated: 0 };
},
onProgress: (entry) => progress.push(entry),
});
const status = await service.processRun();
@@ -116,6 +157,8 @@ describe("native scanner processing service", () => {
expect(status.parsed).toBe(1);
expect(status.stored).toBe(0);
expect(status.persisted).toBe(false);
expect(progress[0]).toMatchObject({ running: true, total: 1, processed: 0 });
expect(progress.at(-1)).toMatchObject({ running: false, total: 1, processed: 1, parsed: 1, stored: 0 });
expect(status.results[0]).toMatchObject({
sequence: 7,
page: 2,
@@ -138,7 +181,7 @@ describe("native scanner processing service", () => {
expect(loadedResults.results[0]).toMatchObject({
sequence: 7,
extractionStatus: "parsed",
valueStatus: "deferred",
valueStatus: "evaluated",
});
expect(scanResults).toHaveLength(1);
expect(scanResults[0]).toMatchObject({
@@ -147,8 +190,8 @@ describe("native scanner processing service", () => {
category: "artifact",
source: "native-ik-scan",
extractionStatus: "parsed",
valueStatus: "deferred",
valueScore: null,
valueStatus: "evaluated",
valueScore: expect.any(Number),
persistedArtifact: false,
artifact: {
name: "Pristine Plume of the Blessed",
@@ -159,6 +202,47 @@ describe("native scanner processing service", () => {
expect(savedRecords).toHaveLength(0);
});
it("persists one shared timing timeline through durable results and terminal reconciliation", async () => {
const runDir = await makeRunDir();
const { relativePath } = await writeCrop(runDir);
await writeJobs(runDir, [{ sequence: 1, relativePath }]);
await writeProducerStatus(runDir, { running: false, status: "done", target: 1, captured: 1 });
const origin = new Date(Date.now() - 1_000);
await recordNativeScannerRunTiming(runDir, {
requestStartedAt: origin.toISOString(),
scannerStartedAt: new Date(origin.getTime() + 10).toISOString(),
captureStartedAt: new Date(origin.getTime() + 20).toISOString(),
captureCompletedAt: new Date(origin.getTime() + 100).toISOString(),
});
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async () => ({ added: 0, updated: 0 }),
recordRunTiming: recordNativeScannerRunTiming,
loadRunTiming: readNativeScannerRunTiming,
});
const processed = await service.processRun();
const loaded = await service.loadResults();
const timing = await readNativeScannerRunTiming(runDir);
const report = JSON.parse(await fs.readFile(path.join(runDir, "processing-report.json"), "utf8"));
expect(processed.timing).toMatchObject({
processingStartedAt: expect.any(String),
processingCompletedAt: expect.any(String),
resultsDurableAt: expect.any(String),
});
expect(loaded.timing).toMatchObject({
resultsReconciledAt: expect.any(String),
requestToResultsDurableMs: expect.any(Number),
requestToResultsReconciledMs: expect.any(Number),
});
expect(report.timing).toMatchObject({ resultsDurableAt: expect.any(String) });
expect(timing).not.toBeNull();
expect(validateNativeScannerRunTiming(timing!)).toEqual([]);
expect(timing?.requestToResultsReconciledMs).toBeGreaterThanOrEqual(timing?.requestToResultsDurableMs ?? 0);
});
it("returns a safe empty status when native scan results are not available", async () => {
const runDir = await makeRunDir();
const service = createNativeScannerProcessingService({
@@ -316,12 +400,125 @@ describe("native scanner processing service", () => {
const scanResults = JSON.parse(await fs.readFile(persistedRun.scanResultsPath, "utf8"));
expect(scanResults[0]).toMatchObject({
extractionStatus: "parsed",
valueStatus: "deferred",
valueStatus: "evaluated",
artifactRecordId: savedRecords[0].id,
persistedArtifact: true,
});
});
it("does not persist a clean parse whose legal total-roll evaluation fails", async () => {
const runDir = await makeRunDir();
const { relativePath } = await writeCrop(runDir);
await writeJobs(runDir, [{ sequence: 1, relativePath }]);
const savedRecords: StoredArtifactRecord[] = [];
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => illegalTotalPlumeCapture(),
saveArtifacts: async (records) => {
savedRecords.push(...records);
return { added: records.length, updated: 0 };
},
});
const status = await service.processRun({ persist: true });
const scanResults = JSON.parse(await fs.readFile(status.scanResultsPath, "utf8"));
expect(status).toMatchObject({ ok: true, parsed: 1, review: 0, stored: 0, persisted: true });
expect(savedRecords).toHaveLength(0);
expect(scanResults[0]).toMatchObject({
extractionStatus: "parsed",
needsReview: false,
valueStatus: "unknown",
valueScore: null,
persistedArtifact: false,
});
});
it("keeps confirmed non-5-star results visible but excluded from persistence", async () => {
const runDir = await makeRunDir();
const { relativePath } = await writeCrop(runDir);
await writeJobs(runDir, [{
sequence: 1,
relativePath,
starCount: 4,
starConfidence: 0.91,
starSource: "native-star-row-v1",
}]);
const savedRecords: StoredArtifactRecord[] = [];
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(16),
saveArtifacts: async (records) => {
savedRecords.push(...records);
return { added: records.length, updated: 0 };
},
});
const status = await service.processRun({ persist: true });
const scanResults = JSON.parse(await fs.readFile(status.scanResultsPath, "utf8"));
expect(status).toMatchObject({ ok: true, parsed: 1, review: 0, stored: 0, persisted: true });
expect(status.results[0]).toMatchObject({
parsed: true,
needsReview: false,
rarity: 4,
rarityConfidence: 0.91,
persisted: false,
});
expect(scanResults[0]).toMatchObject({
extractionStatus: "parsed",
needsReview: false,
valueStatus: "excluded",
valueScore: null,
persistedArtifact: false,
artifact: {
rarity: 4,
rarityConfidence: 0.91,
raritySource: "native",
},
fieldConfidences: expect.arrayContaining([
expect.objectContaining({ key: "rarity", label: "Stars", value: "4★", confidence: 91, source: "visual" }),
]),
});
expect(savedRecords).toEqual([]);
});
it("fails closed into Review when native star evidence is not confirmed", async () => {
const runDir = await makeRunDir();
const { relativePath } = await writeCrop(runDir);
await writeJobs(runDir, [{
sequence: 1,
relativePath,
starCount: null,
starConfidence: 0,
starSource: null,
}]);
const savedRecords: StoredArtifactRecord[] = [];
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async (records) => {
savedRecords.push(...records);
return { added: records.length, updated: 0 };
},
});
const status = await service.processRun({ persist: true });
const scanResults = JSON.parse(await fs.readFile(status.scanResultsPath, "utf8"));
expect(status).toMatchObject({ ok: true, parsed: 1, review: 1, stored: 0, persisted: true });
expect(status.results[0]).toMatchObject({ parsed: true, needsReview: true, persisted: false });
expect(scanResults[0]).toMatchObject({
extractionStatus: "review",
needsReview: true,
valueStatus: "review",
valueScore: null,
persistedArtifact: false,
});
expect(scanResults[0].notes.join(" ")).toContain("Artifact star row could not be confirmed");
expect(savedRecords).toEqual([]);
});
it("preserves the native capture timestamp in durable scan results", async () => {
const runDir = await makeRunDir();
const { relativePath } = await writeCrop(runDir);
@@ -363,7 +560,7 @@ describe("native scanner processing service", () => {
setName: "Silken Moon's Serenade",
mainStat: "ATK",
mainValue: "311",
substats: ["CRIT DMG+7.0%", "DEF+30.6%", "Elemental Mastery+40", "ATK%+5.8%"],
substats: ["CRIT DMG+7.0%", "DEF%+30.6%", "Elemental Mastery+40", "ATK%+5.8%"],
equipped: "Aino",
},
ikMatch: {
@@ -399,6 +596,157 @@ describe("native scanner processing service", () => {
expect(logLines).toHaveLength(1);
});
it("tombstones a locally removed result, deletes only its contained crop, and keeps raw run evidence intact", async () => {
const runDir = await makeRunDir();
const { absolutePath } = await writeCrop(runDir, "artifact-0001.png");
const result: StoredScanResultEntry = {
id: "remove-local-result",
runId: "run-remove-local",
sequence: 1,
category: "artifact",
source: "native-ik-scan",
imagePath: absolutePath,
capturedAt: "2026-07-11T10:00:00.000Z",
extractionStatus: "parsed",
extractionConfidence: 96,
needsReview: false,
valueStatus: "deferred",
valueScore: null,
persistedArtifact: false,
notes: [],
};
await fs.writeFile(path.join(runDir, "scan-results.json"), JSON.stringify([result], null, 2), "utf8");
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async () => ({ added: 0, updated: 0 }),
});
const status = await service.deleteResult({ resultId: result.id });
const visible = await service.loadResults();
const rawResults = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
const tombstones = (await fs.readFile(path.join(runDir, "deleted-results.jsonl"), "utf8")).trim().split(/\r?\n/).map((line) => JSON.parse(line));
const repeat = await service.deleteResult({ resultId: result.id });
expect(status).toMatchObject({ ok: true, deletedResult: true, deletedCrop: true, deletedStoreRecord: false });
await expect(fs.access(absolutePath)).rejects.toMatchObject({ code: "ENOENT" });
expect(visible).toMatchObject({ ok: true, total: 0, results: [] });
expect(rawResults).toHaveLength(1);
expect(tombstones).toHaveLength(1);
expect(tombstones[0]).toMatchObject({ resultId: result.id, removeLinkedStoreRecord: false });
expect(repeat).toMatchObject({ ok: true, alreadyDeleted: true, deletedResult: false });
});
it("optionally removes only the exact linked local Store record after tombstoning the scan result", async () => {
const runDir = await makeRunDir();
const { absolutePath } = await writeCrop(runDir, "artifact-0002.png");
const result: StoredScanResultEntry = {
id: "remove-linked-store-result",
runId: "run-remove-store",
sequence: 2,
category: "artifact",
source: "native-ik-scan",
imagePath: absolutePath,
capturedAt: "2026-07-11T10:01:00.000Z",
extractionStatus: "parsed",
extractionConfidence: 96,
needsReview: false,
valueStatus: "deferred",
valueScore: null,
artifactRecordId: "exact-local-store-record",
persistedArtifact: true,
notes: [],
};
await fs.writeFile(path.join(runDir, "scan-results.json"), JSON.stringify([result], null, 2), "utf8");
const removedIds: string[][] = [];
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async () => ({ added: 0, updated: 0 }),
removeArtifacts: async (ids) => {
removedIds.push(ids);
return { ok: true, removed: 1 };
},
});
const status = await service.deleteResult({ resultId: result.id, removeLinkedStoreRecord: true });
expect(status).toMatchObject({ ok: true, deletedResult: true, deletedCrop: true, deletedStoreRecord: true, storeRecordId: "exact-local-store-record" });
expect(removedIds).toEqual([["exact-local-store-record"]]);
});
it("blocks local deletion while the native run is active", async () => {
const runDir = await makeRunDir();
const { absolutePath } = await writeCrop(runDir, "artifact-0003.png");
const result: StoredScanResultEntry = {
id: "active-result",
runId: "run-active",
sequence: 3,
category: "artifact",
source: "native-ik-scan",
imagePath: absolutePath,
capturedAt: "2026-07-11T10:02:00.000Z",
extractionStatus: "parsed",
extractionConfidence: 96,
needsReview: false,
valueStatus: "deferred",
valueScore: null,
persistedArtifact: false,
notes: [],
};
await fs.writeFile(path.join(runDir, "scan-results.json"), JSON.stringify([result], null, 2), "utf8");
await writeProducerStatus(runDir, { running: true, status: "running", target: 3, captured: 3 });
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async () => ({ added: 0, updated: 0 }),
});
const status = await service.deleteResult({ resultId: result.id });
expect(status).toMatchObject({ ok: false, deletedResult: false });
expect(status.error).toContain("active native scan");
await expect(fs.access(absolutePath)).resolves.toBeUndefined();
await expect(fs.access(path.join(runDir, "deleted-results.jsonl"))).rejects.toMatchObject({ code: "ENOENT" });
});
it("never removes a recorded crop path outside the native run directory", async () => {
const runDir = await makeRunDir();
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "gaa-native-outside-"));
tempDirs.push(outsideDir);
const outsideCrop = path.join(outsideDir, "outside.png");
await fs.writeFile(outsideCrop, "outside crop", "utf8");
const result: StoredScanResultEntry = {
id: "outside-crop-result",
runId: "run-outside-crop",
sequence: 4,
category: "artifact",
source: "native-ik-scan",
imagePath: outsideCrop,
capturedAt: "2026-07-11T10:03:00.000Z",
extractionStatus: "parsed",
extractionConfidence: 96,
needsReview: false,
valueStatus: "deferred",
valueScore: null,
persistedArtifact: false,
notes: [],
};
await fs.writeFile(path.join(runDir, "scan-results.json"), JSON.stringify([result], null, 2), "utf8");
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async () => ({ added: 0, updated: 0 }),
});
const status = await service.deleteResult({ resultId: result.id });
expect(status).toMatchObject({ ok: true, deletedResult: true, deletedCrop: false });
expect(status.warnings.join(" ")).toContain("outside the native scan run");
await expect(fs.access(outsideCrop)).resolves.toBeUndefined();
expect((await service.loadResults()).results).toEqual([]);
});
it("approves a corrected review result only after structural and IK validation", async () => {
const runDir = await makeRunDir();
const reviewResult: StoredScanResultEntry = {
@@ -415,7 +763,7 @@ describe("native scanner processing service", () => {
valueStatus: "review",
valueScore: null,
persistedArtifact: false,
notes: ["Substat value has no valid roll combination: DEF+1."],
notes: ["Substat value has no valid roll combination: DEF%+1.7%."],
artifact: {
name: "Pristine Plume of the Blessed",
slot: "Plume of Death",
@@ -423,13 +771,13 @@ describe("native scanner processing service", () => {
setName: "Silken Moon's Serenade",
mainStat: "ATK",
mainValue: "311",
substats: ["CRIT DMG+7.0%", "Elemental Mastery+40", "ATK%+5.8%", "DEF+1"],
substats: ["HP+508", "DEF+44", "DEF%+1.7%", "CRIT DMG+13.2%"],
equipped: "Aino",
},
};
await fs.writeFile(path.join(runDir, "scan-results.json"), JSON.stringify([reviewResult], null, 2), "utf8");
await fs.writeFile(path.join(runDir, "processing-report.json"), JSON.stringify({
results: [{ sequence: 6, ocr: [{ id: "artifact-substats", label: "Substats", text: "DEF+1", confidence: 80 }] }],
results: [{ sequence: 6, ocr: [{ id: "artifact-substats", label: "Substats", text: "DEF%+1.7%", confidence: 80 }] }],
}), "utf8");
const evalSamples: unknown[] = [];
const service = createNativeScannerProcessingService({
@@ -449,7 +797,7 @@ describe("native scanner processing service", () => {
artifact: {
...reviewResult.artifact!,
level: 20,
substats: ["CRIT Rate+2.3%", "Elemental Mastery+40", "ATK%+5.8%", "DEF+23"],
substats: ["CRIT Rate+2.3%", "DEF+44", "DEF%+11.7%", "CRIT DMG+13.2%"],
},
});
expect(invalidRarity).toMatchObject({ ok: false, action: "approve" });
@@ -458,19 +806,21 @@ describe("native scanner processing service", () => {
const status = await service.reviewResult({
resultId: reviewResult.id,
action: "approve",
note: "Crop confirms DEF+23.",
note: "Crop confirms DEF%+11.7%.",
artifact: {
...reviewResult.artifact!,
level: 20,
substats: ["CRIT DMG+7.0%", "Elemental Mastery+40", "ATK%+5.8%", "DEF+23"],
substats: ["HP+508", "DEF+44", "DEF%+11.7%", "CRIT DMG+13.2%"],
},
});
const updated = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
expect(status.ok, status.error).toBe(true);
expect(status).toMatchObject({ action: "approve", evalSampleSaved: true, correctedFields: ["substats"] });
const log = await fs.readFile(path.join(runDir, "review-log.jsonl"), "utf8");
expect(status).toMatchObject({ ok: true, action: "approve", evalSampleSaved: true, correctedFields: ["substats"] });
expect(updated[0]).toMatchObject({ extractionStatus: "parsed", needsReview: false, valueStatus: "deferred" });
expect(updated[0].artifact.substats).toContain("DEF+23");
expect(updated[0]).toMatchObject({ extractionStatus: "parsed", needsReview: false, valueStatus: "evaluated" });
expect(updated[0].valueScore).toBeGreaterThanOrEqual(70);
expect(updated[0].artifact.substats).toContain("DEF%+11.7%");
expect(updated[0].review).toMatchObject({ status: "approved", correctedFields: ["substats"] });
expect(evalSamples).toHaveLength(1);
expect(log).toContain("review-result");
@@ -610,4 +960,216 @@ describe("native scanner processing service", () => {
});
expect(savedRecords).toHaveLength(0);
});
it("tails capture jobs while the producer is still running and writes live durable results", async () => {
const runDir = await makeRunDir();
await fs.writeFile(path.join(runDir, "capture-jobs.jsonl"), "", "utf8");
await writeProducerStatus(runDir, { running: true, status: "running", target: 2, captured: 0 });
const firstCrop = await writeCrop(runDir, "cards/artifact-1.png");
const secondCrop = await writeCrop(runDir, "cards/artifact-2.png");
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async () => ({ added: 0, updated: 0 }),
});
let settled = false;
const processing = service.processRun({ stream: true, expectedTotal: 2 })
.finally(() => {
settled = true;
});
await appendJob(runDir, { sequence: 1, relativePath: firstCrop.relativePath });
await writeProducerStatus(runDir, { running: true, status: "running", target: 2, captured: 1 });
const live = await waitFor(async () => {
const loaded = await service.loadResults({ afterSequence: 0 });
return loaded.results.length === 1 ? loaded : null;
});
expect(settled).toBe(false);
expect(live.total).toBe(1);
expect(live.results.map((entry) => entry.sequence)).toEqual([1]);
const durableLive = await waitFor(async () => {
try {
const results = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
return Array.isArray(results) && results.length === 1 ? results : null;
} catch {
return null;
}
});
expect(durableLive[0]).toMatchObject({ sequence: 1, valueStatus: "evaluated" });
await appendJob(runDir, { sequence: 2, relativePath: secondCrop.relativePath });
await writeProducerStatus(runDir, { running: false, status: "done", target: 2, captured: 2 });
const final = await processing;
expect(final.ok).toBe(true);
expect(final.processed).toBe(2);
expect(final.results.map((entry) => entry.sequence)).toEqual([1, 2]);
});
it("publishes streamed results in capture order when workers finish out of order", async () => {
const runDir = await makeRunDir();
const firstCrop = await writeCrop(runDir, "cards/artifact-1.png");
const secondCrop = await writeCrop(runDir, "cards/artifact-2.png");
await writeJobs(runDir, [
{ sequence: 1, relativePath: firstCrop.relativePath },
{ sequence: 2, relativePath: secondCrop.relativePath },
]);
await writeProducerStatus(runDir, { running: false, status: "done", target: 2, captured: 2 });
const published: number[] = [];
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async (_imagePath, job) => {
await new Promise((resolve) => setTimeout(resolve, job.sequence === 1 ? 45 : 5));
return safePlumeCapture();
},
saveArtifacts: async () => ({ added: 0, updated: 0 }),
onResult: (result) => published.push(result.sequence),
});
const status = await service.processRun({ stream: true, expectedTotal: 2 });
expect(status.results.map((entry) => entry.sequence)).toEqual([1, 2]);
expect(published).toEqual([1, 2]);
});
it("uses one in-flight processing promise per run directory", async () => {
const runDir = await makeRunDir();
const { relativePath } = await writeCrop(runDir);
await writeJobs(runDir, [{ sequence: 1, relativePath }]);
await writeProducerStatus(runDir, { running: false, status: "done", target: 1, captured: 1 });
let buildCalls = 0;
let releaseBuild: () => void = () => undefined;
const buildGate = new Promise<void>((resolve) => {
releaseBuild = resolve;
});
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => {
buildCalls += 1;
await buildGate;
return safePlumeCapture();
},
saveArtifacts: async () => ({ added: 0, updated: 0 }),
});
const first = service.processRun({ stream: true, expectedTotal: 1 });
const second = service.processRun({ stream: true, expectedTotal: 1 });
expect(second).toBe(first);
await waitFor(async () => buildCalls === 1 ? true : null);
releaseBuild();
const [firstStatus, secondStatus] = await Promise.all([first, second]);
expect(buildCalls).toBe(1);
expect(secondStatus).toBe(firstStatus);
});
it("drains jobs that become visible immediately after a stopped producer status", async () => {
const runDir = await makeRunDir();
await fs.writeFile(path.join(runDir, "capture-jobs.jsonl"), "", "utf8");
const firstCrop = await writeCrop(runDir, "cards/artifact-1.png");
const secondCrop = await writeCrop(runDir, "cards/artifact-2.png");
await writeProducerStatus(runDir, { running: true, status: "running", target: 5, captured: 0 });
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async () => ({ added: 0, updated: 0 }),
});
const processing = service.processRun({ stream: true, expectedTotal: 5 });
await appendJob(runDir, { sequence: 1, relativePath: firstCrop.relativePath });
await writeProducerStatus(runDir, { running: false, status: "stopped", target: 5, captured: 2 });
await new Promise((resolve) => setTimeout(resolve, 80));
await appendJob(runDir, { sequence: 2, relativePath: secondCrop.relativePath });
const status = await processing;
expect(status.ok).toBe(true);
expect(status.processed).toBe(2);
expect(status.results.map((entry) => entry.sequence)).toEqual([1, 2]);
});
it("serves ordered result deltas from memory without requiring the final result file", async () => {
const runDir = await makeRunDir();
const jobs: NativeCaptureJobPayload[] = [];
for (let sequence = 1; sequence <= 3; sequence += 1) {
const { relativePath } = await writeCrop(runDir, `cards/artifact-${sequence}.png`);
jobs.push({ sequence, relativePath });
}
await writeJobs(runDir, jobs);
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async () => ({ added: 0, updated: 0 }),
});
await service.processRun();
await fs.rm(path.join(runDir, "scan-results.json"));
const firstDelta = await service.loadResults({ afterSequence: 1, limit: 1 });
const secondDelta = await service.loadResults({ afterSequence: 2 });
expect(firstDelta.total).toBe(3);
expect(firstDelta.results.map((entry) => entry.sequence)).toEqual([2]);
expect(secondDelta.total).toBe(3);
expect(secondDelta.results.map((entry) => entry.sequence)).toEqual([3]);
});
it("waits for a partial JSONL record to receive its newline before processing it", async () => {
const runDir = await makeRunDir();
await fs.writeFile(path.join(runDir, "capture-jobs.jsonl"), "", "utf8");
await writeProducerStatus(runDir, { running: true, status: "running", target: 1, captured: 0 });
const { relativePath } = await writeCrop(runDir);
let buildCalls = 0;
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => {
buildCalls += 1;
return safePlumeCapture();
},
saveArtifacts: async () => ({ added: 0, updated: 0 }),
});
const processing = service.processRun({ stream: true, expectedTotal: 1 });
const line = JSON.stringify({ sequence: 1, relativePath } satisfies NativeCaptureJobPayload);
const splitAt = Math.floor(line.length / 2);
await fs.appendFile(path.join(runDir, "capture-jobs.jsonl"), line.slice(0, splitAt), "utf8");
await new Promise((resolve) => setTimeout(resolve, 120));
expect(buildCalls).toBe(0);
expect((await service.loadResults({ afterSequence: 0 })).total).toBe(0);
await fs.appendFile(path.join(runDir, "capture-jobs.jsonl"), `${line.slice(splitAt)}\n`, "utf8");
await writeProducerStatus(runDir, { running: false, status: "done", target: 1, captured: 1 });
const status = await processing;
expect(status.ok).toBe(true);
expect(status.processed).toBe(1);
expect(buildCalls).toBe(1);
});
it("deduplicates identical sequence records and blocks conflicting payloads", async () => {
const runDir = await makeRunDir();
const firstCrop = await writeCrop(runDir, "cards/artifact-1.png");
const conflictingCrop = await writeCrop(runDir, "cards/conflicting-artifact-1.png");
await writeJobs(runDir, [
{ sequence: 1, relativePath: firstCrop.relativePath },
{ sequence: 1, relativePath: firstCrop.relativePath },
{ sequence: 1, relativePath: conflictingCrop.relativePath },
]);
await writeProducerStatus(runDir, { running: false, status: "done", target: 1, captured: 1 });
let buildCalls = 0;
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => {
buildCalls += 1;
return safePlumeCapture();
},
saveArtifacts: async () => ({ added: 0, updated: 0 }),
});
const status = await service.processRun({ stream: true, expectedTotal: 1 });
expect(status.ok).toBe(false);
expect(status.error).toContain("Conflicting capture jobs use sequence 1");
expect(status.processed).toBe(1);
expect(buildCalls).toBe(1);
});
});
+108
View File
@@ -0,0 +1,108 @@
import {
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveNativeScannerRunDirectory } from "../../electron/services/nativeScannerRunDirectory";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
describe("native scanner run directory resolution", () => {
it("keeps an explicit contained run ahead of active and discovered runs", () => {
const root = makeRoot();
const requested = makeRunDirectory(root, "requested");
const active = makeRunDirectory(root, "active");
writeCompleteRun(root, "20260710-120000", "2026-07-10T12:00:00.000Z");
expect(resolveNativeScannerRunDirectory({
outputRoot: root,
requestedRunDir: requested,
activeRunDir: active,
})).toBe(requested);
});
it("keeps the in-memory active run ahead of disk discovery", () => {
const root = makeRoot();
const active = makeRunDirectory(root, "active");
writeCompleteRun(root, "20260710-120000", "2026-07-10T12:00:00.000Z");
expect(resolveNativeScannerRunDirectory({ outputRoot: root, activeRunDir: active })).toBe(active);
});
it("finds the newest complete processed run after a fresh app start", () => {
const root = makeRoot();
const older = writeCompleteRun(root, "20260710-100000", "2026-07-10T10:00:00.000Z");
const newest = writeCompleteRun(root, "20260710-110000", "2026-07-10T11:00:00.000Z");
writeIncompleteRun(root, "20260710-120000", "2026-07-10T12:00:00.000Z");
const resolved = resolveNativeScannerRunDirectory({ outputRoot: root });
expect(resolved).toBe(newest);
expect(resolved).not.toBe(older);
});
it("skips done runs whose durable result count is incomplete", () => {
const root = makeRoot();
const complete = writeCompleteRun(root, "20260710-100000", "2026-07-10T10:00:00.000Z");
writeCompleteRun(root, "20260710-110000", "2026-07-10T11:00:00.000Z", { captured: 2, resultCount: 1 });
expect(resolveNativeScannerRunDirectory({ outputRoot: root })).toBe(complete);
});
it("rejects an explicit directory outside the native scan root without falling back", () => {
const root = makeRoot();
writeCompleteRun(root, "20260710-100000", "2026-07-10T10:00:00.000Z");
const outside = makeRoot();
expect(resolveNativeScannerRunDirectory({ outputRoot: root, requestedRunDir: outside })).toBe("");
});
});
function makeRoot() {
const root = mkdtempSync(path.join(os.tmpdir(), "gaa-native-runs-"));
tempDirs.push(root);
return root;
}
function makeRunDirectory(root: string, name: string) {
const runDir = path.join(root, name);
mkdirSync(runDir, { recursive: true });
return runDir;
}
function writeCompleteRun(
root: string,
name: string,
createdAt: string,
options: { captured?: number; resultCount?: number } = {},
) {
const runDir = makeRunDirectory(root, name);
const captured = options.captured ?? 1;
const resultCount = options.resultCount ?? captured;
writeFileSync(path.join(runDir, "manifest.json"), JSON.stringify({ createdAt }), "utf8");
writeFileSync(path.join(runDir, "status.json"), JSON.stringify({
scanner: { running: false, status: "done", target: captured, captured },
}), "utf8");
writeFileSync(path.join(runDir, "capture-jobs.jsonl"), `${JSON.stringify({ sequence: 1 })}\n`, "utf8");
writeFileSync(path.join(runDir, "scan-results.json"), JSON.stringify(
Array.from({ length: resultCount }, (_, index) => ({ sequence: index + 1 })),
), "utf8");
writeFileSync(path.join(runDir, "processing-report.json"), JSON.stringify({ processed: resultCount }), "utf8");
return runDir;
}
function writeIncompleteRun(root: string, name: string, createdAt: string) {
const runDir = writeCompleteRun(root, name, createdAt);
writeFileSync(path.join(runDir, "status.json"), JSON.stringify({
scanner: { running: true, status: "running", target: 1, captured: 1 },
}), "utf8");
return runDir;
}
+76
View File
@@ -0,0 +1,76 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
nativeScannerRunTimingPath,
readNativeScannerRunTiming,
recordNativeScannerRunTiming,
validateNativeScannerRunTiming,
} from "../../electron/services/nativeScannerRunTiming";
const tempDirs: string[] = [];
async function makeRunDir() {
const runDir = await fs.mkdtemp(path.join(os.tmpdir(), "gaa-native-timing-"));
tempDirs.push(runDir);
return runDir;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("native scanner run timing", () => {
it("serializes concurrent lifecycle markers into an immutable shared end-to-end metric", async () => {
const runDir = await makeRunDir();
const origin = Date.parse("2026-07-11T10:00:00.000Z");
const at = (offsetMs: number) => new Date(origin + offsetMs).toISOString();
await Promise.all([
recordNativeScannerRunTiming(runDir, { requestStartedAt: at(0) }),
recordNativeScannerRunTiming(runDir, { scannerStartedAt: at(5) }),
recordNativeScannerRunTiming(runDir, { captureStartedAt: at(20) }),
recordNativeScannerRunTiming(runDir, { processingStartedAt: at(25) }),
recordNativeScannerRunTiming(runDir, { captureCompletedAt: at(150) }),
recordNativeScannerRunTiming(runDir, { processingCompletedAt: at(225) }),
recordNativeScannerRunTiming(runDir, { resultsDurableAt: at(240) }),
recordNativeScannerRunTiming(runDir, { resultsReconciledAt: at(255) }),
]);
const first = await readNativeScannerRunTiming(runDir);
await recordNativeScannerRunTiming(runDir, {
requestStartedAt: at(99),
resultsReconciledAt: at(300),
});
const second = await readNativeScannerRunTiming(runDir);
expect(first).toMatchObject({
requestStartedAt: at(0),
scannerStartedAt: at(5),
captureCompletedAt: at(150),
processingCompletedAt: at(225),
resultsDurableAt: at(240),
resultsReconciledAt: at(255),
requestToResultsDurableMs: 240,
requestToResultsReconciledMs: 255,
});
expect(second).toEqual(first);
expect(validateNativeScannerRunTiming(second!)).toEqual([]);
await expect(fs.access(nativeScannerRunTimingPath(runDir))).resolves.toBeUndefined();
});
it("reports impossible marker ordering or stale derived metrics", () => {
const issues = validateNativeScannerRunTiming({
version: "native-scanner-run-timing-v1",
requestStartedAt: "2026-07-11T10:00:01.000Z",
scannerStartedAt: "2026-07-11T10:00:00.000Z",
resultsReconciledAt: "2026-07-11T10:00:02.000Z",
requestToResultsReconciledMs: 2,
});
expect(issues).toEqual(expect.arrayContaining([
"scannerStartedAt is before requestStartedAt",
"requestToResultsReconciledMs does not match requestStartedAt to resultsReconciledAt",
]));
});
});
@@ -0,0 +1,33 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const scriptPath = path.join(process.cwd(), "scripts", "run-packaged-builds-acceptance.mjs");
const script = readFileSync(scriptPath, "utf8");
describe("packaged Builds UI acceptance contract", () => {
it("proves the English default, German opt-in, and restoration to English", () => {
expect(script).toContain("localStorage.removeItem('gaa-ui-locale')");
expect(script).toContain("initialLocale !== 'en'");
expect(script).toContain("languageSelect.value = 'de'");
expect(script).toContain("languageSelect.value = 'en'");
expect(script).toContain("germanLocaleApplied");
expect(script).toContain("englishLocaleRestored");
});
it("uses the native input setter so a controlled React context field is actually exercised", () => {
expect(script).toContain("Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set");
expect(script).toContain("nativeValueSetter?.call(contextInput, '106.6')");
expect(script).toContain("contextInput.dispatchEvent(new Event('input', { bubbles: true }))");
expect(script).toContain("contextInteraction.accepted");
});
it("keeps the renderer probe read-only and checks focus, motion, and overflow", () => {
expect(script).toContain("nativeScannerLoadResults({ limit: 2400 })");
expect(script).not.toContain("nativeScannerStart");
expect(script).not.toContain("clickScreen(");
expect(script).toContain("headingFocusAfterOpen");
expect(script).toContain("reducedMotionRespected");
expect(script).toContain("noDocumentOverflow");
});
});
@@ -0,0 +1,66 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const scriptPath = path.join(process.cwd(), "scripts", "run-packaged-live-acceptance.mjs");
const script = readFileSync(scriptPath, "utf8");
describe("packaged full-inventory UI acceptance contract", () => {
it("selects the explicit full-inventory scope without inventing a numeric target", () => {
expect(script).toContain('"ui-full-inventory"');
expect(script).toContain('scopeMode: "all"');
expect(script).toContain('expectedTarget: null');
expect(script).toContain("input[data-scan-scope=\"all\"]");
expect(script).toContain("const resolvedTarget = config.expectedTarget ?? status.target");
});
it("uses stable UI hooks instead of the current locale's visible labels", () => {
expect(script).toContain('data-navigation-id');
expect(script).toContain('data-scan-action="open-settings"');
expect(script).toContain('data-scan-limit-input');
expect(script).toContain('data-inventory-filter="review"');
expect(script).not.toContain("Gesamtes Inventar");
expect(script).not.toContain("Auto-Scan starten");
});
it("accepts only a bounded, complete, page-coherent run", () => {
expect(script).toContain("resolvedTarget <= 2400");
expect(script).toContain("const expectedPages = targetValid ? Math.ceil(resolvedTarget / 32) : 0");
expect(script).toContain("status.status === 'done'");
expect(script).toContain("status.captured === resolvedTarget");
expect(script).toContain("status.pages === expectedPages");
expect(script).toContain("processing.processed === resolvedTarget");
expect(script).toContain("loadedEntries.length === resolvedTarget");
expect(script).toContain("resultRows === resolvedTarget");
});
it("keeps review bounded, persistence disabled, streaming visible, and the document fitted", () => {
expect(script).toContain("reviewRate <= 0.15");
expect(script).toContain("processing.stored === 0");
expect(script).toContain("!result.persistedArtifact && !result.artifactRecordId");
expect(script).toContain("sawLiveResultDuringCapture && sawEvaluationAdvanceDuringCapture");
expect(script).toContain("resultRows === resolvedTarget && persistenceDisabled && documentFits");
});
it("can reprocess a saved full run without capture or persistence", () => {
expect(script).toContain('"reprocess-run"');
expect(script).toContain("reprocess-run requires --run-dir and --expected-target in 1..2400");
expect(script).toContain("nativeScannerProcessRun({");
expect(script).toContain("persist: false");
expect(script).toContain("processing.processed === expectedTarget");
expect(script).toContain("reviewRate <= maxReviewRate");
expect(script).toContain("processing.persisted === false");
});
it("can prove the local-only deletion confirmation without confirming a deletion", () => {
expect(script).toContain('"inventory-delete-confirm"');
expect(script).toContain('"inventory-store-delete-confirm"');
expect(script).toContain('data-inventory-action="delete"');
expect(script).toContain('data-inventory-action="confirm-delete"');
expect(script).toContain('data-inventory-action="cancel-delete"');
expect(script).toContain("inventory-delete-confirm state requires --view=inventory.");
expect(script).toContain("inventory-store-delete-confirm state requires --view=inventory.");
expect(script).toContain('data-inventory-filter="stored"');
expect(script).toContain("hasLinkedStoreToggle: Boolean(linkedStoreToggle)");
});
});
@@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRendererRepositories } from "../infrastructure/repositories/rendererBridgeRepositoryFactory";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("renderer bridge native scanner lifecycle", () => {
it("propagates start, status, and stop failures to emergency-recovery callers", async () => {
const failure = (operation: string) => vi.fn(async () => {
throw new Error(`${operation} failed`);
});
vi.stubGlobal("window", {
assistantApi: {
nativeScannerStart: failure("start"),
nativeScannerStatus: failure("status"),
nativeScannerStop: failure("stop"),
},
});
const automation = createRendererRepositories()?.automation;
await expect(automation?.nativeScannerStart({ limit: 5, category: "artifacts" })).rejects.toThrow("start failed");
await expect(automation?.nativeScannerStatus()).rejects.toThrow("status failed");
await expect(automation?.nativeScannerStop()).rejects.toThrow("stop failed");
});
});
+51
View File
@@ -0,0 +1,51 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
ikInventoryListsPathCandidates,
inputHelperPathCandidates,
} from "../../electron/runtimePaths";
const options = {
resourcesPath: path.resolve("C:/packaged/resources"),
currentWorkingDirectory: path.resolve("C:/repo"),
appPath: path.resolve("C:/packaged/resources/app.asar"),
};
describe("packaged runtime resource paths", () => {
it("uses only packaged helper resources when no explicit override is set", () => {
expect(inputHelperPathCandidates({ ...options, isPackaged: true })).toEqual([
path.join(options.resourcesPath, "input-helper", "InputHelper.exe"),
]);
});
it("uses only packaged IK resources when no explicit override is set", () => {
expect(ikInventoryListsPathCandidates({ ...options, isPackaged: true })).toEqual([
path.join(options.resourcesPath, "ik-inventorylists"),
]);
});
it("keeps repository fallbacks available in development", () => {
expect(inputHelperPathCandidates({ ...options, isPackaged: false })).toEqual([
path.join(options.currentWorkingDirectory, "native", "input-helper", "bin", "publish", "InputHelper.exe"),
path.join(options.appPath, "native", "input-helper", "bin", "publish", "InputHelper.exe"),
path.join(options.resourcesPath, "input-helper", "InputHelper.exe"),
]);
expect(ikInventoryListsPathCandidates({ ...options, isPackaged: false })).toEqual([
path.join(options.currentWorkingDirectory, "data", "ik-inventorylists"),
path.join(options.appPath, "data", "ik-inventorylists"),
path.join(options.resourcesPath, "ik-inventorylists"),
]);
});
it("preserves an explicit override without reintroducing implicit repo lookup", () => {
const overridePath = path.resolve("C:/explicit/ik-inventorylists");
expect(ikInventoryListsPathCandidates({
...options,
isPackaged: true,
overridePath,
})).toEqual([
overridePath,
path.join(options.resourcesPath, "ik-inventorylists"),
]);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import {
absoluteRendererAssetReferences,
rendererAssetReferences,
runtimeSignature,
} from "../../scripts/verify-packaged-app.mjs";
describe("packaged renderer verification", () => {
it("accepts renderer references that remain beside the loaded HTML file", () => {
const html = '<script src="./assets/app.js"></script><link href="./assets/app.css">';
expect(rendererAssetReferences(html)).toEqual(["./assets/app.js", "./assets/app.css"]);
expect(absoluteRendererAssetReferences(html)).toEqual([]);
});
it("detects root-absolute renderer references that loadFile cannot resolve", () => {
const html = '<script src="/assets/app.js"></script><link href="/assets/app.css">';
expect(absoluteRendererAssetReferences(html)).toEqual(["/assets/app.js", "/assets/app.css"]);
});
it("extracts the evidence signature used to reject stale packages", () => {
expect(runtimeSignature('const APP_RUNTIME_SIGNATURE = "2026-07-10-current";')).toBe("2026-07-10-current");
expect(runtimeSignature("const unrelated = true;")).toBe("");
});
});