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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user