feat(scanner): complete localized artifact quality checkpoint
This commit is contained in:
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user