feat(scanner): add native artifact pipeline

Add native IK-style capture processing, Artifact Inventory, explicit promotion and single-result review. Confirm the three live OCR corrections in the eval corpus and preserve extraction/value separation.
This commit is contained in:
AzuTear
2026-07-09 23:30:42 +02:00
parent 28d60eb915
commit 639b0b7f59
92 changed files with 13606 additions and 1703 deletions
+89 -1
View File
@@ -11,4 +11,92 @@ export interface ConfirmedReviewEvalCase extends OcrEvalCase {
// Human-confirmed review samples belong here after their `expect` values were
// checked against the real artifact. Do not paste unconfirmed exporter output
// directly from outputs/review-eval-candidates/.
export const confirmedReviewCorpus: ConfirmedReviewEvalCase[] = [];
export const confirmedReviewCorpus: ConfirmedReviewEvalCase[] = [
{
id: "native-review-crown-befallen-decimal-loss",
confirmed: true,
ocr: {
"artifact-name": "Crown of the Befallen",
"artifact-slot": "Circlet of Logos",
"artifact-main-stat-label": "CRIT Rate",
"artifact-main-stat-value": "311%",
"artifact-level": "+20",
"artifact-substats": "+ HP+508\n- DEF+44\n- DEF+1.7%\n+ CRIT DMG+13.2%",
"artifact-set-effects": ":2-Piece Set: Increases Elementa\nMastery by 80.\nZ4-Piece Set: When nearby party\nmembers trigger Lunar",
"artifact-footer": "Equipped: Zibai",
},
expect: {
name: "Crown of the Befallen",
slot: "Circlet of Logos",
level: 20,
mainStat: "CRIT Rate",
mainValue: "31.1%",
setName: "Night of the Sky's Unveiling",
equipped: "Zibai",
substats: ["HP+508", "DEF+44", "DEF%+11.7%", "CRIT DMG+13.2%"],
},
meta: {
source: "review-sample",
resolution: "492x838",
note: "native-review-approved | run=20260709-223441 | sequence=6",
},
},
{
id: "native-review-viridescent-extra-digit",
confirmed: true,
ocr: {
"artifact-name": "Viridescent Arrow Feather",
"artifact-slot": "Plume of Death",
"artifact-main-stat-label": "ATK",
"artifact-main-stat-value": "",
"artifact-level": "+20",
"artifact-substats": "- DEF+39\n+ HP+687\n+ HP+5.3%\n+ ATK+156.7%",
"artifact-set-effects": "r2-Piece Set: Anemo DMG Bonus\n+15%\n2 4-Piece Set: Increases Swirl\nDMG by 60%. Decreases",
"artifact-footer": "Equipped: Sucrose",
},
expect: {
name: "Viridescent Arrow Feather",
slot: "Plume of Death",
level: 20,
mainStat: "ATK",
mainValue: "311",
setName: "Viridescent Venerer",
equipped: "Sucrose",
substats: ["DEF+39", "HP+687", "HP%+5.3%", "ATK%+15.7%"],
},
meta: {
source: "review-sample",
resolution: "492x838",
note: "native-review-approved | run=20260709-223441 | sequence=60",
},
},
{
id: "native-review-gladiator-flat-hp-comma",
confirmed: true,
ocr: {
"artifact-name": "Gladiator's Intoxication",
"artifact-slot": "Goblet of Eonothem",
"artifact-main-stat-label": "Dendro DMG Bonus",
"artifact-main-stat-value": "46.6%",
"artifact-level": "+20",
"artifact-substats": "+ HP+1,165\n+ HP+5.8%\n+ CRIT Rate+3.9%\n- Energy Recharge+11.7%",
"artifact-set-effects": "2-Piece Set: ATK +18%.\n4-Piece Set: If the wielder of this artifact set uses a Sword",
"artifact-footer": "Equipped: Tighnari",
},
expect: {
name: "Gladiator's Intoxication",
slot: "Goblet of Eonothem",
level: 20,
mainStat: "Dendro DMG Bonus",
mainValue: "46.6%",
setName: "Gladiator's Finale",
equipped: "Tighnari",
substats: ["HP+1,165", "HP%+5.8%", "CRIT Rate+3.9%", "Energy Recharge+11.7%"],
},
meta: {
source: "review-sample",
resolution: "492x838",
note: "native-review-approved | run=20260709-223441 | sequence=76",
},
},
];
@@ -0,0 +1,613 @@
import fs from "node:fs/promises";
import os from "node:os";
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 type { IkArtifactCatalog } from "../lib/ikArtifactMatcher";
import type { StoredArtifactRecord, StoredScanResultEntry } from "../types/storage";
import { captureFromOcr } from "./ocrEvalHarness";
const tempDirs: string[] = [];
async function makeRunDir() {
const runDir = await fs.mkdtemp(path.join(os.tmpdir(), "gaa-native-process-"));
tempDirs.push(runDir);
return runDir;
}
async function writeJobs(runDir: string, jobs: NativeCaptureJobPayload[]) {
const lines = jobs.map((job) => JSON.stringify(job)).join("\n");
await fs.writeFile(path.join(runDir, "capture-jobs.jsonl"), `${lines}\n`, "utf8");
}
async function writeCrop(runDir: string, relativePath = "cards/artifact-0001.png") {
const absolutePath = path.join(runDir, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, "test image placeholder", "utf8");
return { relativePath, absolutePath };
}
async function writePreviewPng(runDir: string, relativePath = "cards/preview.png") {
const absolutePath = path.join(runDir, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(
absolutePath,
Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", "base64"),
);
return { relativePath, absolutePath };
}
function safePlumeCapture() {
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-set-effects": "Silken Moon's Serenade:\n2-Piece Set: Energy Recharge +20%.",
"artifact-footer": "Equipped: Aino",
});
}
function safePlumeIkCatalog(overrides: Partial<IkArtifactCatalog["artifacts"][number]["pieces"][number]> = {}): IkArtifactCatalog {
return {
artifacts: [
{
normalizedName: "silkenmoonsserenade",
setName: "Silken Moon's Serenade",
good: "SilkenMoonsSerenade",
pieces: [
{
slot: "plume",
artifactName: "Pristine Plume of the Blessed",
good: "PristinePlumeOfTheBlessed",
normalizedName: "pristineplumeoftheblessed",
...overrides,
},
],
},
],
};
}
function reviewOnlyCapture() {
return captureFromOcr({
"artifact-title": "Moonlit Offering's Final\nSands of Eon",
"artifact-main-stat": "46.6%\n1S 2.5.8 J\nSe",
"artifact-substats": "+ ATK+19\n+ Energy Recharge+6.5%\n+ CRIT DMG+18.7%",
"artifact-set-effects": "Aubade of Morningstar and Moon\n2-Piece Set: Increases Elemental Mastery by 80.",
"artifact-footer": "",
});
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("native scanner processing service", () => {
it("reads native capture jobs and writes a processing report without persisting by default", async () => {
const runDir = await makeRunDir();
const { relativePath, absolutePath } = await writeCrop(runDir);
await writeJobs(runDir, [{ sequence: 7, page: 2, row: 1, col: 3, relativePath }]);
const savedRecords: StoredArtifactRecord[] = [];
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async (imagePath, job) => {
expect(imagePath).toBe(absolutePath);
expect(job.sequence).toBe(7);
return safePlumeCapture();
},
saveArtifacts: async (records) => {
savedRecords.push(...records);
return { added: records.length, updated: 0 };
},
});
const status = await service.processRun();
const loadedResults = await service.loadResults();
const report = JSON.parse(await fs.readFile(path.join(runDir, "processing-report.json"), "utf8"));
const scanResults = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
expect(status.ok).toBe(true);
expect(status.scanResultsPath).toBe(path.join(runDir, "scan-results.json"));
expect(status.processed).toBe(1);
expect(status.parsed).toBe(1);
expect(status.stored).toBe(0);
expect(status.persisted).toBe(false);
expect(status.results[0]).toMatchObject({
sequence: 7,
page: 2,
row: 1,
col: 3,
imagePath: absolutePath,
parsed: true,
artifactName: "Pristine Plume of the Blessed",
setName: "Silken Moon's Serenade",
slot: "Plume of Death",
persisted: false,
});
expect(report.processed).toBe(1);
expect(loadedResults).toMatchObject({
ok: true,
runDir,
path: path.join(runDir, "scan-results.json"),
total: 1,
});
expect(loadedResults.results[0]).toMatchObject({
sequence: 7,
extractionStatus: "parsed",
valueStatus: "deferred",
});
expect(scanResults).toHaveLength(1);
expect(scanResults[0]).toMatchObject({
runId: path.basename(runDir),
sequence: 7,
category: "artifact",
source: "native-ik-scan",
extractionStatus: "parsed",
valueStatus: "deferred",
valueScore: null,
persistedArtifact: false,
artifact: {
name: "Pristine Plume of the Blessed",
slot: "Plume of Death",
setName: "Silken Moon's Serenade",
},
});
expect(savedRecords).toHaveLength(0);
});
it("returns a safe empty status when native scan results are not available", async () => {
const runDir = await makeRunDir();
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async () => ({ added: 0, updated: 0 }),
});
const loadedResults = await service.loadResults();
expect(loadedResults.ok).toBe(false);
expect(loadedResults.runDir).toBe(runDir);
expect(loadedResults.path).toBe(path.join(runDir, "scan-results.json"));
expect(loadedResults.results).toEqual([]);
});
it("loads native crop previews only from inside the run directory", async () => {
const runDir = await makeRunDir();
const { absolutePath } = await writePreviewPng(runDir);
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async () => ({ added: 0, updated: 0 }),
});
const preview = await service.loadImage({ imagePath: absolutePath });
const blocked = await service.loadImage({ imagePath: path.join(os.tmpdir(), "outside.png") });
expect(preview).toMatchObject({
ok: true,
runDir,
path: absolutePath,
width: 1,
height: 1,
});
expect(preview.dataUrl).toMatch(/^data:image\/png;base64,/);
expect(blocked.ok).toBe(false);
expect(blocked.error).toMatch(/outside/);
});
it("marks missing crop images as review errors and skips OCR processing", async () => {
const runDir = await makeRunDir();
await writeJobs(runDir, [{ sequence: 1, relativePath: "cards/missing.png" }]);
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({ persist: true });
const scanResults = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
expect(status.ok).toBe(true);
expect(status.processed).toBe(1);
expect(status.parsed).toBe(0);
expect(status.review).toBe(1);
expect(status.errors).toBe(1);
expect(status.stored).toBe(0);
expect(status.results[0]).toMatchObject({
sequence: 1,
parsed: false,
needsReview: true,
error: "Card crop image missing.",
});
expect(scanResults[0]).toMatchObject({
extractionStatus: "missing_crop",
valueStatus: "review",
needsReview: true,
error: "Card crop image missing.",
});
expect(buildCalls).toBe(0);
});
it("blocks non-artifact native jobs before OCR processing", async () => {
const runDir = await makeRunDir();
const { relativePath, absolutePath } = await writeCrop(runDir, "cards/weapon-0001.png");
await writeJobs(runDir, [{ sequence: 2, category: "weapons", page: 1, row: 0, col: 1, relativePath }]);
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({ persist: true });
const scanResults = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
expect(status.ok).toBe(true);
expect(status.processed).toBe(1);
expect(status.parsed).toBe(0);
expect(status.review).toBe(1);
expect(status.errors).toBe(1);
expect(status.stored).toBe(0);
expect(status.results[0]).toMatchObject({
sequence: 2,
category: "weapon",
imagePath: absolutePath,
parsed: false,
needsReview: true,
error: "Native post-capture processing for category 'weapons' is not implemented yet; IK catalog is available only.",
});
expect(scanResults[0]).toMatchObject({
category: "weapon",
extractionStatus: "error",
valueStatus: "review",
needsReview: true,
error: "Native post-capture processing for category 'weapons' is not implemented yet; IK catalog is available only.",
});
expect(buildCalls).toBe(0);
});
it("persists safe parsed artifacts only when persistence is explicitly enabled", 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 () => safePlumeCapture(),
saveArtifacts: async (records) => {
savedRecords.push(...records);
return { added: records.length, updated: 0 };
},
});
const dryRun = await service.processRun({ persist: false });
const persistedRun = await service.processRun({ persist: true });
expect(dryRun.stored).toBe(0);
expect(dryRun.results[0].persisted).toBe(false);
expect(persistedRun.stored).toBe(1);
expect(persistedRun.results[0].persisted).toBe(true);
expect(persistedRun.scanResultsPath).toBe(path.join(runDir, "scan-results.json"));
expect(savedRecords).toHaveLength(1);
expect(savedRecords[0]).toMatchObject({
name: "Pristine Plume of the Blessed",
slot: "Plume of Death",
setName: "Silken Moon's Serenade",
mainStat: "ATK",
mainValue: "311",
source: "native-ik-scan",
needsReview: false,
});
const scanResults = JSON.parse(await fs.readFile(persistedRun.scanResultsPath, "utf8"));
expect(scanResults[0]).toMatchObject({
extractionStatus: "parsed",
valueStatus: "deferred",
artifactRecordId: savedRecords[0].id,
persistedArtifact: true,
});
});
it("preserves the native capture timestamp in durable scan results", async () => {
const runDir = await makeRunDir();
const { relativePath } = await writeCrop(runDir);
const capturedAt = "2026-07-09T22:30:50.5886316+02:00";
await writeJobs(runDir, [{ sequence: 1, relativePath, capturedAt }]);
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async () => ({ added: 0, updated: 0 }),
});
const status = await service.processRun({ persist: false });
const scanResults = JSON.parse(await fs.readFile(status.scanResultsPath, "utf8"));
expect(scanResults[0].capturedAt).toBe(capturedAt);
});
it("promotes only explicitly selected safe results and writes a durable log", async () => {
const runDir = await makeRunDir();
const safeResult: StoredScanResultEntry = {
id: "safe-result",
runId: "run-1",
sequence: 1,
category: "artifact",
source: "native-ik-scan",
imagePath: "artifact-0001.png",
capturedAt: "2026-07-09T22:30:50.000Z",
extractionStatus: "parsed",
extractionConfidence: 96,
needsReview: false,
valueStatus: "deferred",
valueScore: null,
persistedArtifact: false,
notes: [],
artifact: {
name: "Pristine Plume of the Blessed",
slot: "Plume of Death",
level: 20,
setName: "Silken Moon's Serenade",
mainStat: "ATK",
mainValue: "311",
substats: ["CRIT DMG+7.0%", "DEF+30.6%", "Elemental Mastery+40", "ATK%+5.8%"],
equipped: "Aino",
},
ikMatch: {
matched: true,
confidence: 100,
source: "ik-inventorylists",
setGood: "SilkenMoonsSerenade",
artifactGood: "PristinePlumeOfTheBlessed",
notes: [],
},
fieldConfidences: [],
};
await fs.writeFile(path.join(runDir, "scan-results.json"), JSON.stringify([safeResult], null, 2), "utf8");
const savedRecords: StoredArtifactRecord[] = [];
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
loadArtifacts: async () => ({ ok: true, artifacts: [], total: 0, path: path.join(runDir, "artifact-store.json") }),
saveArtifacts: async (records) => {
savedRecords.push(...records);
return { ok: true, added: records.length, updated: 0, total: records.length, path: path.join(runDir, "artifact-store.json") };
},
});
const status = await service.promoteResults({ resultIds: [safeResult.id] });
const updatedResults = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
const logLines = (await fs.readFile(path.join(runDir, "promotion-log.jsonl"), "utf8")).trim().split(/\r?\n/);
expect(status).toMatchObject({ ok: true, requested: 1, selected: 1, promoted: 1, added: 1, updated: 0 });
expect(savedRecords).toHaveLength(1);
expect(savedRecords[0]).toMatchObject({ source: "native-ik-scan", needsReview: false });
expect(updatedResults[0]).toMatchObject({ persistedArtifact: true, artifactRecordId: savedRecords[0].id });
expect(logLines).toHaveLength(1);
});
it("approves a corrected review result only after structural and IK validation", async () => {
const runDir = await makeRunDir();
const reviewResult: StoredScanResultEntry = {
id: "review-result",
runId: "run-review",
sequence: 6,
category: "artifact",
source: "native-ik-scan",
imagePath: path.join(runDir, "artifact-0006.png"),
capturedAt: "2026-07-09T22:34:43.000Z",
extractionStatus: "review",
extractionConfidence: 96,
needsReview: true,
valueStatus: "review",
valueScore: null,
persistedArtifact: false,
notes: ["Substat value has no valid roll combination: DEF+1."],
artifact: {
name: "Pristine Plume of the Blessed",
slot: "Plume of Death",
level: 20,
setName: "Silken Moon's Serenade",
mainStat: "ATK",
mainValue: "311",
substats: ["CRIT DMG+7.0%", "Elemental Mastery+40", "ATK%+5.8%", "DEF+1"],
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 }] }],
}), "utf8");
const evalSamples: unknown[] = [];
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async () => ({ added: 0, updated: 0 }),
loadIkArtifactCatalog: async () => safePlumeIkCatalog(),
saveReviewSample: async (sample) => {
evalSamples.push(sample);
return { ok: true };
},
});
const invalidRarity = await service.reviewResult({
resultId: reviewResult.id,
action: "approve",
artifact: {
...reviewResult.artifact!,
level: 20,
substats: ["CRIT Rate+2.3%", "Elemental Mastery+40", "ATK%+5.8%", "DEF+23"],
},
});
expect(invalidRarity).toMatchObject({ ok: false, action: "approve" });
expect(invalidRarity.error).toContain("Implausible substats: CRIT Rate+2.3%");
const status = await service.reviewResult({
resultId: reviewResult.id,
action: "approve",
note: "Crop confirms DEF+23.",
artifact: {
...reviewResult.artifact!,
level: 20,
substats: ["CRIT DMG+7.0%", "Elemental Mastery+40", "ATK%+5.8%", "DEF+23"],
},
});
const updated = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
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].review).toMatchObject({ status: "approved", correctedFields: ["substats"] });
expect(evalSamples).toHaveLength(1);
expect(log).toContain("review-result");
});
it("keeps rejected review results blocked from promotion", async () => {
const runDir = await makeRunDir();
const result = {
id: "reject-result",
runId: "run-review",
sequence: 7,
category: "artifact",
source: "native-ik-scan",
imagePath: "artifact-0007.png",
capturedAt: "2026-07-09T22:34:44.000Z",
extractionStatus: "review",
extractionConfidence: 70,
needsReview: true,
valueStatus: "review",
valueScore: null,
persistedArtifact: false,
notes: [],
} satisfies StoredScanResultEntry;
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.reviewResult({ resultId: result.id, action: "reject", note: "Crop unreadable." });
const updated = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
expect(status).toMatchObject({ ok: true, action: "reject", evalSampleSaved: false });
expect(updated[0]).toMatchObject({ extractionStatus: "review", needsReview: true, valueStatus: "review" });
expect(updated[0].review).toMatchObject({ status: "rejected" });
});
it("uses IK inventorylists matching to force review on catalog conflicts", async () => {
const runDir = await makeRunDir();
const { relativePath } = await writeCrop(runDir);
await writeJobs(runDir, [{ sequence: 1, relativePath }]);
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async () => ({ added: 0, updated: 0 }),
loadIkArtifactCatalog: async () => safePlumeIkCatalog({ slot: "flower" }),
});
const status = await service.processRun({ persist: true });
const scanResults = JSON.parse(await fs.readFile(status.scanResultsPath, "utf8"));
expect(status.review).toBe(1);
expect(status.stored).toBe(0);
expect(status.results[0].ikMatch).toMatchObject({
matched: false,
source: "ik-inventorylists",
artifactGood: "PristinePlumeOfTheBlessed",
slotKey: "flower",
});
expect(status.results[0].notes?.join(" ")).toContain("IK slot mismatch");
expect(scanResults[0]).toMatchObject({
extractionStatus: "review",
valueStatus: "review",
persistedArtifact: false,
ikMatch: {
matched: false,
artifactGood: "PristinePlumeOfTheBlessed",
},
});
});
it("processes native crops through a bounded post-capture queue while preserving result order", async () => {
const runDir = await makeRunDir();
const jobs: NativeCaptureJobPayload[] = [];
for (let sequence = 1; sequence <= 5; sequence++) {
const { relativePath } = await writeCrop(runDir, `cards/artifact-${sequence}.png`);
jobs.push({ sequence, relativePath });
}
await writeJobs(runDir, jobs);
let active = 0;
let maxActive = 0;
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 10));
active -= 1;
return safePlumeCapture();
},
saveArtifacts: async () => ({ added: 0, updated: 0 }),
});
const status = await service.processRun();
expect(status.queueConcurrency).toBe(4);
expect(maxActive).toBeGreaterThan(1);
expect(status.results.map((result) => result.sequence)).toEqual([1, 2, 3, 4, 5]);
});
it("keeps review-only parsed artifacts out of the persistent store", 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 () => reviewOnlyCapture(),
saveArtifacts: async (records) => {
savedRecords.push(...records);
return { added: records.length, updated: 0 };
},
});
const status = await service.processRun({ persist: true });
expect(status.processed).toBe(1);
expect(status.parsed).toBe(1);
expect(status.review).toBe(1);
expect(status.stored).toBe(0);
expect(status.results[0]).toMatchObject({
parsed: true,
needsReview: true,
persisted: false,
slot: "Sands of Eon",
});
const scanResults = JSON.parse(await fs.readFile(status.scanResultsPath, "utf8"));
expect(scanResults[0]).toMatchObject({
extractionStatus: "review",
valueStatus: "review",
persistedArtifact: false,
artifact: {
slot: "Sands of Eon",
},
});
expect(savedRecords).toHaveLength(0);
});
});
+46 -107
View File
@@ -6,28 +6,27 @@ import { describe, expect, it } from "vitest";
function validAssessment() {
return {
createdAt: "2026-07-08T12:00:00.000Z",
goal100Decision: "qualified-comparison: winner=ik-traineddata",
createdAt: "2026-07-09T12:00:00.000Z",
goal100Decision: "qualified: winner=current",
goal100: {
limit: 100,
comparisonComplete: true,
winnerEngine: "ik-traineddata",
singleEngine: true,
winnerEngine: "current",
winnerQualified: true,
winnerActiveAverageMsPerParsed: 820,
winnerActiveProjectedMsFor100: 82000,
winnerAverageCaptureRoundTripMs: 420,
winnerAverageCaptureRoundTripOverheadMs: 160,
winnerActiveAverageMsPerParsed: 393,
winnerActiveProjectedMsFor100: 39300,
winnerAverageCaptureRoundTripMs: 333,
winnerAverageCaptureRoundTripOverheadMs: 148,
winnerMissRate: 0,
winnerReviewRate: 0.04,
winnerReviewRate: 0,
engines: [
{ engine: "ik-traineddata", qualified: true, missRate: 0, reviewRate: 0.04, averageCaptureRoundTripMs: 420, averageCaptureRoundTripOverheadMs: 160 },
{ engine: "current", qualified: true, missRate: 0, reviewRate: 0.06, averageCaptureRoundTripMs: 460, averageCaptureRoundTripOverheadMs: 190 },
{ engine: "current", qualified: true, missRate: 0, reviewRate: 0, averageCaptureRoundTripMs: 333, averageCaptureRoundTripOverheadMs: 148 },
],
},
limits: [
{
limit: 20,
comparisonComplete: true,
singleEngine: true,
winnerEngine: "current",
winnerQualified: true,
winnerActiveAverageMsPerParsed: 390,
@@ -38,7 +37,6 @@ function validAssessment() {
winnerReviewRate: 0.05,
engines: [
{ engine: "current", qualified: true, missRate: 0, reviewRate: 0.05, averageCaptureRoundTripMs: 364, averageCaptureRoundTripOverheadMs: 152 },
{ engine: "ik-traineddata", qualified: true, missRate: 0, reviewRate: 0.1, averageCaptureRoundTripMs: 410, averageCaptureRoundTripOverheadMs: 180 },
],
},
],
@@ -52,7 +50,7 @@ function writeAssessment(dir: string, payload: unknown) {
}
describe("scan assessment validator", () => {
it("accepts a qualified complete 100-artifact comparison", () => {
it("accepts a qualified 100-artifact current run", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const inputPath = writeAssessment(dir, validAssessment());
@@ -70,14 +68,10 @@ describe("scan assessment validator", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const inputPath = writeAssessment(dir, validAssessment());
const output = execFileSync(
"node",
["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=ik-traineddata"],
{
cwd: process.cwd(),
encoding: "utf8",
},
);
const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=current"], {
cwd: process.cwd(),
encoding: "utf8",
});
expect(JSON.parse(output).ok).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
@@ -94,28 +88,24 @@ describe("scan assessment validator", () => {
});
expect(output).toContain("scan assessment: PASS");
expect(output).toContain(`input: ${inputPath}`);
expect(output).toContain("createdAt: 2026-07-08T12:00:00.000Z");
expect(output).toContain("createdAt: 2026-07-09T12:00:00.000Z");
expect(output).toContain("limit: 100");
expect(output).toContain("winner: ik-traineddata");
expect(output).toContain("activeAvg: 820ms/artifact");
expect(output).toContain("captureRoundTripOverhead: 160ms");
expect(output).toContain("winner: current");
expect(output).toContain("activeAvg: 393ms/artifact");
expect(output).toContain("captureRoundTripOverhead: 148ms");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("accepts a qualified complete 20-artifact comparison", () => {
it("accepts a qualified 20-artifact current run", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const inputPath = writeAssessment(dir, validAssessment());
const output = execFileSync(
"node",
["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary", "--limit=20"],
{
cwd: process.cwd(),
encoding: "utf8",
},
);
const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary", "--limit=20"], {
cwd: process.cwd(),
encoding: "utf8",
});
expect(output).toContain("scan assessment: PASS");
expect(output).toContain("limit: 20");
expect(output).toContain("winner: current");
@@ -228,35 +218,35 @@ describe("scan assessment validator", () => {
it("keeps the validated npm scripts wired through preflight and the correct assessment limit", () => {
const packageJson = JSON.parse(readFileSync(path.join(process.cwd(), "package.json"), "utf8"));
expect(packageJson.scripts["scan:goal:compare:validated"]).toBe(
"npm run scan:live:preflight && npm run scan:goal:compare && npm run scan:assessment:validate -- --latest --summary",
expect(packageJson.scripts["scan:goal:validated"]).toBe(
"npm run scan:live:preflight && npm run scan:goal && npm run scan:assessment:validate -- --latest --summary",
);
expect(packageJson.scripts["scan:goal:compare:validated:wait"]).toBe(
"npm run scan:live:preflight:wait && npm run scan:goal:compare && npm run scan:assessment:validate -- --latest --summary",
expect(packageJson.scripts["scan:goal:validated:wait"]).toBe(
"npm run scan:live:preflight:wait && npm run scan:goal && npm run scan:assessment:validate -- --latest --summary",
);
expect(packageJson.scripts["scan:iterate:compare"]).toBe(
"powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -Limits 20 -ScanEngine compare -BenchmarkOcr",
expect(packageJson.scripts["scan:iterate"]).toBe(
"powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -Limits 20 -BenchmarkOcr",
);
expect(packageJson.scripts["scan:iterate:compare:validated"]).toBe(
"npm run scan:live:preflight && npm run scan:iterate:compare && npm run scan:assessment:validate -- --latest --summary --limit=20",
expect(packageJson.scripts["scan:iterate:validated"]).toBe(
"npm run scan:live:preflight && npm run scan:iterate && npm run scan:assessment:validate -- --latest --summary --limit=20",
);
expect(packageJson.scripts["scan:iterate:compare:validated:wait"]).toBe(
"npm run scan:live:preflight:wait && npm run scan:iterate:compare && npm run scan:assessment:validate -- --latest --summary --limit=20",
expect(packageJson.scripts["scan:iterate:validated:wait"]).toBe(
"npm run scan:live:preflight:wait && npm run scan:iterate && npm run scan:assessment:validate -- --latest --summary --limit=20",
);
expect(packageJson.scripts["scan:repeatability"]).toBe(
"powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -RepeatabilityRun -ScanEngine current",
);
expect(packageJson.scripts["scan:repeatability:wait"]).toBe(
"npm run scan:live:preflight:wait && npm run scan:repeatability && npm run scan:assessment:validate -- --latest --summary --limit=100 --expect-winner=current --allow-single-engine",
"npm run scan:live:preflight:wait && npm run scan:repeatability && npm run scan:assessment:validate -- --latest --summary --limit=100 --expect-winner=current",
);
});
it("rejects a mismatched expected winner", () => {
it("rejects an invalid expected winner", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const inputPath = writeAssessment(dir, validAssessment());
expect(() =>
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=current"], {
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=old-engine"], {
cwd: process.cwd(),
stdio: "pipe",
}),
@@ -269,10 +259,12 @@ describe("scan assessment validator", () => {
it("prints errors in summary mode for rejected assessments", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const inputPath = writeAssessment(dir, validAssessment());
const payload = validAssessment();
payload.goal100Decision = "not-qualified: 100-artifact winner failed quality gates";
const inputPath = writeAssessment(dir, payload);
let stdout = "";
try {
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=current", "--summary"], {
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], {
cwd: process.cwd(),
encoding: "utf8",
stdio: "pipe",
@@ -281,60 +273,7 @@ describe("scan assessment validator", () => {
stdout = String((error as { stdout?: string }).stdout || "");
}
expect(stdout).toContain("scan assessment: FAIL");
expect(stdout).toContain("Expected winner");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("rejects a single-engine 100-artifact run", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const payload = validAssessment();
payload.goal100Decision = "not-comparable: current and ik-traineddata were not both run";
payload.goal100.comparisonComplete = false;
payload.goal100.engines = [
{ engine: "current", qualified: true, missRate: 0, reviewRate: 0, averageCaptureRoundTripMs: 360, averageCaptureRoundTripOverheadMs: 150 },
];
const inputPath = writeAssessment(dir, payload);
expect(() =>
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`], {
cwd: process.cwd(),
stdio: "pipe",
}),
).toThrow();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("accepts a single-engine 100-artifact repeatability run only with the explicit flag", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const payload = validAssessment();
payload.goal100Decision = "not-comparable: current and ik-traineddata were not both run";
payload.goal100.comparisonComplete = false;
payload.goal100.winnerEngine = "current";
payload.goal100.engines = [
{ engine: "current", qualified: true, missRate: 0, reviewRate: 0, averageCaptureRoundTripMs: 360, averageCaptureRoundTripOverheadMs: 150 },
];
const inputPath = writeAssessment(dir, payload);
const output = execFileSync(
"node",
[
"scripts/validate-scan-assessment.cjs",
`--input=${inputPath}`,
"--summary",
"--expect-winner=current",
"--allow-single-engine",
],
{
cwd: process.cwd(),
encoding: "utf8",
},
);
expect(output).toContain("scan assessment: PASS");
expect(output).toContain("winner: current");
expect(stdout).toContain("goal100Decision is not a qualified 100-artifact run");
} finally {
rmSync(dir, { recursive: true, force: true });
}
@@ -438,8 +377,8 @@ describe("scan assessment validator", () => {
it("validates the latest assessment under a live-soak root", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const older = path.join(dir, "2026-07-08T10-00-00");
const newer = path.join(dir, "2026-07-08T11-00-00");
const older = path.join(dir, "2026-07-09T10-00-00");
const newer = path.join(dir, "2026-07-09T11-00-00");
mkdirSync(older);
mkdirSync(newer);
writeAssessment(older, { goal100Decision: "not-run: missing 100-artifact assessment" });
@@ -451,7 +390,7 @@ describe("scan assessment validator", () => {
});
const parsed = JSON.parse(output);
expect(parsed.ok).toBe(true);
expect(parsed.inputPath).toContain("2026-07-08T11-00-00");
expect(parsed.inputPath).toContain("2026-07-09T11-00-00");
} finally {
rmSync(dir, { recursive: true, force: true });
}