Prepare scanner branch for merge
This commit is contained in:
@@ -5272,6 +5272,11 @@
|
||||
},
|
||||
"pieceAliases": {
|
||||
"A Note in Springs Leich": "A Note in Spring's Leich",
|
||||
"Determination oT": "Viridescent Venerer's Determination",
|
||||
"Determmation oT": "Viridescent Venerer's Determination",
|
||||
"From Grand Dreams. Tn aking": "Moment That Ceased Upon Waking From Grand Dreams",
|
||||
"From Grand Dreams Tn aking": "Moment That Ceased Upon Waking From Grand Dreams",
|
||||
"Postintty That Ceased Upon": "Moment That Ceased Upon Waking From Grand Dreams",
|
||||
"Viridescent Vencrers Vessel": "Viridescent Venerer's Vessel",
|
||||
"Holy Crown of the Believer ": "Holy Crown of the Believer"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { confirmedReviewCorpus, ocrEvalCorpus, seedCorpus, validateConfirmedReviewCorpus } from ".";
|
||||
|
||||
describe("confirmed review corpus", () => {
|
||||
it("contains only human-confirmed review labels", () => {
|
||||
expect(validateConfirmedReviewCorpus()).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not duplicate eval case ids", () => {
|
||||
const ids = ocrEvalCorpus.map((entry) => entry.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("is included in the full OCR eval corpus", () => {
|
||||
expect(ocrEvalCorpus).toHaveLength(seedCorpus.length + confirmedReviewCorpus.length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { OcrEvalCase } from "../ocrEvalHarness";
|
||||
|
||||
export interface ConfirmedReviewEvalCase extends OcrEvalCase {
|
||||
confirmed: true;
|
||||
meta: NonNullable<OcrEvalCase["meta"]> & {
|
||||
source: "review-sample";
|
||||
note: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 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[] = [];
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { confirmedReviewCorpus, type ConfirmedReviewEvalCase } from "./confirmedReviewCorpus";
|
||||
import { seedCorpus } from "./seedCorpus";
|
||||
import type { OcrEvalCase } from "../ocrEvalHarness";
|
||||
|
||||
export { confirmedReviewCorpus, seedCorpus };
|
||||
export type { ConfirmedReviewEvalCase };
|
||||
|
||||
export const ocrEvalCorpus: OcrEvalCase[] = [...seedCorpus, ...confirmedReviewCorpus];
|
||||
|
||||
export function validateConfirmedReviewCorpus(corpus: readonly ConfirmedReviewEvalCase[] = confirmedReviewCorpus) {
|
||||
const errors: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const entry of corpus) {
|
||||
if (seen.has(entry.id)) errors.push(`${entry.id}: duplicate id`);
|
||||
seen.add(entry.id);
|
||||
if (entry.confirmed !== true) errors.push(`${entry.id}: confirmed must be true`);
|
||||
if (entry.meta.source !== "review-sample") errors.push(`${entry.id}: meta.source must be review-sample`);
|
||||
if (!entry.meta.note?.trim()) errors.push(`${entry.id}: meta.note must describe the review source/reason`);
|
||||
if (Object.keys(entry.expect).length === 0) errors.push(`${entry.id}: expect must label at least one field`);
|
||||
if (Object.keys(entry.ocr).length === 0) errors.push(`${entry.id}: ocr must contain at least one field`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { formatSummary, parseWaitSeconds, validatePreflight } = require("../../scripts/live-preflight.cjs") as {
|
||||
formatSummary: (result: unknown) => string;
|
||||
parseWaitSeconds: (value: string) => number;
|
||||
validatePreflight: (input: unknown) => { ok: boolean; errors: string[]; signature: string; isElevated?: boolean; genshinFound?: boolean };
|
||||
};
|
||||
|
||||
function health(signature = "sig-current") {
|
||||
return { ok: true, appBuild: { signature } };
|
||||
}
|
||||
|
||||
function status(runtime = { isElevated: true, genshinFound: true, targetProcess: "GenshinImpact.exe", foregroundProcess: "GenshinImpact.exe" }) {
|
||||
return { ok: true, status: { runtimeInfo: runtime } };
|
||||
}
|
||||
|
||||
describe("live preflight script", () => {
|
||||
it("accepts a matching elevated Genshin runtime", () => {
|
||||
const result = validatePreflight({ health: health(), status: status(), expected: "sig-current" });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.signature).toBe("sig-current");
|
||||
});
|
||||
|
||||
it("rejects stale runtime signatures", () => {
|
||||
const result = validatePreflight({ health: health("old"), status: status(), expected: "sig-current" });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.errors.join("\n")).toContain("does not match");
|
||||
});
|
||||
|
||||
it("rejects non-elevated or missing Genshin runtime by default", () => {
|
||||
const result = validatePreflight({
|
||||
health: health(),
|
||||
status: status({ isElevated: false, genshinFound: false, targetProcess: "", foregroundProcess: "explorer.exe" }),
|
||||
expected: "sig-current",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.errors).toContain("Runtime is not elevated.");
|
||||
expect(result.errors).toContain("Genshin process/window was not found.");
|
||||
});
|
||||
|
||||
it("formats a concise human summary", () => {
|
||||
const summary = formatSummary(validatePreflight({ health: health(), status: status(), expected: "sig-current" }));
|
||||
expect(summary).toContain("live preflight: PASS");
|
||||
expect(summary).toContain("elevated: yes");
|
||||
expect(summary).toContain("genshin: yes");
|
||||
});
|
||||
|
||||
it("parses optional wait seconds strictly", () => {
|
||||
expect(parseWaitSeconds("")).toBe(0);
|
||||
expect(parseWaitSeconds("120")).toBe(120);
|
||||
expect(() => parseWaitSeconds("-1")).toThrow("--wait");
|
||||
expect(() => parseWaitSeconds("1.5")).toThrow("--wait");
|
||||
expect(() => parseWaitSeconds("soon")).toThrow("--wait");
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatReport, runOcrEval } from "./ocrEvalHarness";
|
||||
import { seedCorpus } from "./corpus/seedCorpus";
|
||||
import { ocrEvalCorpus } from "./corpus";
|
||||
|
||||
// Regression gate: the seed corpus is verified ground truth, so the parser must
|
||||
// read every labeled field correctly. A drop here means an OCR/parser change
|
||||
// regressed a previously-correct read - look at the printed failures. If a
|
||||
// change intentionally alters a correct output, update the corpus label in the
|
||||
// same commit (the label is the source of truth, not the code).
|
||||
// Regression gate: every case in the combined corpus is verified ground truth,
|
||||
// so the parser must read every labeled field correctly. A drop here means an
|
||||
// OCR/parser change regressed a previously-correct read - look at the printed
|
||||
// failures. If a change intentionally alters a correct output, update the corpus
|
||||
// label in the same commit (the label is the source of truth, not the code).
|
||||
|
||||
describe("OCR eval harness", () => {
|
||||
const report = runOcrEval(seedCorpus);
|
||||
const report = runOcrEval(ocrEvalCorpus);
|
||||
|
||||
it("prints the accuracy report", () => {
|
||||
// Surfaced in test output for humans; not an assertion.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("\n" + formatReport(report) + "\n");
|
||||
expect(report.totalCases).toBe(seedCorpus.length);
|
||||
expect(report.totalCases).toBe(ocrEvalCorpus.length);
|
||||
});
|
||||
|
||||
it("reads every labeled field on the seed corpus correctly", () => {
|
||||
it("reads every labeled field on the eval corpus correctly", () => {
|
||||
const failureSummary = report.failures
|
||||
.map((failure) => {
|
||||
const wrong = failure.fields
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { pngBufferToBitmap } from "../../electron/services/pngBitmap";
|
||||
import { lockSignalRatio } from "../lib/lockDetection";
|
||||
|
||||
function chunk(type: string, data: Buffer) {
|
||||
const result = Buffer.alloc(12 + data.length);
|
||||
result.writeUInt32BE(data.length, 0);
|
||||
result.write(type, 4, 4, "ascii");
|
||||
data.copy(result, 8);
|
||||
return result;
|
||||
}
|
||||
|
||||
function rgbPng1x1(r: number, g: number, b: number) {
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(1, 0);
|
||||
ihdr.writeUInt32BE(1, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 2;
|
||||
const raw = Buffer.from([0, r, g, b]);
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", deflateSync(raw)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
describe("pngBufferToBitmap", () => {
|
||||
it("decodes RGB PNG pixels for lock detection", () => {
|
||||
const bitmap = pngBufferToBitmap(rgbPng1x1(235, 92, 90));
|
||||
expect(bitmap.width).toBe(1);
|
||||
expect(bitmap.height).toBe(1);
|
||||
expect(lockSignalRatio(bitmap)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function writeCandidatePayload(dir: string) {
|
||||
const inputPath = path.join(dir, "review-eval-candidates.json");
|
||||
writeFileSync(
|
||||
inputPath,
|
||||
JSON.stringify({
|
||||
summary: {},
|
||||
candidates: [
|
||||
{
|
||||
id: "review-2026-07-08T15-02-39-765Z-152",
|
||||
savedAt: "2026-07-08T15:02:39.765Z",
|
||||
reason: "automatic:parser-notes:p1:r2c3",
|
||||
resolution: "1920x1080",
|
||||
ocr: {
|
||||
"artifact-name": "Pristine Plume of the Blessed",
|
||||
"artifact-slot": "Plume of Death",
|
||||
"artifact-main-stat-label": "ATK",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "- Energy Recharge+10.4%",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
describe("prepare confirmed review case script", () => {
|
||||
it("creates a confirmed corpus snippet from a candidate and explicit labels", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-confirmed-review-"));
|
||||
try {
|
||||
const inputPath = writeCandidatePayload(dir);
|
||||
const expectPath = path.join(dir, "expect.json");
|
||||
const outputPath = path.join(dir, "snippet.ts");
|
||||
writeFileSync(
|
||||
expectPath,
|
||||
`\uFEFF${JSON.stringify({
|
||||
name: "Pristine Plume of the Blessed",
|
||||
slot: "Plume of Death",
|
||||
level: 20,
|
||||
mainStat: "ATK",
|
||||
setName: "Silken Moon's Serenade",
|
||||
})}`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
execFileSync(
|
||||
"node",
|
||||
[
|
||||
"scripts/prepare-confirmed-review-case.cjs",
|
||||
`--input=${inputPath}`,
|
||||
"--candidate=review-2026-07-08T15-02-39-765Z-152",
|
||||
`--expect-file=${expectPath}`,
|
||||
`--out=${outputPath}`,
|
||||
],
|
||||
{ cwd: process.cwd(), stdio: "pipe" },
|
||||
);
|
||||
|
||||
const snippet = readFileSync(outputPath, "utf8");
|
||||
expect(snippet).toContain("confirmed: true");
|
||||
expect(snippet).toContain("Pristine Plume of the Blessed");
|
||||
expect(snippet).toContain('"artifact-name": "Pristine Plume of the Blessed"');
|
||||
expect(snippet).toContain("sourceCandidate=review-2026-07-08T15-02-39-765Z-152");
|
||||
expect(snippet).not.toContain("confirmed: false");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects missing explicit labels", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-confirmed-review-"));
|
||||
try {
|
||||
const inputPath = writeCandidatePayload(dir);
|
||||
expect(() =>
|
||||
execFileSync(
|
||||
"node",
|
||||
["scripts/prepare-confirmed-review-case.cjs", `--input=${inputPath}`, "--candidate=review-2026-07-08T15-02-39-765Z-152"],
|
||||
{ cwd: process.cwd(), stdio: "pipe" },
|
||||
),
|
||||
).toThrow();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function reviewRecord(savedAt: string, reason: string, ocr: Array<{ id: string; text: string }>, locked?: boolean) {
|
||||
return {
|
||||
savedAt,
|
||||
sample: {
|
||||
reason,
|
||||
capture: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
ocr,
|
||||
locked,
|
||||
},
|
||||
parsed: {
|
||||
name: "Pristine Plume of the Blessed",
|
||||
slot: ocr.some((entry) => entry.id === "artifact-slot") ? "Plume of Death" : "Unknown Slot",
|
||||
level: 20,
|
||||
mainStat: "ATK",
|
||||
mainValue: "311",
|
||||
setName: "Silken Moon's Serenade",
|
||||
equipped: ocr.some((entry) => entry.id === "artifact-footer") ? "Aino" : "Not detected",
|
||||
substats: ["Energy Recharge+10.4%"],
|
||||
confidence: 0.9,
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const completeOcr = [
|
||||
{ id: "artifact-name", text: "Pristine Plume of the Blessed" },
|
||||
{ id: "artifact-slot", text: "Plume of Death" },
|
||||
{ id: "artifact-main-stat-label", text: "ATK" },
|
||||
{ id: "artifact-level", text: "+20" },
|
||||
{ id: "artifact-substats", text: "- Energy Recharge+10.4%" },
|
||||
{ id: "artifact-footer", text: "Equipped: Aino" },
|
||||
];
|
||||
|
||||
describe("review eval candidate exporter", () => {
|
||||
it("exports deduplicated candidates with stats and stale markers", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-review-candidates-"));
|
||||
try {
|
||||
const inputPath = path.join(dir, "review-samples.jsonl");
|
||||
const outDir = path.join(dir, "out");
|
||||
const staleOcr = completeOcr.filter((entry) => entry.id !== "artifact-slot");
|
||||
writeFileSync(
|
||||
inputPath,
|
||||
[
|
||||
JSON.stringify(reviewRecord("2026-07-08T15:00:00.000Z", "automatic:missing-crops-or-ocr:p1:r0c0", completeOcr)),
|
||||
JSON.stringify(reviewRecord("2026-07-08T15:00:00.000Z", "automatic:missing-crops-or-ocr:p1:r0c0", completeOcr)),
|
||||
JSON.stringify(reviewRecord("2026-07-08T15:01:00.000Z", "automatic:capture-rejected:p1:r0c1", staleOcr, true)),
|
||||
"{not json",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
execFileSync("node", ["scripts/export-review-eval-candidates.cjs", `--input=${inputPath}`, `--out=${outDir}`, "--limit=10"], {
|
||||
cwd: process.cwd(),
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const payload = JSON.parse(readFileSync(path.join(outDir, "review-eval-candidates.json"), "utf8"));
|
||||
const markdown = readFileSync(path.join(outDir, "review-eval-candidates.md"), "utf8");
|
||||
|
||||
expect(payload.summary.recordsRead).toBe(4);
|
||||
expect(payload.summary.invalidRecords).toBe(1);
|
||||
expect(payload.summary.uniqueCandidates).toBe(2);
|
||||
expect(payload.summary.exportedCandidates).toBe(2);
|
||||
expect(payload.summary.exportStats.completeFastFields).toBe(1);
|
||||
expect(payload.summary.exportStats.likelyStaleCaptures).toBe(1);
|
||||
expect(payload.summary.exportStats.equippedFooterCandidates).toBe(2);
|
||||
expect(payload.summary.exportStats.lockedTrueCandidates).toBe(1);
|
||||
expect(payload.candidates[0].missingFastFields).toEqual([]);
|
||||
expect(payload.candidates[0].parsed.equipped).toBe("Aino");
|
||||
expect(payload.candidates[0].reviewPrompt.expectedFields.equipped).toBe("Aino");
|
||||
expect(payload.candidates[1].likelyStaleCapture).toBe(true);
|
||||
expect(payload.candidates[1].locked).toBe(true);
|
||||
expect(markdown).toContain("## Export stats");
|
||||
expect(markdown).toContain("- artifact-slot: 1");
|
||||
expect(markdown).toContain("equipped=Aino");
|
||||
expect(markdown).toContain("Locked=true candidates: 1");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import type { OcrEvalCase } from "./ocrEvalHarness";
|
||||
// itself). The intended flow is:
|
||||
// 1. reviewSampleToEvalCase() extracts the OCR + the parser's current guess.
|
||||
// 2. A human confirms or corrects `expect` in the produced case.
|
||||
// 3. The corrected case is committed into src/eval/corpus/.
|
||||
// 3. The corrected case is committed into src/eval/corpus/confirmedReviewCorpus.ts.
|
||||
// The `confirmed` flag records whether step 2 happened.
|
||||
|
||||
export interface ReviewSampleEvalCase extends OcrEvalCase {
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function validAssessment() {
|
||||
return {
|
||||
createdAt: "2026-07-08T12:00:00.000Z",
|
||||
goal100Decision: "qualified-comparison: winner=ik-traineddata",
|
||||
goal100: {
|
||||
limit: 100,
|
||||
comparisonComplete: true,
|
||||
winnerEngine: "ik-traineddata",
|
||||
winnerQualified: true,
|
||||
winnerActiveAverageMsPerParsed: 820,
|
||||
winnerActiveProjectedMsFor100: 82000,
|
||||
winnerMissRate: 0,
|
||||
winnerReviewRate: 0.04,
|
||||
engines: [
|
||||
{ engine: "ik-traineddata", qualified: true, missRate: 0, reviewRate: 0.04 },
|
||||
{ engine: "current", qualified: true, missRate: 0, reviewRate: 0.06 },
|
||||
],
|
||||
},
|
||||
limits: [
|
||||
{
|
||||
limit: 20,
|
||||
comparisonComplete: true,
|
||||
winnerEngine: "current",
|
||||
winnerQualified: true,
|
||||
winnerActiveAverageMsPerParsed: 390,
|
||||
winnerActiveProjectedMsFor100: 39000,
|
||||
winnerMissRate: 0,
|
||||
winnerReviewRate: 0.05,
|
||||
engines: [
|
||||
{ engine: "current", qualified: true, missRate: 0, reviewRate: 0.05 },
|
||||
{ engine: "ik-traineddata", qualified: true, missRate: 0, reviewRate: 0.1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function writeAssessment(dir: string, payload: unknown) {
|
||||
const inputPath = path.join(dir, "scan-performance-assessment.json");
|
||||
writeFileSync(inputPath, JSON.stringify(payload), "utf8");
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
describe("scan assessment validator", () => {
|
||||
it("accepts a qualified complete 100-artifact comparison", () => {
|
||||
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}`], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(JSON.parse(output).ok).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a matching expected winner", () => {
|
||||
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",
|
||||
},
|
||||
);
|
||||
expect(JSON.parse(output).ok).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("prints an opt-in human summary", () => {
|
||||
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"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(output).toContain("scan assessment: PASS");
|
||||
expect(output).toContain(`input: ${inputPath}`);
|
||||
expect(output).toContain("createdAt: 2026-07-08T12:00:00.000Z");
|
||||
expect(output).toContain("limit: 100");
|
||||
expect(output).toContain("winner: ik-traineddata");
|
||||
expect(output).toContain("activeAvg: 820ms/artifact");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a qualified complete 20-artifact comparison", () => {
|
||||
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",
|
||||
},
|
||||
);
|
||||
expect(output).toContain("scan assessment: PASS");
|
||||
expect(output).toContain("limit: 20");
|
||||
expect(output).toContain("winner: current");
|
||||
expect(output).toContain("activeAvg: 390ms/artifact");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid requested limits", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const inputPath = writeAssessment(dir, validAssessment());
|
||||
let stdout = "";
|
||||
try {
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary", "--limit=0"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
stdout = String((error as { stdout?: string }).stdout || "");
|
||||
}
|
||||
expect(stdout).toContain("scan assessment: FAIL");
|
||||
expect(stdout).toContain("--limit must be a positive integer");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a requested limit that is missing from the assessment", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const inputPath = writeAssessment(dir, validAssessment());
|
||||
let stdout = "";
|
||||
try {
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary", "--limit=45"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
stdout = String((error as { stdout?: string }).stdout || "");
|
||||
}
|
||||
expect(stdout).toContain("scan assessment: FAIL");
|
||||
expect(stdout).toContain("Missing limit=45 assessment");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
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: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:iterate:compare"]).toBe(
|
||||
"powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -Limits 20 -ScanEngine compare -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:compare:validated:wait"]).toBe(
|
||||
"npm run scan:live:preflight:wait && npm run scan:iterate:compare && npm run scan:assessment:validate -- --latest --summary --limit=20",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a mismatched 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"], {
|
||||
cwd: process.cwd(),
|
||||
stdio: "pipe",
|
||||
}),
|
||||
).toThrow();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("prints errors in summary mode for rejected assessments", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const inputPath = writeAssessment(dir, validAssessment());
|
||||
let stdout = "";
|
||||
try {
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=current", "--summary"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
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 }];
|
||||
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("rejects an unqualified winner", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const payload = validAssessment();
|
||||
payload.goal100Decision = "not-qualified: 100-artifact winner failed quality gates";
|
||||
payload.goal100.winnerQualified = false;
|
||||
payload.goal100.engines[0].qualified = false;
|
||||
payload.goal100.engines[0].reviewRate = 0.3;
|
||||
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("rejects a winner with missing quality rates", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const payload = validAssessment();
|
||||
delete (payload.goal100.engines[0] as { missRate?: number }).missRate;
|
||||
delete (payload.goal100.engines[0] as { reviewRate?: number }).reviewRate;
|
||||
const inputPath = writeAssessment(dir, payload);
|
||||
let stdout = "";
|
||||
try {
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
stdout = String((error as { stdout?: string }).stdout || "");
|
||||
}
|
||||
expect(stdout).toContain("scan assessment: FAIL");
|
||||
expect(stdout).toContain("Winner missRate must be a finite number");
|
||||
expect(stdout).toContain("Winner reviewRate must be a finite number");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a winner with missing summary quality rates", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const payload = validAssessment();
|
||||
delete (payload.goal100 as { winnerMissRate?: number }).winnerMissRate;
|
||||
delete (payload.goal100 as { winnerReviewRate?: number }).winnerReviewRate;
|
||||
const inputPath = writeAssessment(dir, payload);
|
||||
let stdout = "";
|
||||
try {
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
stdout = String((error as { stdout?: string }).stdout || "");
|
||||
}
|
||||
expect(stdout).toContain("scan assessment: FAIL");
|
||||
expect(stdout).toContain("Winner summary missRate must be a finite number");
|
||||
expect(stdout).toContain("Winner summary reviewRate must be a finite number");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a winner with missing speed timing", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const payload = validAssessment();
|
||||
delete (payload.goal100 as { winnerActiveAverageMsPerParsed?: number }).winnerActiveAverageMsPerParsed;
|
||||
delete (payload.goal100 as { winnerActiveProjectedMsFor100?: number }).winnerActiveProjectedMsFor100;
|
||||
const inputPath = writeAssessment(dir, payload);
|
||||
let stdout = "";
|
||||
try {
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
stdout = String((error as { stdout?: string }).stdout || "");
|
||||
}
|
||||
expect(stdout).toContain("scan assessment: FAIL");
|
||||
expect(stdout).toContain("Winner active average timing must be a positive finite number");
|
||||
expect(stdout).toContain("Winner projected100 timing must be a positive finite number");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
mkdirSync(older);
|
||||
mkdirSync(newer);
|
||||
writeAssessment(older, { goal100Decision: "not-run: missing 100-artifact assessment" });
|
||||
writeAssessment(newer, validAssessment());
|
||||
|
||||
const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", "--latest", `--root=${dir}`], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
});
|
||||
const parsed = JSON.parse(output);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.inputPath).toContain("2026-07-08T11-00-00");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -144,13 +144,16 @@ export async function captureSelectedSourceAction(
|
||||
setTopbarStatus(`Capture in ${Math.round(delayMs / 1000)}s. Put Genshin in front and leave it visible.`);
|
||||
}
|
||||
const capture = await captureRepo.captureSource(selectedSourceId, delayMs, focusGenshin, options);
|
||||
setLatestCapture(capture);
|
||||
const ocrStatus = capture.ocrSkipped
|
||||
? "OCR skipped for fast scan."
|
||||
: capture.ocrTimedOut
|
||||
? "OCR timed out; review sample needed."
|
||||
: "OCR handoff is next.";
|
||||
setTopbarStatus(`Captured ${capture.name} at ${capture.width}x${capture.height}. ${ocrStatus}`);
|
||||
const quietArtifactScanCapture = options?.ocrMode === "artifact" && options?.ocrProfile === "fast" && options.omitFullFrame;
|
||||
if (!quietArtifactScanCapture) {
|
||||
setLatestCapture(capture);
|
||||
const ocrStatus = capture.ocrSkipped
|
||||
? "OCR skipped for fast scan."
|
||||
: capture.ocrTimedOut
|
||||
? "OCR timed out; review sample needed."
|
||||
: "OCR handoff is next.";
|
||||
setTopbarStatus(`Captured ${capture.name} at ${capture.width}x${capture.height}. ${ocrStatus}`);
|
||||
}
|
||||
return capture;
|
||||
} catch (error) {
|
||||
setTopbarStatus(error instanceof Error ? error.message : "Capture failed.");
|
||||
|
||||
@@ -23,7 +23,7 @@ const appDiagnosisSections = [
|
||||
"Genshin-Fenster erkennen, Smart Capture ausfuehren und fokussierte Artifact-Crops erzeugen.",
|
||||
"Artifact-Felder deterministisch gegen das lokale Genshin-Datenpaket parsen.",
|
||||
"Auto-Scan read-only aus der sichtbaren Inventory-Seite starten, inklusive Grid, Verifikation, Dedupe und Store.",
|
||||
"Review-Samples, lokale Text-Lernregeln, GOOD Import/Export und Lock-Status im Store nutzen.",
|
||||
"Review-Samples, lokale Lernregeln, GOOD Import/Export, Equipped-Footer und Lock-Status im Store nutzen.",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -33,7 +33,7 @@ const appDiagnosisSections = [
|
||||
items: [
|
||||
"Paimon-Menue-Einstieg ist gebaut, aber live noch nicht mit 2/20/45 Limits validiert.",
|
||||
"Native/IK-Tesseract ist nur als Benchmark-Pfad vorbereitet, noch nicht Standard.",
|
||||
"Positive locked=true Probe an einem sicher gesperrten Artifact fehlt.",
|
||||
"Positive locked=true Probe und erneuter Equipped-Footer-Livebeweis an bekannten Artifacts fehlen.",
|
||||
"Empfehlungen bleiben Nebenfunktion, bis Scanner-Vertrauen und Review-Rate stabil genug sind.",
|
||||
],
|
||||
},
|
||||
@@ -54,6 +54,7 @@ const appDiagnosisSections = [
|
||||
icon: Target,
|
||||
items: [
|
||||
"Review-Corpus aus echten Samples vergroessern und mit `npm run eval` messbar halten.",
|
||||
"Review-Export fuer Equipped-Footer und locked=true Kandidaten nutzen.",
|
||||
"OCR-Benchmark gegen identische Crops fahren und erst danach Engine-Standard wechseln.",
|
||||
"Paimon-Menue-Pfad live pruefen und bei Blockade sichtbar auf visible-inventory zurueckfallen.",
|
||||
"Diagnose weiter als Operator-Cockpit halten: Live-Status, Evidenz und naechster sicherer Schritt.",
|
||||
|
||||
@@ -23,7 +23,7 @@ export function useScanSummaryFooterModel({
|
||||
: `${scanSummary.attempted} Positionen bearbeitet, ${scanSummary.verified} Ansichten verifiziert, ${scanSummary.parsed} Artifact${scanSummary.parsed === 1 ? "" : "s"} gelesen in ${formatDuration(scanSummary.elapsedMs)} (${scanSummary.averageMsPerParsed || 0} ms/Artifact). Deine Sammlung: ${storedTotal ?? "?"} Artifacts.`;
|
||||
|
||||
const devCopy = devMode
|
||||
? `clicked ${scanSummary.clicked} | attempted ${scanSummary.attempted} | verified ${scanSummary.verified} | parsed ${scanSummary.parsed} | misses ${scanSummary.misses} | pages ${scanSummary.pages} | active ${formatDuration(scanSummary.activeScanMs)} | flush ${scanSummary.writeFlushMs}ms | capture ${scanSummary.averageCaptureMs}ms | ocr ${scanSummary.averageOcrMs}ms | ${scanSummary.artifactsPerMinute}/min | active ${scanSummary.activeArtifactsPerMinute}/min | 100 projected ${formatDuration(scanSummary.projectedMsFor100)}`
|
||||
? `clicked ${scanSummary.clicked} | attempted ${scanSummary.attempted} | verified ${scanSummary.verified} | parsed ${scanSummary.parsed} | misses ${scanSummary.misses} | pages ${scanSummary.pages} | active ${formatDuration(scanSummary.activeScanMs)} | flush ${scanSummary.writeFlushMs}ms | capture ${scanSummary.averageCaptureMs}ms | roundtrip ${scanSummary.averageCaptureRoundTripMs}ms | ocr ${scanSummary.averageOcrMs}ms | ${scanSummary.artifactsPerMinute}/min | active ${scanSummary.activeArtifactsPerMinute}/min | 100 projected ${formatDuration(scanSummary.projectedMsFor100)}`
|
||||
: null;
|
||||
|
||||
return {
|
||||
|
||||
@@ -141,6 +141,8 @@ export function useScanDiagnosticsModalModel({
|
||||
{ label: "avgMs", value: controller.autoScanStats.averageMsPerParsed },
|
||||
{ label: "activeAvgMs", value: controller.autoScanStats.activeAverageMsPerParsed },
|
||||
{ label: "avgCaptureMs", value: controller.autoScanStats.averageCaptureMs },
|
||||
{ label: "avgCaptureRoundTripMs", value: controller.autoScanStats.averageCaptureRoundTripMs },
|
||||
{ label: "avgCaptureRoundTripOverheadMs", value: controller.autoScanStats.averageCaptureRoundTripOverheadMs },
|
||||
{ label: "captureP50Ms", value: controller.autoScanStats.captureP50Ms },
|
||||
{ label: "captureP90Ms", value: controller.autoScanStats.captureP90Ms },
|
||||
{ label: "avgOcrMs", value: controller.autoScanStats.averageOcrMs },
|
||||
@@ -171,6 +173,8 @@ export function useScanDiagnosticsModalModel({
|
||||
controller.autoScanStats.averageMsPerParsed,
|
||||
controller.autoScanStats.activeAverageMsPerParsed,
|
||||
controller.autoScanStats.averageCaptureMs,
|
||||
controller.autoScanStats.averageCaptureRoundTripMs,
|
||||
controller.autoScanStats.averageCaptureRoundTripOverheadMs,
|
||||
controller.autoScanStats.captureP50Ms,
|
||||
controller.autoScanStats.captureP90Ms,
|
||||
controller.autoScanStats.averageOcrMs,
|
||||
|
||||
@@ -51,6 +51,14 @@ export interface ScanActionContextInput {
|
||||
source: string,
|
||||
needsReview: boolean,
|
||||
) => Promise<boolean>;
|
||||
persistParsedArtifactsBatch?: (
|
||||
items: Array<{
|
||||
capture: CaptureResult | null;
|
||||
parsed: ParsedArtifactCandidate;
|
||||
source: string;
|
||||
needsReview: boolean;
|
||||
}>,
|
||||
) => Promise<number>;
|
||||
saveReviewSample: (
|
||||
capture: CaptureResult | null,
|
||||
parsed: ParsedArtifactCandidate | null,
|
||||
@@ -107,6 +115,7 @@ export function createScanActionContext(input: ScanActionContextInput): ScanActi
|
||||
appendDiagnosticEvent: input.appendDiagnosticEvent,
|
||||
parseArtifact: input.parseArtifact,
|
||||
persistParsedArtifact: input.persistParsedArtifact,
|
||||
persistParsedArtifactsBatch: input.persistParsedArtifactsBatch,
|
||||
shouldFlagArtifactForReview: (parsed) => (parsed ? shouldFlagArtifactForReview(parsed) : false),
|
||||
saveReviewSample: input.saveReviewSample,
|
||||
focusDashboard: input.focusDashboard,
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
artifactTabClickTarget,
|
||||
keyPressBlocked,
|
||||
validateAutoScanEntryPreflight,
|
||||
type ScanEntryMode,
|
||||
} from "../../../lib/autoScanEntry";
|
||||
import { wait } from "../../../lib/scanReviewUtils";
|
||||
import {
|
||||
summarizeClickResult,
|
||||
summarizeKeyPressResult,
|
||||
type createScanDiagnosticEvent,
|
||||
} from "../../../lib/scanDiagnosticsLog";
|
||||
import type { AutomationRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||
import type { CaptureOptions, CaptureResult } from "../../../types/global";
|
||||
|
||||
export interface PrepareAutoScanEntryInput {
|
||||
mode: ScanEntryMode;
|
||||
automationRepo: AutomationRepositoryPort;
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
appendAutomationLog: (line: string) => void;
|
||||
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
|
||||
}
|
||||
|
||||
export async function prepareAutoScanEntry({
|
||||
mode,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
}: PrepareAutoScanEntryInput) {
|
||||
if (mode === "visible-inventory") {
|
||||
const capture = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 900,
|
||||
predicate: (candidate) => validateAutoScanEntryPreflight(candidate).ok,
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-visible",
|
||||
severity: capture ? "ok" : "error",
|
||||
message: capture && validateAutoScanEntryPreflight(capture).ok
|
||||
? "Visible inventory preflight capture ready."
|
||||
: "Visible inventory preflight capture failed.",
|
||||
capture,
|
||||
});
|
||||
return capture;
|
||||
}
|
||||
|
||||
if (mode === "direct-inventory") {
|
||||
return tryInventoryEntrySequence({
|
||||
label: "direct",
|
||||
sendEscapeFirst: false,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === "auto-entry") {
|
||||
const directCapture = await tryInventoryEntrySequence({
|
||||
label: "auto-direct",
|
||||
sendEscapeFirst: false,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
});
|
||||
const directPreflight = validateAutoScanEntryPreflight(directCapture);
|
||||
if (directPreflight.ok) return directCapture;
|
||||
appendAutomationLog(`auto-entry direct path failed: ${directPreflight.reason}`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-fallback",
|
||||
severity: "info",
|
||||
message: `Direct inventory entry did not reach an artifact detail card. Trying IK fallback. ${directPreflight.reason}`,
|
||||
capture: directCapture,
|
||||
});
|
||||
}
|
||||
|
||||
return tryInventoryEntrySequence({
|
||||
label: "paimon",
|
||||
sendEscapeFirst: true,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
});
|
||||
}
|
||||
|
||||
async function tryInventoryEntrySequence({
|
||||
label,
|
||||
sendEscapeFirst,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
}: {
|
||||
label: string;
|
||||
sendEscapeFirst: boolean;
|
||||
automationRepo: AutomationRepositoryPort;
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
appendAutomationLog: (line: string) => void;
|
||||
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
|
||||
}) {
|
||||
if (sendEscapeFirst) {
|
||||
const escapeResult = await automationRepo.keyPress?.("ESC");
|
||||
appendAutomationLog(`${label} entry key ESC: ${escapeResult?.ok ? "ok" : "blocked"}`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-key",
|
||||
severity: keyPressBlocked(escapeResult) ? "error" : "ok",
|
||||
message: `${label} entry key ESC`,
|
||||
details: summarizeKeyPressResult(escapeResult),
|
||||
});
|
||||
if (keyPressBlocked(escapeResult)) return null;
|
||||
const menuProbe = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 750,
|
||||
predicate: (capture) => Boolean(capture),
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: menuProbe ? "info" : "warn",
|
||||
message: `${label} capture after ESC step.`,
|
||||
capture: menuProbe,
|
||||
});
|
||||
if (menuProbe?.paimonMenu?.present) {
|
||||
const closeMenuResult = await automationRepo.keyPress?.("ESC");
|
||||
appendAutomationLog(`${label} entry key ESC close menu: ${closeMenuResult?.ok ? "ok" : "blocked"}`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-key",
|
||||
severity: keyPressBlocked(closeMenuResult) ? "error" : "ok",
|
||||
message: `${label} entry key ESC close menu`,
|
||||
details: summarizeKeyPressResult(closeMenuResult),
|
||||
});
|
||||
if (keyPressBlocked(closeMenuResult)) return menuProbe;
|
||||
const worldProbe = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 750,
|
||||
predicate: (capture) => Boolean(capture && !capture.paimonMenu?.present),
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: worldProbe ? "info" : "warn",
|
||||
message: `${label} capture after closing Paimon menu.`,
|
||||
capture: worldProbe,
|
||||
});
|
||||
if (worldProbe?.paimonMenu?.present) {
|
||||
appendAutomationLog(`${label} entry stopped: Paimon menu still visible after second ESC`);
|
||||
return worldProbe;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inventoryResult = await automationRepo.keyPress?.("B");
|
||||
appendAutomationLog(`${label} entry key B: ${inventoryResult?.ok ? "ok" : "blocked"}`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-key",
|
||||
severity: keyPressBlocked(inventoryResult) ? "error" : "ok",
|
||||
message: `${label} entry key B`,
|
||||
details: summarizeKeyPressResult(inventoryResult),
|
||||
});
|
||||
if (keyPressBlocked(inventoryResult)) return null;
|
||||
const tabProbe = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 1200,
|
||||
predicate: (capture) => Boolean(capture && !capture.paimonMenu?.present && capture.inventoryGrid && capture.inventoryGrid.source !== "missing"),
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: tabProbe ? "info" : "warn",
|
||||
message: `${label} capture after Inventory-key step.`,
|
||||
capture: tabProbe,
|
||||
});
|
||||
if (tabProbe?.paimonMenu?.present) {
|
||||
appendAutomationLog(`${label} entry stopped: Paimon menu still visible after Inventory key`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: "info",
|
||||
message: `${label} entry stopped before tab click because Paimon menu is still visible.`,
|
||||
capture: tabProbe,
|
||||
});
|
||||
return tabProbe;
|
||||
}
|
||||
if (!tabProbe?.inventoryGrid || tabProbe.inventoryGrid.source === "missing") return tabProbe;
|
||||
|
||||
const target = artifactTabClickTarget(tabProbe);
|
||||
appendAutomationLog(`${label} entry artifact tab -> ${target.x},${target.y}`);
|
||||
const click = await automationRepo.clickScreen(target.x, target.y);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-click",
|
||||
severity: click.inputBlocked || click.clicked === false || click.moved === false ? "warn" : "ok",
|
||||
message: `${label} artifact tab click at ${target.x},${target.y}`,
|
||||
details: summarizeClickResult(click),
|
||||
capture: tabProbe,
|
||||
});
|
||||
if (click.inputBlocked || click.clicked === false) return null;
|
||||
const gridProbe = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 900,
|
||||
predicate: (capture) => Boolean(capture?.inventoryGrid && capture.inventoryGrid.source !== "missing"),
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: gridProbe ? "info" : "warn",
|
||||
message: `${label} capture after artifact-tab click before first tile selection.`,
|
||||
capture: gridProbe,
|
||||
});
|
||||
const firstTarget = gridProbe?.inventoryGrid?.centers?.[0];
|
||||
if (!firstTarget) return gridProbe;
|
||||
appendAutomationLog(`${label} entry first artifact tile -> ${firstTarget.x},${firstTarget.y}`);
|
||||
const firstTileClick = await automationRepo.clickScreen(firstTarget.x, firstTarget.y);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-click",
|
||||
severity: firstTileClick.inputBlocked || firstTileClick.clicked === false || firstTileClick.moved === false ? "warn" : "ok",
|
||||
message: `${label} first artifact tile click at ${firstTarget.x},${firstTarget.y}`,
|
||||
details: summarizeClickResult(firstTileClick),
|
||||
capture: gridProbe,
|
||||
});
|
||||
if (firstTileClick.inputBlocked || firstTileClick.clicked === false) return null;
|
||||
const finalCapture = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 900,
|
||||
predicate: (capture) => validateAutoScanEntryPreflight(capture).ok,
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: finalCapture ? "info" : "warn",
|
||||
message: `${label} final capture after first artifact selection.`,
|
||||
capture: finalCapture,
|
||||
});
|
||||
return finalCapture;
|
||||
}
|
||||
|
||||
async function waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs,
|
||||
pollMs = 150,
|
||||
predicate,
|
||||
}: {
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
timeoutMs: number;
|
||||
pollMs?: number;
|
||||
predicate: (capture: CaptureResult | null) => boolean;
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
let latest: CaptureResult | null = null;
|
||||
while (Date.now() - startedAt <= timeoutMs) {
|
||||
latest = await captureFastSelectedSource(0, true);
|
||||
if (predicate(latest)) return latest;
|
||||
const remaining = timeoutMs - (Date.now() - startedAt);
|
||||
if (remaining <= 0) break;
|
||||
await wait(Math.min(pollMs, remaining));
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
@@ -110,7 +110,7 @@ function shouldRecoverIntoStore(existing: StoredArtifactRecord | undefined, inco
|
||||
}
|
||||
|
||||
function getDefaultScannerRules(loadedRules: { rules?: ScannerLearningRules } | null | undefined): ScannerLearningRules {
|
||||
return { textReplacements: { ...(loadedRules?.rules?.textReplacements ?? {}) } };
|
||||
return mergeLearningRulePayloads({}, loadedRules?.rules);
|
||||
}
|
||||
|
||||
async function loadReviewSamplesAndRecover(context: ReviewStateContext, rules: ScannerLearningRules, limit = REVIEW_SAMPLE_LIMIT_INITIAL) {
|
||||
@@ -213,17 +213,47 @@ export async function mergeLearningRules(
|
||||
context: ReviewStateContext,
|
||||
) {
|
||||
if (!nextRules || countScannerLearningRules(nextRules) === 0) return null;
|
||||
const merged = {
|
||||
textReplacements: {
|
||||
...currentRules.textReplacements,
|
||||
...(nextRules.textReplacements ?? {}),
|
||||
},
|
||||
};
|
||||
const merged = mergeLearningRulePayloads(currentRules, nextRules);
|
||||
context.setScannerLearningRules(merged);
|
||||
const result = await context.learningRepo?.saveRules?.(merged).catch(() => null);
|
||||
return { result, merged };
|
||||
}
|
||||
|
||||
function mergeLearningRulePayloads(
|
||||
current: Partial<ScannerLearningRules> | null | undefined,
|
||||
next: Partial<ScannerLearningRules> | null | undefined,
|
||||
): ScannerLearningRules {
|
||||
return {
|
||||
textReplacements: {
|
||||
...(current?.textReplacements ?? {}),
|
||||
...(next?.textReplacements ?? {}),
|
||||
},
|
||||
fieldAliases: mergeNestedRuleMap(current?.fieldAliases, next?.fieldAliases),
|
||||
constrainedFixes: {
|
||||
...(current?.constrainedFixes ?? {}),
|
||||
...(next?.constrainedFixes ?? {}),
|
||||
},
|
||||
cropAdjustments: {
|
||||
...(current?.cropAdjustments ?? {}),
|
||||
...(next?.cropAdjustments ?? {}),
|
||||
},
|
||||
uiProfileAdjustments: {
|
||||
...(current?.uiProfileAdjustments ?? {}),
|
||||
...(next?.uiProfileAdjustments ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergeNestedRuleMap(
|
||||
current: Record<string, Record<string, string>> | undefined,
|
||||
next: Record<string, Record<string, string>> | undefined,
|
||||
) {
|
||||
const merged: Record<string, Record<string, string>> = {};
|
||||
for (const [field, aliases] of Object.entries(current ?? {})) merged[field] = { ...(aliases ?? {}) };
|
||||
for (const [field, aliases] of Object.entries(next ?? {})) merged[field] = { ...(merged[field] ?? {}), ...(aliases ?? {}) };
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function persistParsedArtifact(
|
||||
capture: CaptureResult | null,
|
||||
parsed: ParsedArtifactCandidate,
|
||||
@@ -256,6 +286,47 @@ export async function persistParsedArtifact(
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistParsedArtifactsBatch(
|
||||
items: Array<{
|
||||
capture: CaptureResult | null;
|
||||
parsed: ParsedArtifactCandidate;
|
||||
source: string;
|
||||
needsReview: boolean;
|
||||
}>,
|
||||
context: ReviewStateContext,
|
||||
) {
|
||||
const { artifactRepo, onStoredArtifactsChanged, setStoredTotal, appendAutomationLog } = context;
|
||||
if (!artifactRepo?.saveMany || items.length === 0) return 0;
|
||||
|
||||
const records: StoredArtifactRecord[] = [];
|
||||
for (const item of items) {
|
||||
const rejection = captureRejectionReason(item.capture, item.parsed);
|
||||
if (rejection) {
|
||||
appendAutomationLog(`persist skip: ${rejection}`);
|
||||
continue;
|
||||
}
|
||||
if (!shouldPersistParsedArtifact(item.parsed, item.needsReview)) {
|
||||
appendAutomationLog(`persist skip: parsed artifact bleibt vorerst nur Review (${item.parsed.name})`);
|
||||
continue;
|
||||
}
|
||||
records.push(toStoredArtifact(item.parsed, item.source, item.needsReview, item.capture?.locked));
|
||||
}
|
||||
|
||||
if (records.length === 0) return 0;
|
||||
|
||||
try {
|
||||
const result = await artifactRepo.saveMany(records);
|
||||
if (result?.ok) {
|
||||
setStoredTotal(result.total);
|
||||
void onStoredArtifactsChanged?.();
|
||||
return records.length;
|
||||
}
|
||||
return 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveReviewSample(
|
||||
capture: CaptureResult | null,
|
||||
parsed: ParsedArtifactCandidate | null,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { automationBlockReason, requiresAdminForAutomation } from "../../../lib/automationPlanner";
|
||||
import { artifactTabClickTarget, keyPressBlocked, validateAutoScanEntryPreflight, type ScanEntryMode } from "../../../lib/autoScanEntry";
|
||||
import { validateAutoScanEntryPreflight, type ScanEntryMode } from "../../../lib/autoScanEntry";
|
||||
import { captureRejectionReason } from "../../../lib/scannerCaptureQuality";
|
||||
import { runAutoScanLoop } from "../../../lib/autoScanLoop";
|
||||
import { addCaptureTiming, clampScanLimit, emptyAutoScanStats, resolveScanTargetCount, updateScanTiming, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
|
||||
import { getAutoReviewReason, wait } from "../../../lib/scanReviewUtils";
|
||||
import { summarizeClickResult, summarizeKeyPressResult, type createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
|
||||
import type { AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||
import { type createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
|
||||
import type { AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||
import type {
|
||||
AutomationGuard,
|
||||
BooleanResult,
|
||||
@@ -17,8 +17,8 @@ import type {
|
||||
ScrollResult,
|
||||
} from "../../../types/global";
|
||||
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||
import { prepareAutoScanEntry } from "./scanViewEntryActions";
|
||||
|
||||
export interface ScanActionContext {
|
||||
autoScanRunning: boolean;
|
||||
@@ -43,6 +43,7 @@ export interface ScanActionContext {
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
|
||||
persistParsedArtifact: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate, source: string, needsReview: boolean) => Promise<boolean>;
|
||||
persistParsedArtifactsBatch?: (items: Array<{ capture: CaptureResult | null; parsed: ParsedArtifactCandidate; source: string; needsReview: boolean }>) => Promise<number>;
|
||||
saveReviewSample: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason?: string) => Promise<BooleanResult | null>;
|
||||
shouldFlagArtifactForReview: (parsed: ParsedArtifactCandidate | null) => boolean;
|
||||
focusDashboard: () => Promise<void>;
|
||||
@@ -74,6 +75,7 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
|
||||
captureSelectedSource,
|
||||
parseArtifact,
|
||||
persistParsedArtifact,
|
||||
persistParsedArtifactsBatch,
|
||||
saveReviewSample,
|
||||
shouldFlagArtifactForReview,
|
||||
focusDashboard,
|
||||
@@ -128,7 +130,7 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
|
||||
stats.attempted++;
|
||||
stats.verified++;
|
||||
stats.parsed++;
|
||||
addCaptureTiming(stats, capture.timings);
|
||||
addCaptureTiming(stats, capture.timings, capture.elapsedMs);
|
||||
|
||||
const reason = getAutoReviewReason(capture, parsed);
|
||||
const needsReview = shouldFlagArtifactForReview(parsed);
|
||||
@@ -183,6 +185,7 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
captureFastSelectedSource,
|
||||
parseArtifact,
|
||||
persistParsedArtifact,
|
||||
persistParsedArtifactsBatch,
|
||||
saveReviewSample,
|
||||
shouldFlagArtifactForReview,
|
||||
scanLimit: configuredScanLimit,
|
||||
@@ -393,6 +396,7 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
captureFastSelectedSource,
|
||||
parseArtifact,
|
||||
persistParsedArtifact,
|
||||
persistParsedArtifactsBatch,
|
||||
saveReviewSample,
|
||||
getAutoReviewReason,
|
||||
shouldFlagArtifactForReview,
|
||||
@@ -424,235 +428,3 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
gridLabel: result.gridLabel,
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareAutoScanEntry({
|
||||
mode,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
}: {
|
||||
mode: ScanEntryMode;
|
||||
automationRepo: AutomationRepositoryPort;
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
appendAutomationLog: (line: string) => void;
|
||||
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
|
||||
}) {
|
||||
if (mode === "visible-inventory") {
|
||||
const capture = await captureFastSelectedSource(0, true);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-visible",
|
||||
severity: capture ? "ok" : "error",
|
||||
message: capture ? "Visible inventory preflight capture ready." : "Visible inventory preflight capture failed.",
|
||||
capture,
|
||||
});
|
||||
return capture;
|
||||
}
|
||||
|
||||
if (mode === "direct-inventory") {
|
||||
return tryInventoryEntrySequence({
|
||||
label: "direct",
|
||||
sendEscapeFirst: false,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === "auto-entry") {
|
||||
const directCapture = await tryInventoryEntrySequence({
|
||||
label: "auto-direct",
|
||||
sendEscapeFirst: false,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
});
|
||||
const directPreflight = validateAutoScanEntryPreflight(directCapture);
|
||||
if (directPreflight.ok) return directCapture;
|
||||
appendAutomationLog(`auto-entry direct path failed: ${directPreflight.reason}`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-fallback",
|
||||
severity: "info",
|
||||
message: `Direct inventory entry did not reach an artifact detail card. Trying IK fallback. ${directPreflight.reason}`,
|
||||
capture: directCapture,
|
||||
});
|
||||
}
|
||||
|
||||
return tryInventoryEntrySequence({
|
||||
label: "paimon",
|
||||
sendEscapeFirst: true,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
});
|
||||
}
|
||||
|
||||
async function tryInventoryEntrySequence({
|
||||
label,
|
||||
sendEscapeFirst,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
}: {
|
||||
label: string;
|
||||
sendEscapeFirst: boolean;
|
||||
automationRepo: AutomationRepositoryPort;
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
appendAutomationLog: (line: string) => void;
|
||||
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
|
||||
}) {
|
||||
if (sendEscapeFirst) {
|
||||
const escapeResult = await automationRepo.keyPress?.("ESC");
|
||||
appendAutomationLog(`${label} entry key ESC: ${escapeResult?.ok ? "ok" : "blocked"}`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-key",
|
||||
severity: keyPressBlocked(escapeResult) ? "error" : "ok",
|
||||
message: `${label} entry key ESC`,
|
||||
details: summarizeKeyPressResult(escapeResult),
|
||||
});
|
||||
if (keyPressBlocked(escapeResult)) return null;
|
||||
const menuProbe = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 750,
|
||||
predicate: (capture) => Boolean(capture),
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: menuProbe ? "info" : "warn",
|
||||
message: `${label} capture after ESC step.`,
|
||||
capture: menuProbe,
|
||||
});
|
||||
if (menuProbe?.paimonMenu?.present) {
|
||||
const closeMenuResult = await automationRepo.keyPress?.("ESC");
|
||||
appendAutomationLog(`${label} entry key ESC close menu: ${closeMenuResult?.ok ? "ok" : "blocked"}`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-key",
|
||||
severity: keyPressBlocked(closeMenuResult) ? "error" : "ok",
|
||||
message: `${label} entry key ESC close menu`,
|
||||
details: summarizeKeyPressResult(closeMenuResult),
|
||||
});
|
||||
if (keyPressBlocked(closeMenuResult)) return menuProbe;
|
||||
const worldProbe = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 750,
|
||||
predicate: (capture) => Boolean(capture && !capture.paimonMenu?.present),
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: worldProbe ? "info" : "warn",
|
||||
message: `${label} capture after closing Paimon menu.`,
|
||||
capture: worldProbe,
|
||||
});
|
||||
if (worldProbe?.paimonMenu?.present) {
|
||||
appendAutomationLog(`${label} entry stopped: Paimon menu still visible after second ESC`);
|
||||
return worldProbe;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inventoryResult = await automationRepo.keyPress?.("B");
|
||||
appendAutomationLog(`${label} entry key B: ${inventoryResult?.ok ? "ok" : "blocked"}`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-key",
|
||||
severity: keyPressBlocked(inventoryResult) ? "error" : "ok",
|
||||
message: `${label} entry key B`,
|
||||
details: summarizeKeyPressResult(inventoryResult),
|
||||
});
|
||||
if (keyPressBlocked(inventoryResult)) return null;
|
||||
const tabProbe = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 1200,
|
||||
predicate: (capture) => Boolean(capture && !capture.paimonMenu?.present && capture.inventoryGrid && capture.inventoryGrid.source !== "missing"),
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: tabProbe ? "info" : "warn",
|
||||
message: `${label} capture after Inventory-key step.`,
|
||||
capture: tabProbe,
|
||||
});
|
||||
if (tabProbe?.paimonMenu?.present) {
|
||||
appendAutomationLog(`${label} entry stopped: Paimon menu still visible after Inventory key`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: "info",
|
||||
message: `${label} entry stopped before tab click because Paimon menu is still visible.`,
|
||||
capture: tabProbe,
|
||||
});
|
||||
return tabProbe;
|
||||
}
|
||||
if (!tabProbe?.inventoryGrid || tabProbe.inventoryGrid.source === "missing") return tabProbe;
|
||||
|
||||
const target = artifactTabClickTarget(tabProbe);
|
||||
appendAutomationLog(`${label} entry artifact tab -> ${target.x},${target.y}`);
|
||||
const click = await automationRepo.clickScreen(target.x, target.y);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-click",
|
||||
severity: click.inputBlocked || click.clicked === false || click.moved === false ? "warn" : "ok",
|
||||
message: `${label} artifact tab click at ${target.x},${target.y}`,
|
||||
details: summarizeClickResult(click),
|
||||
capture: tabProbe,
|
||||
});
|
||||
if (click.inputBlocked || click.clicked === false) return null;
|
||||
const gridProbe = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 900,
|
||||
predicate: (capture) => Boolean(capture?.inventoryGrid && capture.inventoryGrid.source !== "missing"),
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: gridProbe ? "info" : "warn",
|
||||
message: `${label} capture after artifact-tab click before first tile selection.`,
|
||||
capture: gridProbe,
|
||||
});
|
||||
const firstTarget = gridProbe?.inventoryGrid?.centers?.[0];
|
||||
if (!firstTarget) return gridProbe;
|
||||
appendAutomationLog(`${label} entry first artifact tile -> ${firstTarget.x},${firstTarget.y}`);
|
||||
const firstTileClick = await automationRepo.clickScreen(firstTarget.x, firstTarget.y);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-click",
|
||||
severity: firstTileClick.inputBlocked || firstTileClick.clicked === false || firstTileClick.moved === false ? "warn" : "ok",
|
||||
message: `${label} first artifact tile click at ${firstTarget.x},${firstTarget.y}`,
|
||||
details: summarizeClickResult(firstTileClick),
|
||||
capture: gridProbe,
|
||||
});
|
||||
if (firstTileClick.inputBlocked || firstTileClick.clicked === false) return null;
|
||||
const finalCapture = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 900,
|
||||
predicate: (capture) => validateAutoScanEntryPreflight(capture).ok,
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: finalCapture ? "info" : "warn",
|
||||
message: `${label} final capture after first artifact selection.`,
|
||||
capture: finalCapture,
|
||||
});
|
||||
return finalCapture;
|
||||
}
|
||||
|
||||
async function waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs,
|
||||
pollMs = 150,
|
||||
predicate,
|
||||
}: {
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
timeoutMs: number;
|
||||
pollMs?: number;
|
||||
predicate: (capture: CaptureResult | null) => boolean;
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
let latest: CaptureResult | null = null;
|
||||
while (Date.now() - startedAt <= timeoutMs) {
|
||||
latest = await captureFastSelectedSource(0, true);
|
||||
if (predicate(latest)) return latest;
|
||||
const remaining = timeoutMs - (Date.now() - startedAt);
|
||||
if (remaining <= 0) break;
|
||||
await wait(Math.min(pollMs, remaining));
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback } from "react";
|
||||
import { goodDatabaseToStoredArtifacts, type GoodImportDatabase, storedArtifactsToGood } from "../../../lib/goodInterop";
|
||||
import type { ArtifactRepositoryPort, ScanExportPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||
import type { StoredArtifactRecord } from "../../../types/storage";
|
||||
|
||||
interface UseScanGoodInteropInput {
|
||||
artifactRepo?: ArtifactRepositoryPort;
|
||||
exportRepo?: ScanExportPort;
|
||||
onStoredArtifactsChanged?: () => Promise<void>;
|
||||
setStoredTotal: (value: number | null) => void;
|
||||
bridgeReady: boolean;
|
||||
}
|
||||
|
||||
export function useScanGoodInterop({
|
||||
artifactRepo,
|
||||
exportRepo,
|
||||
onStoredArtifactsChanged,
|
||||
setStoredTotal,
|
||||
bridgeReady,
|
||||
}: UseScanGoodInteropInput) {
|
||||
const canGoodInterop = bridgeReady && Boolean(artifactRepo?.loadAll) && Boolean(artifactRepo?.saveMany);
|
||||
|
||||
const exportGoodFromStore = useCallback(async () => {
|
||||
if (!artifactRepo?.loadAll || !exportRepo?.exportGood) return { ok: false, count: 0 };
|
||||
const loaded = await artifactRepo.loadAll();
|
||||
const records = loaded.artifacts ?? [];
|
||||
const good = storedArtifactsToGood(records);
|
||||
const result = await exportRepo.exportGood(good);
|
||||
return { ok: Boolean(result.ok), path: result.path, count: good.artifacts.length };
|
||||
}, [artifactRepo, exportRepo]);
|
||||
|
||||
const importGoodArtifacts = useCallback(async (records: StoredArtifactRecord[]) => {
|
||||
if (!artifactRepo?.saveMany || records.length === 0) return { ok: false, added: 0, updated: 0 };
|
||||
const result = await artifactRepo.saveMany(records);
|
||||
if (typeof result.total === "number") setStoredTotal(result.total);
|
||||
await onStoredArtifactsChanged?.();
|
||||
return { ok: Boolean(result.ok), added: result.added ?? 0, updated: result.updated ?? 0 };
|
||||
}, [artifactRepo, onStoredArtifactsChanged, setStoredTotal]);
|
||||
|
||||
const importGoodFromFile = useCallback(async () => {
|
||||
if (!exportRepo?.importGoodFile || !artifactRepo?.saveMany) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, error: "GOOD import is unavailable." };
|
||||
}
|
||||
const fileResult = await exportRepo.importGoodFile();
|
||||
if (fileResult.canceled) return { ok: false, added: 0, updated: 0, count: 0, canceled: true };
|
||||
if (!fileResult.ok) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, path: fileResult.path, error: fileResult.error };
|
||||
}
|
||||
const records = goodDatabaseToStoredArtifacts(fileResult.database as GoodImportDatabase);
|
||||
if (records.length === 0) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, path: fileResult.path, error: "No valid GOOD artifacts found." };
|
||||
}
|
||||
const saved = await importGoodArtifacts(records);
|
||||
return { ...saved, count: records.length, path: fileResult.path };
|
||||
}, [artifactRepo, exportRepo, importGoodArtifacts]);
|
||||
|
||||
return {
|
||||
canGoodInterop,
|
||||
exportGoodFromStore,
|
||||
importGoodFromFile,
|
||||
importGoodArtifacts,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
initializeLearningState,
|
||||
loadReviewQueue as loadReviewQueueFromRepo,
|
||||
persistParsedArtifact as persistParsedArtifactHelper,
|
||||
persistParsedArtifactsBatch as persistParsedArtifactsBatchHelper,
|
||||
saveReviewSample as saveReviewSampleHelper,
|
||||
} from "./scanViewReviewHelpers";
|
||||
import { createReviewContext, createScanActionContext } from "./scanViewControllerService";
|
||||
@@ -186,6 +187,15 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
[reviewContext],
|
||||
);
|
||||
|
||||
const parseArtifactsAndPersistBatch = useCallback(
|
||||
async function parseArtifactsAndPersistBatch(
|
||||
items: Array<{ capture: CaptureResult | null; parsed: ParsedArtifactCandidate; source: string; needsReview: boolean }>,
|
||||
) {
|
||||
return persistParsedArtifactsBatchHelper(items, reviewContext);
|
||||
},
|
||||
[reviewContext],
|
||||
);
|
||||
|
||||
const handleSaveReviewSample = useCallback(
|
||||
async function handleSaveReviewSample(
|
||||
capture: CaptureResult | null = latestCapture,
|
||||
@@ -227,6 +237,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
appendDiagnosticEvent,
|
||||
parseArtifact,
|
||||
persistParsedArtifact: parseArtifactAndPersist,
|
||||
persistParsedArtifactsBatch: parseArtifactsAndPersistBatch,
|
||||
saveReviewSample: handleSaveReviewSample,
|
||||
focusDashboard,
|
||||
captureSelectedSource: (delayMs = 0, focusGenshin = false, options) => captureSelectedSource(delayMs, focusGenshin, {
|
||||
@@ -265,6 +276,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
appendDiagnosticEvent,
|
||||
parseArtifact,
|
||||
parseArtifactAndPersist,
|
||||
parseArtifactsAndPersistBatch,
|
||||
handleSaveReviewSample,
|
||||
focusDashboard,
|
||||
captureSelectedSource,
|
||||
@@ -317,13 +329,17 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
severity: visibleInventoryReady ? "ok" : "info",
|
||||
message: visibleInventoryReady
|
||||
? "Artifact inventory detail view already visible; starting scan directly."
|
||||
: "Artifact detail view is not ready; trying direct inventory entry, then Inventory Kamera fallback.",
|
||||
: "Artifact detail view is not ready; guided scan waits for a visible artifact detail card instead of navigating.",
|
||||
capture: preflightCapture,
|
||||
});
|
||||
if (!visibleInventoryReady) {
|
||||
setReviewStatus("Auto-Scan wartet: Bitte Artifact-Inventar mit sichtbarer Detailkarte oeffnen und erneut starten.");
|
||||
return;
|
||||
}
|
||||
await runVisibleGridScanAction(scanActionContext, {
|
||||
scanLimit: options.scanLimit,
|
||||
scanEntryMode: visibleInventoryReady ? "visible-inventory" : "auto-entry",
|
||||
processInitialSelection: visibleInventoryReady,
|
||||
scanEntryMode: "visible-inventory",
|
||||
processInitialSelection: true,
|
||||
ocrEngine: options.ocrEngine,
|
||||
});
|
||||
}, [
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
parseLearnedArtifact as parseLearnedArtifactHelper,
|
||||
} from "./scanViewReviewHelpers";
|
||||
import { useScanRuntimeInfo } from "./useScanRuntimeInfo";
|
||||
import { useScanGoodInterop } from "./useScanGoodInterop";
|
||||
import { useScanSnapshotPublisher } from "./useScanSnapshotPublisher";
|
||||
import { useScanViewActions } from "./useScanViewActions";
|
||||
import { useScanViewStateSync } from "./useScanViewStateSync";
|
||||
@@ -17,8 +18,6 @@ import { emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type Sc
|
||||
import { createScanDiagnosticEvent, summarizeClickResult, type ScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
|
||||
import type { ScanViewProps, ScanViewControllerResult } from "../types";
|
||||
import type { CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global";
|
||||
import type { StoredArtifactRecord } from "../../../types/storage";
|
||||
import { goodDatabaseToStoredArtifacts, type GoodImportDatabase, storedArtifactsToGood } from "../../../lib/goodInterop";
|
||||
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||
|
||||
export function useScanViewController({
|
||||
@@ -79,7 +78,7 @@ export function useScanViewController({
|
||||
const canAutoScan = bridgeReady && Boolean(automationRepo?.clickScreen) && Boolean(automationRepo?.scrollScreen);
|
||||
const reviewAnalysis = useMemo(() => analyzeReviewSamples(reviewSamples), [reviewSamples]);
|
||||
const learningRuleCount = countScannerLearningRules(scannerLearningRules);
|
||||
const detectedInventoryCount = latestCapture?.inventoryCount?.current ?? 0;
|
||||
const detectedInventoryCount = latestCapture?.inventoryCount?.total ?? latestCapture?.inventoryCount?.current ?? 0;
|
||||
const activeTargetCount = autoScanRunning
|
||||
? resolveScanTargetCount(scanLimit, detectedInventoryCount)
|
||||
: scanSummary?.targetCount ?? resolveScanTargetCount(scanLimit, detectedInventoryCount);
|
||||
@@ -175,41 +174,18 @@ export function useScanViewController({
|
||||
setReviewQueueOpen,
|
||||
});
|
||||
|
||||
const canGoodInterop = bridgeReady && Boolean(artifactRepo?.loadAll) && Boolean(artifactRepo?.saveMany);
|
||||
|
||||
const exportGoodFromStore = useCallback(async () => {
|
||||
if (!artifactRepo?.loadAll || !exportRepo?.exportGood) return { ok: false, count: 0 };
|
||||
const loaded = await artifactRepo.loadAll();
|
||||
const records = loaded.artifacts ?? [];
|
||||
const good = storedArtifactsToGood(records);
|
||||
const result = await exportRepo.exportGood(good);
|
||||
return { ok: Boolean(result.ok), path: result.path, count: good.artifacts.length };
|
||||
}, [artifactRepo, exportRepo]);
|
||||
|
||||
const importGoodArtifacts = useCallback(async (records: StoredArtifactRecord[]) => {
|
||||
if (!artifactRepo?.saveMany || records.length === 0) return { ok: false, added: 0, updated: 0 };
|
||||
const result = await artifactRepo.saveMany(records);
|
||||
if (typeof result.total === "number") setStoredTotal(result.total);
|
||||
await onStoredArtifactsChanged?.();
|
||||
return { ok: Boolean(result.ok), added: result.added ?? 0, updated: result.updated ?? 0 };
|
||||
}, [artifactRepo, onStoredArtifactsChanged]);
|
||||
|
||||
const importGoodFromFile = useCallback(async () => {
|
||||
if (!exportRepo?.importGoodFile || !artifactRepo?.saveMany) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, error: "GOOD import is unavailable." };
|
||||
}
|
||||
const fileResult = await exportRepo.importGoodFile();
|
||||
if (fileResult.canceled) return { ok: false, added: 0, updated: 0, count: 0, canceled: true };
|
||||
if (!fileResult.ok) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, path: fileResult.path, error: fileResult.error };
|
||||
}
|
||||
const records = goodDatabaseToStoredArtifacts(fileResult.database as GoodImportDatabase);
|
||||
if (records.length === 0) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, path: fileResult.path, error: "No valid GOOD artifacts found." };
|
||||
}
|
||||
const saved = await importGoodArtifacts(records);
|
||||
return { ...saved, count: records.length, path: fileResult.path };
|
||||
}, [artifactRepo, exportRepo, importGoodArtifacts]);
|
||||
const {
|
||||
canGoodInterop,
|
||||
exportGoodFromStore,
|
||||
importGoodFromFile,
|
||||
importGoodArtifacts,
|
||||
} = useScanGoodInterop({
|
||||
artifactRepo,
|
||||
exportRepo,
|
||||
onStoredArtifactsChanged,
|
||||
setStoredTotal,
|
||||
bridgeReady,
|
||||
});
|
||||
|
||||
useScanViewStateSync({
|
||||
artifactRepo,
|
||||
|
||||
@@ -20,7 +20,7 @@ export function useScanViewStateSync({
|
||||
setStoredTotal,
|
||||
}: ScanViewStateSyncInput) {
|
||||
useEffect(() => {
|
||||
const detectedCount = latestCapture?.inventoryCount?.current ?? 0;
|
||||
const detectedCount = latestCapture?.inventoryCount?.total ?? latestCapture?.inventoryCount?.current ?? 0;
|
||||
if (!scanLimitTouched && detectedCount > 0) {
|
||||
setScanLimit(clampScanLimit(detectedCount));
|
||||
}
|
||||
@@ -32,4 +32,3 @@ export function useScanViewStateSync({
|
||||
}).catch(() => undefined);
|
||||
}, [artifactRepo, setStoredTotal]);
|
||||
}
|
||||
|
||||
|
||||
@@ -140,6 +140,51 @@ describe("parseArtifactCandidate", () => {
|
||||
expect(parsed?.fields.mainValue.source).toBe("derived");
|
||||
});
|
||||
|
||||
it("derives percent main stat values when slot rules disallow flat HP ATK or DEF", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Moonlit Offering's Final Hour",
|
||||
"artifact-main-stat-label": "HP",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "+ ATK+19\n- Energy Recharge+6.5%\n+ CRIT DMG+18.7%\n- DEF+53",
|
||||
}));
|
||||
|
||||
expect(parsed?.slot).toBe("Sands of Eon");
|
||||
expect(parsed?.mainStat).toBe("HP%");
|
||||
expect(parsed?.mainValue).toBe("46.6%");
|
||||
expect(parsed?.fields.mainStat.source).toBe("derived");
|
||||
expect(parsed?.fields.mainValue.source).toBe("derived");
|
||||
});
|
||||
|
||||
it("recovers a known piece from a truncated Viridescent Determination OCR tail", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Determmation oT",
|
||||
"artifact-main-stat-label": "Energy Recharge",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "- ATK+5.8%\n- Elemental Mastery+37\nHP+11.7%\n+ ATK+54",
|
||||
}));
|
||||
|
||||
expect(parsed?.name).toBe("Viridescent Venerer's Determination");
|
||||
expect(parsed?.slot).toBe("Sands of Eon");
|
||||
expect(parsed?.setName).toBe("Viridescent Venerer");
|
||||
expect(parsed?.mainStat).toBe("Energy Recharge");
|
||||
expect(parsed?.mainValue).toBe("51.8%");
|
||||
});
|
||||
|
||||
it("recovers long Disenchantment piece names from truncated OCR fragments", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Postintty That Ceased Upon",
|
||||
"artifact-main-stat-label": "DEF",
|
||||
"artifact-level": "+0",
|
||||
"artifact-substats": "+ DEF+21\n+ Energy Recharge+4.5%\n+ CRIT Rate+3.1%\n- ATK+14",
|
||||
}));
|
||||
|
||||
expect(parsed?.name).toBe("Moment That Ceased Upon Waking From Grand Dreams");
|
||||
expect(parsed?.slot).toBe("Sands of Eon");
|
||||
expect(parsed?.setName).toBe("Disenchantment in Deep Shadow");
|
||||
expect(parsed?.mainStat).toBe("DEF%");
|
||||
expect(parsed?.mainValue).toBe("8.7%");
|
||||
});
|
||||
|
||||
it("derives slot and set from the piece name when fast auto-scan skips slot OCR", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Gladiator's Nostalgia",
|
||||
@@ -265,6 +310,46 @@ describe("parseArtifactCandidate", () => {
|
||||
expect(parsed?.equipped).toBe("Bennett");
|
||||
});
|
||||
|
||||
it("recognizes equipped characters when OCR splits the footer label and name", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-title": "Gladiator's Nostalgia\nFlower of Life",
|
||||
"artifact-main-stat": "HP\n4,780",
|
||||
"artifact-substats": "+ Energy Recharge+11.0%\n+ ATK+9.9%\n+ HP+14.6%\n+ CRIT DMG+12.4%",
|
||||
"artifact-set-effects": "Gladiator's Finale:\n2-Piece Set: ATK +18%",
|
||||
"artifact-footer": "Equipped:\nBennett",
|
||||
}));
|
||||
|
||||
expect(parsed?.equipped).toBe("Bennett");
|
||||
expect(parsed?.fields.equipped.source).toBe("fallback");
|
||||
});
|
||||
|
||||
it("does not persist unknown one-letter equipped fragments as characters", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-title": "Vessel of Plenty",
|
||||
"artifact-main-stat": "Goblet of Eonothem\nDLT\n58.3%",
|
||||
"artifact-substats": "- DEF+58\n- Elemental Mastery+47\n+ CRIT Rate+5.8%\n+ HP+299",
|
||||
"artifact-footer": "Equipped: I -",
|
||||
}));
|
||||
|
||||
expect(parsed?.equipped).toBe("Not detected");
|
||||
expect(parsed?.fields.equipped.source).toBe("missing");
|
||||
});
|
||||
|
||||
it("normalizes equipped footer trailing fragments to a known character", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Pristine Plume of the Blessed",
|
||||
"artifact-slot": "Plume of Death",
|
||||
"artifact-main-stat-label": "ATK",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "+ HP+16.9%\n- ATK+8.7%\n+ CRIT DMG+13.2%\n+ Elemental Mastery+21",
|
||||
"artifact-set-effects": "2-Piece Set: Energy Recharge +20%.",
|
||||
"artifact-footer": "Equipped: Linnea l",
|
||||
}));
|
||||
|
||||
expect(parsed?.equipped).toBe("Linnea");
|
||||
expect(parsed?.fields.equipped.source).toBe("fallback");
|
||||
});
|
||||
|
||||
it("recognizes ATK percent main stats from OCR text on non-fixed slots", () => {
|
||||
const sands = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-title": "Myths of the Night Realm\nSands of Eon",
|
||||
|
||||
@@ -82,6 +82,7 @@ export function parseArtifactCandidate(capture: CaptureResult | null): ParsedArt
|
||||
const parsedLevel = levelField.value ? Number.parseInt(levelField.value, 10) : null;
|
||||
const level = parsedLevel ?? 0;
|
||||
let mainStatField = inferMainStat(slotField.value, mainText);
|
||||
mainStatField = promoteSlotPercentMainStat(slotField.value, mainStatField);
|
||||
let mainValueField = findMainValue(mainText, mainStatField.value, slotField.value, parsedLevel);
|
||||
if (!mainStatField.value && mainValueField.value) {
|
||||
const inferredFromValue = inferMainStatFromValue(slotField.value, mainValueField.value, mainText, parsedLevel);
|
||||
@@ -227,6 +228,8 @@ function parseSetName(setText: string, artifactName: ParsedField): ParsedField {
|
||||
const setFromText = fuzzyFindKnown(`${directLine ?? ""}\n${setText}`, knownSets, 0.64);
|
||||
if (setFromText && (!setFromPiece || setFromText.score >= 0.78)) return field(setFromText.value, Math.round(setFromText.score * 100), setFromText.score >= 0.95 ? "ocr" : "fallback");
|
||||
if (setFromPiece) return field(setFromPiece, derivedConfidence(artifactName, 92), "derived");
|
||||
const partialSetFromPiece = deriveSetFromPartialPieceName(artifactName.value);
|
||||
if (partialSetFromPiece) return field(partialSetFromPiece, 72, "derived");
|
||||
return setFromText ? field(setFromText.value, Math.round(setFromText.score * 100), "fallback") : field("", 0, "missing");
|
||||
}
|
||||
|
||||
@@ -275,6 +278,22 @@ function firstUsefulLine(text: string, rejectIncludes: string[]) {
|
||||
.find((line) => line.length > 5 && !rejectIncludes.some((reject) => simplifyForMatch(line).includes(simplifyForMatch(reject)))) ?? "";
|
||||
}
|
||||
|
||||
function deriveSetFromPartialPieceName(text: string) {
|
||||
const words = cleanupOcrLabel(text)
|
||||
.split(/\s+/)
|
||||
.map((word) => simplifyForMatch(word))
|
||||
.filter((word) => word.length >= 5);
|
||||
if (words.length < 2) return "";
|
||||
|
||||
const candidates = knownPieceNames.filter((piece) => {
|
||||
const normalizedPiece = simplifyForMatch(piece);
|
||||
const hits = words.filter((word) => normalizedPiece.includes(word)).length;
|
||||
return hits >= 2;
|
||||
});
|
||||
const sets = [...new Set(candidates.map((piece) => pieceToSet.get(piece)).filter((set): set is string => Boolean(set)))];
|
||||
return sets.length === 1 ? sets[0] : "";
|
||||
}
|
||||
|
||||
function findMainValue(text: string, mainStat: string, slot: string, level: number | null): ParsedField {
|
||||
const cleaned = text.replace(/\b20\b/g, " ").replace(/[Oo]/g, "0");
|
||||
const percentValue = extractPercentValue(cleaned);
|
||||
@@ -504,7 +523,7 @@ function parseEquippedCharacter(text: string): ParsedField {
|
||||
const wholeTextMatch = fallbackSearch ? fuzzyFindKnown(fallbackSearch, knownCharacters, 0.88) : null;
|
||||
if (wholeTextMatch) return field(wholeTextMatch.value, Math.round(wholeTextMatch.score * 100), "fallback");
|
||||
|
||||
return afterLabel ? field(afterLabel, 50, "fallback") : field("Not detected", 45, "missing");
|
||||
return field("Not detected", 45, "missing");
|
||||
}
|
||||
|
||||
function field(value: string, confidence: number, source: ParsedField["source"]): ParsedField {
|
||||
@@ -520,6 +539,20 @@ function promotePercentVariant(stat: string, text: string) {
|
||||
return stat;
|
||||
}
|
||||
|
||||
function promoteSlotPercentMainStat(slot: string, mainStat: ParsedField): ParsedField {
|
||||
if (!["ATK", "HP", "DEF"].includes(mainStat.value)) return mainStat;
|
||||
const references = getSlotMainStatValueReferences(slot);
|
||||
const hasFlat = references.some((candidate) => candidate.stat === mainStat.value);
|
||||
const hasPercent = references.some((candidate) => candidate.stat === `${mainStat.value}%`);
|
||||
if (hasFlat || !hasPercent) return mainStat;
|
||||
return {
|
||||
...mainStat,
|
||||
value: `${mainStat.value}%`,
|
||||
confidence: Math.max(mainStat.confidence, 90),
|
||||
source: "derived",
|
||||
};
|
||||
}
|
||||
|
||||
function getSlotMainStatValueReferences(slot: string): MainStatValueReference[] {
|
||||
const valueReferences = mainStatValueReferences[slot];
|
||||
return Array.isArray(valueReferences) ? valueReferences as MainStatValueReference[] : [];
|
||||
@@ -607,6 +640,7 @@ function cleanupOcrLabel(line: string) {
|
||||
function cleanupCharacterNoise(text: string) {
|
||||
return text
|
||||
.replace(/^.*?equipped\s*:?\s*/i, "")
|
||||
.replace(/^(?:by|to)\s+/i, "")
|
||||
.replace(/[^A-Za-z'\-\s]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
@@ -6,10 +6,10 @@ describe("autoScanController", () => {
|
||||
expect(classifyAutoScanCapture({ signature: "", lastDetailSignature: "", seen: new Set() })).toMatchObject({ kind: "unreadable" });
|
||||
});
|
||||
|
||||
it("separates stuck detail views from duplicates", () => {
|
||||
it("treats repeated readable signatures as duplicates", () => {
|
||||
const seen = new Set(["same"]);
|
||||
|
||||
expect(classifyAutoScanCapture({ signature: "same", lastDetailSignature: "same", seen })).toMatchObject({ kind: "stuck" });
|
||||
expect(classifyAutoScanCapture({ signature: "same", lastDetailSignature: "same", seen })).toMatchObject({ kind: "duplicate" });
|
||||
expect(classifyAutoScanCapture({ signature: "same", lastDetailSignature: "other", seen })).toMatchObject({ kind: "duplicate" });
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ export function classifyAutoScanCapture({
|
||||
seen: ReadonlySet<string>;
|
||||
}): AutoScanCaptureDecision {
|
||||
if (!signature) return { kind: "unreadable", countAsMiss: true };
|
||||
if (signature === lastDetailSignature && seen.has(signature)) return { kind: "stuck", countAsMiss: true, signature };
|
||||
if (seen.has(signature)) return { kind: "duplicate", countAsDuplicate: true, signature };
|
||||
return { kind: "new", countAsParsed: true, signature };
|
||||
}
|
||||
|
||||
@@ -265,9 +265,9 @@ describe("autoScanLoop fingerprints", () => {
|
||||
ocrProfile: "fast",
|
||||
ocrEngine: "ik-traineddata",
|
||||
omitFullFrame: true,
|
||||
omitDetailPreview: true,
|
||||
omitInventoryPreview: true,
|
||||
omitCropImages: true,
|
||||
omitEquippedOcr: true,
|
||||
skipOcrUnlessArtifactDetail: true,
|
||||
});
|
||||
});
|
||||
|
||||
+123
-59
@@ -32,6 +32,14 @@ export type AutoScanLoopDependencies = {
|
||||
source: string,
|
||||
needsReview: boolean,
|
||||
) => Promise<boolean>;
|
||||
persistParsedArtifactsBatch?: (
|
||||
items: Array<{
|
||||
capture: CaptureResult | null;
|
||||
parsed: ParsedArtifactCandidate;
|
||||
source: string;
|
||||
needsReview: boolean;
|
||||
}>,
|
||||
) => Promise<number>;
|
||||
saveReviewSample: (
|
||||
capture: CaptureResult | null,
|
||||
parsed: ParsedArtifactCandidate | null,
|
||||
@@ -65,15 +73,17 @@ export type AutoScanLoopResult = {
|
||||
};
|
||||
|
||||
// Card-ready gating replaces a fixed settle delay: poll the detail fingerprint
|
||||
// until it has changed and stabilized (or the budget is spent). See cardReadyGate.
|
||||
const CARD_READY_MAX_MS = 420;
|
||||
const CARD_READY_POLL_MS = 60;
|
||||
const CARD_READY_STABLE_SAMPLES = 2;
|
||||
const CARD_READY_ACCEPT_CHANGED_AFTER_MS = 200;
|
||||
// until it has changed, then read the artifact immediately. See cardReadyGate.
|
||||
const CARD_READY_MAX_MS = 180;
|
||||
const CARD_READY_POLL_MS = 25;
|
||||
const CARD_READY_STABLE_SAMPLES = 1;
|
||||
const CARD_READY_ACCEPT_CHANGED_AFTER_MS = 0;
|
||||
const SCROLL_READY_MAX_MS = 760;
|
||||
const SCROLL_READY_POLL_MS = 80;
|
||||
const SCROLL_READY_STABLE_SAMPLES = 2;
|
||||
const SCROLL_READY_ACCEPT_CHANGED_AFTER_MS = 100;
|
||||
const STATS_PUBLISH_INTERVAL_MS = 250;
|
||||
const ROUTINE_CLICK_LOG_INTERVAL = 12;
|
||||
const MISS_ABORT_THRESHOLD = 3;
|
||||
const UNREADABLE_ABORT_THRESHOLD = 5;
|
||||
|
||||
@@ -87,6 +97,7 @@ export async function runAutoScanLoop(
|
||||
captureFastSelectedSource,
|
||||
parseArtifact,
|
||||
persistParsedArtifact,
|
||||
persistParsedArtifactsBatch,
|
||||
saveReviewSample,
|
||||
getAutoReviewReason,
|
||||
shouldFlagArtifactForReview,
|
||||
@@ -102,34 +113,56 @@ export async function runAutoScanLoop(
|
||||
const maxTargets = resolveScanTargetCount(options.scanLimit, options.detectedInventoryCount);
|
||||
const rowsToSkip = clampSkipRows(options.skipRows);
|
||||
const seen = new Set<string>();
|
||||
const seenDetailFingerprints = new Set<string>();
|
||||
const seenPageFingerprints = new Set<string>();
|
||||
let page = 0;
|
||||
let blockedReason = "";
|
||||
let aborted = false;
|
||||
let consecutiveMisses = 0;
|
||||
let rowsQueued = 0;
|
||||
let writeQueue: Promise<void> = Promise.resolve();
|
||||
function updateStats(preserveActiveScanMs = false) {
|
||||
updateScanTiming(stats, startedAt, Date.now(), { preserveActiveScanMs });
|
||||
setAutoScanStats({ ...stats });
|
||||
let flushingWrites = false;
|
||||
const writeQueue: Array<{ label: string; task: () => Promise<void> }> = [];
|
||||
const batchedPersistQueue: Array<{
|
||||
capture: CaptureResult | null;
|
||||
parsed: ParsedArtifactCandidate;
|
||||
source: string;
|
||||
needsReview: boolean;
|
||||
}> = [];
|
||||
let lastStatsPublishAt = 0;
|
||||
function updateStats(preserveActiveScanMs = false, forcePublish = false) {
|
||||
updateScanTiming(stats, startedAt, Date.now(), { preserveActiveScanMs: preserveActiveScanMs || flushingWrites });
|
||||
const now = Date.now();
|
||||
if (forcePublish || now - lastStatsPublishAt >= STATS_PUBLISH_INTERVAL_MS) {
|
||||
lastStatsPublishAt = now;
|
||||
setAutoScanStats({ ...stats });
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueWrite(label: string, task: () => Promise<void>) {
|
||||
writeQueue = writeQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
try {
|
||||
await task();
|
||||
} catch (error) {
|
||||
appendAutomationLog(`write failed ${label}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
});
|
||||
writeQueue.push({ label, task });
|
||||
}
|
||||
|
||||
async function flushWrites() {
|
||||
await writeQueue.catch(() => undefined);
|
||||
updateStats(true);
|
||||
flushingWrites = true;
|
||||
try {
|
||||
if (persistParsedArtifactsBatch && batchedPersistQueue.length > 0) {
|
||||
const items = batchedPersistQueue.splice(0);
|
||||
try {
|
||||
stats.stored += await persistParsedArtifactsBatch(items);
|
||||
} catch (error) {
|
||||
appendAutomationLog(`write failed persist-batch:${items.length}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
for (const item of writeQueue.splice(0)) {
|
||||
try {
|
||||
await item.task();
|
||||
} catch (error) {
|
||||
appendAutomationLog(`write failed ${item.label}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
flushingWrites = false;
|
||||
}
|
||||
updateStats(true, true);
|
||||
}
|
||||
|
||||
async function finish(result: AutoScanLoopResult) {
|
||||
@@ -137,7 +170,7 @@ export async function runAutoScanLoop(
|
||||
stats.activeScanMs = Math.max(0, flushStartedAt - startedAt);
|
||||
await flushWrites();
|
||||
stats.writeFlushMs += Math.max(0, Date.now() - flushStartedAt);
|
||||
updateStats(true);
|
||||
updateStats(true, true);
|
||||
return { ...result, stats: { ...stats } };
|
||||
}
|
||||
|
||||
@@ -152,6 +185,10 @@ export async function runAutoScanLoop(
|
||||
}
|
||||
|
||||
function persistArtifactLater(capture: CaptureResult | null, parsed: ParsedArtifactCandidate, source: string, needsReview: boolean) {
|
||||
if (persistParsedArtifactsBatch) {
|
||||
batchedPersistQueue.push({ capture, parsed, source, needsReview });
|
||||
return;
|
||||
}
|
||||
enqueueWrite(`persist:${source}:${parsed.name}`, async () => {
|
||||
if (await persistParsedArtifact(capture, parsed, source, needsReview)) {
|
||||
stats.stored++;
|
||||
@@ -192,9 +229,15 @@ export async function runAutoScanLoop(
|
||||
}
|
||||
|
||||
async function clickTarget(target: GridTarget, label: string) {
|
||||
appendAutomationLog(`${label} r${target.row} c${target.col} -> ${target.x},${target.y}`);
|
||||
const clickNumber = stats.clicked + 1;
|
||||
const routineLog = clickNumber <= 2 || clickNumber % ROUTINE_CLICK_LOG_INTERVAL === 0 || label !== "click";
|
||||
if (routineLog) appendAutomationLog(`${label} r${target.row} c${target.col} -> ${target.x},${target.y}`);
|
||||
const clickStartedAt = Date.now();
|
||||
const clickResult = await api.clickScreen(target.x, target.y);
|
||||
appendClickDiagnostics(clickResult, `${label} r${target.row} c${target.col}`);
|
||||
stats.clickMs += Math.max(0, Date.now() - clickStartedAt);
|
||||
if (routineLog || reportedClickDeliveryFailure(clickResult) || clickResult.inputBlocked) {
|
||||
appendClickDiagnostics(clickResult, `${label} r${target.row} c${target.col}`);
|
||||
}
|
||||
stats.clicked++;
|
||||
stats.attempted = stats.clicked;
|
||||
updateStats();
|
||||
@@ -236,7 +279,6 @@ export async function runAutoScanLoop(
|
||||
|
||||
let lastDetailSignature = "";
|
||||
let lastDetailViewFingerprint = detailFingerprint(currentCapture);
|
||||
if (lastDetailViewFingerprint) seenDetailFingerprints.add(lastDetailViewFingerprint);
|
||||
|
||||
const shouldSkipInitialGridTarget = Boolean(options.processInitialSelection && options.skipInitialGridTarget);
|
||||
let initialProcessedOffset = 0;
|
||||
@@ -247,9 +289,9 @@ export async function runAutoScanLoop(
|
||||
ocrProfile: "fast",
|
||||
...(options.ocrEngine ? { ocrEngine: options.ocrEngine } : {}),
|
||||
omitFullFrame: true,
|
||||
omitDetailPreview: true,
|
||||
omitInventoryPreview: true,
|
||||
omitCropImages: true,
|
||||
omitEquippedOcr: true,
|
||||
skipOcrUnlessArtifactDetail: true,
|
||||
});
|
||||
const initialSurfaceRejection = validateAutoScanEntryPreflight(initialCapture);
|
||||
@@ -280,7 +322,7 @@ export async function runAutoScanLoop(
|
||||
saveAutomaticReviewSample(initialCapture, parsed, `automatic:initial-selection-rejected`);
|
||||
stats.verified++;
|
||||
stats.misses++;
|
||||
addCaptureTiming(stats, initialCapture.timings);
|
||||
addCaptureTiming(stats, initialCapture.timings, initialCapture.elapsedMs);
|
||||
initialProcessedOffset = shouldSkipInitialGridTarget ? 1 : 0;
|
||||
lastDetailViewFingerprint = detailFingerprint(initialCapture);
|
||||
updateStats();
|
||||
@@ -288,7 +330,7 @@ export async function runAutoScanLoop(
|
||||
} else {
|
||||
stats.verified++;
|
||||
stats.parsed++;
|
||||
addCaptureTiming(stats, initialCapture.timings);
|
||||
addCaptureTiming(stats, initialCapture.timings, initialCapture.elapsedMs);
|
||||
initialProcessedOffset = shouldSkipInitialGridTarget ? 1 : 0;
|
||||
const signature = sessionSignature(parsed);
|
||||
seen.add(signature);
|
||||
@@ -376,6 +418,30 @@ export async function runAutoScanLoop(
|
||||
return { ready, capture: latestCapture };
|
||||
}
|
||||
|
||||
async function captureArtifactAfterClick(): Promise<{ capture: CaptureResult | null; fingerprint: string; abortReason: string }> {
|
||||
const capture = await captureSelectedSource(0, false, {
|
||||
ocrMode: "artifact",
|
||||
ocrProfile: "fast",
|
||||
...(options.ocrEngine ? { ocrEngine: options.ocrEngine } : {}),
|
||||
omitFullFrame: true,
|
||||
omitDetailPreview: true,
|
||||
omitInventoryPreview: true,
|
||||
omitCropImages: true,
|
||||
skipOcrUnlessArtifactDetail: true,
|
||||
});
|
||||
return { capture, fingerprint: detailFingerprint(capture), abortReason: "" };
|
||||
}
|
||||
|
||||
function validateHotArtifactCapture(capture: CaptureResult | null) {
|
||||
const sourceRejection = captureSourceRejectionReason(capture);
|
||||
if (sourceRejection) return { ok: false, reason: sourceRejection };
|
||||
if (!capture?.artifactDetail?.present) {
|
||||
const confidence = capture?.artifactDetail ? ` (${capture.artifactDetail.confidence}% Detail-Marker)` : "";
|
||||
return { ok: false, reason: `Keine Artifact-Detailansicht erkannt${confidence}. Artifact-Inventar mit sichtbarer Detailkarte offen lassen.` };
|
||||
}
|
||||
return { ok: true, reason: "" };
|
||||
}
|
||||
|
||||
try {
|
||||
while (!blockedReason && !shouldStop() && stats.parsed < maxTargets) {
|
||||
page++;
|
||||
@@ -437,13 +503,15 @@ export async function runAutoScanLoop(
|
||||
appendAutomationLog(`warn r${target.row} c${target.col}: helper reported cursor/click miss; verifying detail change`);
|
||||
}
|
||||
|
||||
let ready = await awaitCardReady();
|
||||
if (ready.abortReason) {
|
||||
blockedReason = ready.abortReason;
|
||||
let read = await captureArtifactAfterClick();
|
||||
if (read.abortReason) {
|
||||
blockedReason = read.abortReason;
|
||||
aborted = true;
|
||||
break;
|
||||
}
|
||||
let changedDetail = ready.changed;
|
||||
let capture = read.capture;
|
||||
let currentDetailFingerprint = read.fingerprint;
|
||||
let changedDetail = Boolean(currentDetailFingerprint) && currentDetailFingerprint !== lastDetailViewFingerprint;
|
||||
|
||||
if (!changedDetail) {
|
||||
if (options.processInitialSelection && !initialSelectionDuplicateSkipped && !reportedClickDeliveryFailure(clickResult)) {
|
||||
@@ -469,13 +537,15 @@ export async function runAutoScanLoop(
|
||||
if (reportedClickDeliveryFailure(clickResult)) {
|
||||
appendAutomationLog(`warn r${target.row} c${target.col}: retry helper reported cursor/click miss; verifying detail change`);
|
||||
}
|
||||
ready = await awaitCardReady();
|
||||
if (ready.abortReason) {
|
||||
blockedReason = ready.abortReason;
|
||||
read = await captureArtifactAfterClick();
|
||||
if (read.abortReason) {
|
||||
blockedReason = read.abortReason;
|
||||
aborted = true;
|
||||
break;
|
||||
}
|
||||
changedDetail = ready.changed;
|
||||
capture = read.capture;
|
||||
currentDetailFingerprint = read.fingerprint;
|
||||
changedDetail = Boolean(currentDetailFingerprint) && currentDetailFingerprint !== lastDetailViewFingerprint;
|
||||
}
|
||||
|
||||
if (!changedDetail) {
|
||||
@@ -504,36 +574,28 @@ export async function runAutoScanLoop(
|
||||
|
||||
stats.verified++;
|
||||
|
||||
if (ready.fingerprint) {
|
||||
if (seenDetailFingerprints.has(ready.fingerprint)) {
|
||||
consecutiveMisses = 0;
|
||||
stats.duplicates++;
|
||||
lastDetailViewFingerprint = ready.fingerprint;
|
||||
updateStats();
|
||||
appendAutomationLog(`duplicate visual r${target.row} c${target.col}: OCR uebersprungen`);
|
||||
continue;
|
||||
let captureSurfaceRejection = validateHotArtifactCapture(capture);
|
||||
for (let retry = 1; !captureSurfaceRejection.ok && retry <= 2; retry++) {
|
||||
appendAutomationLog(`retry capture r${target.row} c${target.col}: ${captureSurfaceRejection.reason}`);
|
||||
await wait(120);
|
||||
read = await captureArtifactAfterClick();
|
||||
if (read.abortReason) {
|
||||
blockedReason = read.abortReason;
|
||||
aborted = true;
|
||||
break;
|
||||
}
|
||||
seenDetailFingerprints.add(ready.fingerprint);
|
||||
capture = read.capture;
|
||||
currentDetailFingerprint = read.fingerprint;
|
||||
captureSurfaceRejection = validateHotArtifactCapture(capture);
|
||||
}
|
||||
|
||||
const capture = await captureSelectedSource(0, false, {
|
||||
ocrMode: "artifact",
|
||||
ocrProfile: "fast",
|
||||
...(options.ocrEngine ? { ocrEngine: options.ocrEngine } : {}),
|
||||
omitFullFrame: true,
|
||||
omitInventoryPreview: true,
|
||||
omitCropImages: true,
|
||||
omitEquippedOcr: true,
|
||||
skipOcrUnlessArtifactDetail: true,
|
||||
});
|
||||
const captureSurfaceRejection = validateAutoScanEntryPreflight(capture);
|
||||
if (aborted) break;
|
||||
if (!captureSurfaceRejection.ok) {
|
||||
blockedReason = captureSurfaceRejection.reason;
|
||||
appendAutomationLog(`blocked r${target.row} c${target.col}: ${blockedReason}`);
|
||||
break;
|
||||
}
|
||||
if (capture?.ocrTimedOut) {
|
||||
addCaptureTiming(stats, capture.timings);
|
||||
addCaptureTiming(stats, capture.timings, capture.elapsedMs);
|
||||
stats.misses++;
|
||||
consecutiveMisses++;
|
||||
lastDetailViewFingerprint = detailFingerprint(capture);
|
||||
@@ -546,9 +608,11 @@ export async function runAutoScanLoop(
|
||||
continue;
|
||||
}
|
||||
|
||||
const parseStartedAt = Date.now();
|
||||
const parsed = parseArtifact(capture);
|
||||
stats.parseMs += Math.max(0, Date.now() - parseStartedAt);
|
||||
const rejection = captureRejectionReason(capture, parsed);
|
||||
addCaptureTiming(stats, capture?.timings);
|
||||
addCaptureTiming(stats, capture?.timings, capture?.elapsedMs);
|
||||
|
||||
if (rejection) {
|
||||
saveAutomaticReviewSample(capture, parsed, `automatic:capture-rejected:p${page}:r${target.row}c${target.col}`);
|
||||
|
||||
@@ -18,6 +18,18 @@ function bitmap(goldPixels: number, total: number): Bitmap {
|
||||
return { data, width: total, height: 1 };
|
||||
}
|
||||
|
||||
function solidPixels(pixels: Array<{ b: number; g: number; r: number }>): Bitmap {
|
||||
const data = Buffer.alloc(pixels.length * 4);
|
||||
pixels.forEach((pixel, index) => {
|
||||
const offset = index * 4;
|
||||
data[offset] = pixel.b;
|
||||
data[offset + 1] = pixel.g;
|
||||
data[offset + 2] = pixel.r;
|
||||
data[offset + 3] = 255;
|
||||
});
|
||||
return { data, width: pixels.length, height: 1 };
|
||||
}
|
||||
|
||||
describe("lockDetection", () => {
|
||||
it("places the lock crop on the lock button in the substat panel", () => {
|
||||
const size = { width: 2560, height: 1440 };
|
||||
@@ -41,4 +53,13 @@ describe("lockDetection", () => {
|
||||
expect(detectLockState(bitmap(20, 100))).toBe(true);
|
||||
expect(detectLockState(bitmap(1, 100))).toBe(false);
|
||||
});
|
||||
|
||||
it("counts the current red lock glyph but ignores grey unlocked button pixels", () => {
|
||||
expect(lockSignalRatio(solidPixels([{ b: 90, g: 92, r: 235 }]))).toBe(1);
|
||||
expect(lockSignalRatio({ data: Buffer.from([235, 92, 90, 255]), width: 1, height: 1 })).toBe(1);
|
||||
expect(lockSignalRatio({ data: Buffer.from([255, 235, 92, 90]), width: 1, height: 1 })).toBe(1);
|
||||
expect(lockSignalRatio(solidPixels([{ b: 235, g: 235, r: 235 }]))).toBe(0);
|
||||
expect(lockSignalRatio({ data: Buffer.from([255, 235, 235, 235]), width: 1, height: 1 })).toBe(0);
|
||||
expect(lockSignalRatio(solidPixels([{ b: 120, g: 122, r: 128 }]))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,15 +2,15 @@ import { clampRect, type LayoutRect } from "./layoutProfile.js";
|
||||
import type { Bitmap } from "./ocrPreprocess.js";
|
||||
|
||||
// EXPERIMENTAL, read-only lock-status detection (nice-to-have). Genshin shows a
|
||||
// padlock at the top-right of the artifact detail card: a bright gold fill when
|
||||
// locked, a dim outline when not. This estimates that icon region and measures
|
||||
// the fraction of bright "lock-gold" pixels; above a threshold the piece is
|
||||
// considered locked.
|
||||
// padlock at the top-right of the artifact detail card: a highlighted red/pink
|
||||
// lock in the current UI when locked, and a dim grey/white button when not.
|
||||
// Older UI captures may still use gold highlights. This estimates that icon
|
||||
// region and measures the fraction of active lock-colour pixels; above a
|
||||
// threshold the piece is considered locked.
|
||||
//
|
||||
// The crop position and threshold need calibration against a reference 16:9
|
||||
// screenshot before this is wired into the capture pipeline, so it ships pure and
|
||||
// unit-tested but unused by main.ts. It never drives any in-game action - it only
|
||||
// reads state for triage.
|
||||
// The crop position and threshold were validated with unlocked=false and
|
||||
// locked=true live samples on 2026-07-09. It never drives any in-game action -
|
||||
// it only reads state for triage and export.
|
||||
|
||||
export function lockIconCropRect(detailRect: LayoutRect, imageSize: { width: number; height: number }): LayoutRect {
|
||||
return clampRect(
|
||||
@@ -29,14 +29,52 @@ function isLockGold(b: number, g: number, r: number): boolean {
|
||||
return r >= 180 && g >= 140 && b <= 120 && r > b + 40 && g > b + 20;
|
||||
}
|
||||
|
||||
// Current Genshin detail lock indicator: pink/red lock glyph and dark button
|
||||
// when the selected artifact is locked. Unlocked buttons are mostly grey/white.
|
||||
function isLockRed(b: number, g: number, r: number): boolean {
|
||||
return r >= 180 && g <= 145 && b <= 145 && r > g + 35 && r > b + 35;
|
||||
}
|
||||
|
||||
function isActiveLockPixel(c0: number, c1: number, c2: number): boolean {
|
||||
const colorMatches = (left: number, middle: number, right: number) =>
|
||||
isLockGold(left, middle, right) ||
|
||||
isLockRed(left, middle, right) ||
|
||||
isLockGold(right, middle, left) ||
|
||||
isLockRed(right, middle, left);
|
||||
return colorMatches(c0, c1, c2);
|
||||
}
|
||||
|
||||
function inferAlphaChannel(data: Buffer | Uint8Array, pixels: number): number | null {
|
||||
const highCounts = [0, 0, 0, 0];
|
||||
for (let pixel = 0; pixel < pixels; pixel++) {
|
||||
const index = pixel * 4;
|
||||
for (let channel = 0; channel < 4; channel++) {
|
||||
if (data[index + channel] >= 245) highCounts[channel]++;
|
||||
}
|
||||
}
|
||||
const ranked = highCounts
|
||||
.map((count, channel) => ({ count, channel }))
|
||||
.sort((left, right) => right.count - left.count);
|
||||
const best = ranked[0];
|
||||
const second = ranked[1];
|
||||
if (best.count / pixels < 0.9) return null;
|
||||
if (second && second.count / pixels > 0.8) return null;
|
||||
return best.channel;
|
||||
}
|
||||
|
||||
export function lockSignalRatio(bitmap: Bitmap): number {
|
||||
const { data, width, height } = bitmap;
|
||||
const pixels = width * height;
|
||||
if (pixels === 0) return 0;
|
||||
const alphaChannel = inferAlphaChannel(data, pixels);
|
||||
let gold = 0;
|
||||
for (let pixel = 0; pixel < pixels; pixel++) {
|
||||
const index = pixel * 4;
|
||||
if (isLockGold(data[index], data[index + 1], data[index + 2])) gold++;
|
||||
const colorChannels = [0, 1, 2, 3]
|
||||
.filter((channel) => channel !== alphaChannel)
|
||||
.map((channel) => data[index + channel])
|
||||
.slice(0, 3);
|
||||
if (colorChannels.length === 3 && isActiveLockPixel(colorChannels[0], colorChannels[1], colorChannels[2])) gold++;
|
||||
}
|
||||
return gold / pixels;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CaptureResult } from "../types/global";
|
||||
import type { ParsedArtifactCandidate, ParsedField } from "./artifactOcrParser";
|
||||
import { getAutoReviewReason } from "./scanReviewUtils";
|
||||
|
||||
function field(value: string): ParsedField {
|
||||
return { value, confidence: 95, source: "ocr" };
|
||||
}
|
||||
|
||||
describe("getAutoReviewReason", () => {
|
||||
it("does not require a detail preview image for clean automatic scan captures", () => {
|
||||
const capture: CaptureResult = {
|
||||
id: "window:test",
|
||||
name: "Genshin",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
dataUrl: "",
|
||||
capturedAt: new Date(0).toISOString(),
|
||||
crops: [{ id: "artifact-name", label: "Artifact name", rect: { x: 0, y: 0, width: 10, height: 10 } }],
|
||||
ocr: [{ id: "artifact-name", label: "Artifact name", text: "Gladiator's Nostalgia", confidence: 95 }],
|
||||
};
|
||||
const parsed: ParsedArtifactCandidate = {
|
||||
name: "Gladiator's Nostalgia",
|
||||
slot: "Flower of Life",
|
||||
level: 20,
|
||||
mainStat: "HP",
|
||||
mainValue: "4780",
|
||||
substats: ["CRIT Rate", "CRIT DMG", "Energy Recharge", "ATK%"],
|
||||
setName: "Gladiator's Finale",
|
||||
equipped: "",
|
||||
confidence: 95,
|
||||
notes: [],
|
||||
fields: {
|
||||
name: field("Gladiator's Nostalgia"),
|
||||
slot: field("Flower of Life"),
|
||||
level: field("20"),
|
||||
mainStat: field("HP"),
|
||||
mainValue: field("4780"),
|
||||
setName: field("Gladiator's Finale"),
|
||||
equipped: field(""),
|
||||
substats: field("CRIT Rate, CRIT DMG, Energy Recharge, ATK%"),
|
||||
},
|
||||
};
|
||||
|
||||
expect(getAutoReviewReason(capture, parsed)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ export interface ReviewReasonInput {
|
||||
}
|
||||
|
||||
export function getAutoReviewReason(capture: ReviewReasonInput["capture"], parsed: ReviewReasonInput["parsed"]) {
|
||||
if (!capture.detailDataUrl || !capture.crops?.length || !capture.ocr?.length) return "missing-crops-or-ocr";
|
||||
if (!capture.crops?.length || !capture.ocr?.length) return "missing-crops-or-ocr";
|
||||
if (!shouldSaveReviewSample(parsed)) return "";
|
||||
const lowFields = Object.entries(parsed.fields)
|
||||
.filter(([, field]) => field.confidence < 70)
|
||||
|
||||
@@ -23,6 +23,25 @@ describe("scannerLearning", () => {
|
||||
expect(learned?.ocr?.[0]?.text).toContain("Energy Recharge+6.5%");
|
||||
});
|
||||
|
||||
it("applies field aliases and constrained fixes before parsing", () => {
|
||||
const learned = applyScannerLearningRules(capture("Equipped; Bennet\nAubade of Morningstar and Moor"), {
|
||||
fieldAliases: {
|
||||
equipped: {
|
||||
Bennet: "Bennett",
|
||||
},
|
||||
setName: {
|
||||
Moor: "Moon",
|
||||
},
|
||||
},
|
||||
constrainedFixes: {
|
||||
"Equipped;": "Equipped:",
|
||||
},
|
||||
});
|
||||
|
||||
expect(learned?.ocr?.[0]?.text).toContain("Equipped: Bennett");
|
||||
expect(learned?.ocr?.[0]?.text).toContain("Aubade of Morningstar and Moon");
|
||||
});
|
||||
|
||||
it("marks low confidence or noted parses for review", () => {
|
||||
expect(shouldSaveReviewSample({ confidence: 96, notes: [], fields: { name: { confidence: 95 } } })).toBe(false);
|
||||
expect(shouldSaveReviewSample({ confidence: 96, notes: ["Artifact name was fuzzy-matched"], fields: { name: { confidence: 95 } } })).toBe(false);
|
||||
@@ -100,7 +119,13 @@ describe("scannerLearning", () => {
|
||||
});
|
||||
|
||||
it("counts learned rules", () => {
|
||||
expect(countScannerLearningRules({ textReplacements: { one: "1", two: "2" } })).toBe(2);
|
||||
expect(countScannerLearningRules({
|
||||
textReplacements: { one: "1", two: "2" },
|
||||
fieldAliases: { equipped: { Bennet: "Bennett" } },
|
||||
cropAdjustments: { "artifact-footer": { dy: -2, approved: false } },
|
||||
uiProfileAdjustments: { "1080p-footer": { fieldId: "artifact-footer", dy: -2, approved: false } },
|
||||
constrainedFixes: { "Moor": "Moon" },
|
||||
})).toBe(6);
|
||||
});
|
||||
|
||||
it("does not flag DB review when only non-critical fields are weak", () => {
|
||||
|
||||
@@ -15,21 +15,35 @@ export const DEFAULT_SCANNER_LEARNING_RULES: ScannerLearningRules = {
|
||||
"Elemental Masterv": "Elemental Mastery",
|
||||
"Equipped;": "Equipped:",
|
||||
},
|
||||
fieldAliases: {},
|
||||
constrainedFixes: {},
|
||||
cropAdjustments: {},
|
||||
uiProfileAdjustments: {},
|
||||
};
|
||||
|
||||
export function mergeScannerLearningRules(...rules: Array<Partial<ScannerLearningRules> | null | undefined>): ScannerLearningRules {
|
||||
return rules.reduce<ScannerLearningRules>(
|
||||
(merged, rule) => ({
|
||||
textReplacements: { ...merged.textReplacements, ...(rule?.textReplacements ?? {}) },
|
||||
fieldAliases: deepMergeRecord(merged.fieldAliases, rule?.fieldAliases),
|
||||
constrainedFixes: { ...merged.constrainedFixes, ...(rule?.constrainedFixes ?? {}) },
|
||||
cropAdjustments: { ...merged.cropAdjustments, ...(rule?.cropAdjustments ?? {}) },
|
||||
uiProfileAdjustments: { ...merged.uiProfileAdjustments, ...(rule?.uiProfileAdjustments ?? {}) },
|
||||
}),
|
||||
{ textReplacements: { ...DEFAULT_SCANNER_LEARNING_RULES.textReplacements } },
|
||||
{
|
||||
textReplacements: { ...DEFAULT_SCANNER_LEARNING_RULES.textReplacements },
|
||||
fieldAliases: {},
|
||||
constrainedFixes: {},
|
||||
cropAdjustments: {},
|
||||
uiProfileAdjustments: {},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function applyScannerLearningRules(capture: CaptureResult | null, rules?: Partial<ScannerLearningRules> | null) {
|
||||
if (!capture?.ocr?.length) return capture;
|
||||
const merged = mergeScannerLearningRules(rules);
|
||||
const replacements = Object.entries(merged.textReplacements ?? {}).filter(([from]) => from.length > 0);
|
||||
const replacements = replacementEntriesFromLearningRules(merged);
|
||||
if (replacements.length === 0) return capture;
|
||||
|
||||
return {
|
||||
@@ -90,7 +104,14 @@ function reviewRelevantConfidence(fields: Record<string, { confidence: number }>
|
||||
}
|
||||
|
||||
export function countScannerLearningRules(rules?: Partial<ScannerLearningRules> | null) {
|
||||
return Object.keys(rules?.textReplacements ?? {}).length;
|
||||
const fieldAliasCount = Object.values(rules?.fieldAliases ?? {}).reduce((sum, aliases) => sum + Object.keys(aliases ?? {}).length, 0);
|
||||
return (
|
||||
Object.keys(rules?.textReplacements ?? {}).length
|
||||
+ fieldAliasCount
|
||||
+ Object.keys(rules?.constrainedFixes ?? {}).length
|
||||
+ Object.keys(rules?.cropAdjustments ?? {}).length
|
||||
+ Object.keys(rules?.uiProfileAdjustments ?? {}).length
|
||||
);
|
||||
}
|
||||
|
||||
export function deriveScannerLearningRules(
|
||||
@@ -149,6 +170,36 @@ function expectedValuesForOcrEntry(id: string, parsed: ParsedArtifactCandidate)
|
||||
}
|
||||
}
|
||||
|
||||
function replacementEntriesFromLearningRules(rules: ScannerLearningRules) {
|
||||
const entries = new Map<string, string>();
|
||||
for (const [from, to] of Object.entries(rules.textReplacements ?? {})) {
|
||||
if (from.length > 0) entries.set(from, to);
|
||||
}
|
||||
for (const aliases of Object.values(rules.fieldAliases ?? {})) {
|
||||
for (const [from, to] of Object.entries(aliases ?? {})) {
|
||||
if (from.length > 0) entries.set(from, to);
|
||||
}
|
||||
}
|
||||
for (const [from, to] of Object.entries(rules.constrainedFixes ?? {})) {
|
||||
if (from.length > 0) entries.set(from, to);
|
||||
}
|
||||
return [...entries.entries()];
|
||||
}
|
||||
|
||||
function deepMergeRecord(
|
||||
left: Record<string, Record<string, string>> | undefined,
|
||||
right: Record<string, Record<string, string>> | undefined,
|
||||
) {
|
||||
const merged: Record<string, Record<string, string>> = {};
|
||||
for (const [field, aliases] of Object.entries(left ?? {})) {
|
||||
merged[field] = { ...(aliases ?? {}) };
|
||||
}
|
||||
for (const [field, aliases] of Object.entries(right ?? {})) {
|
||||
merged[field] = { ...(merged[field] ?? {}), ...(aliases ?? {}) };
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function deriveReplacementPairs(rawText: string, expected: string) {
|
||||
if (!expected || expected.startsWith("Unknown")) return [];
|
||||
const normalizedExpected = normalizeLearningText(expected);
|
||||
|
||||
@@ -21,8 +21,8 @@ describe("scannerSession helpers", () => {
|
||||
|
||||
it("updates scan timing and projects the 100-artifact run", () => {
|
||||
const stats = { ...emptyAutoScanStats, parsed: 4, verified: 2 };
|
||||
addCaptureTiming(stats, { totalMs: 1200, ocrMs: 900 });
|
||||
addCaptureTiming(stats, { totalMs: 800, ocrMs: 500 });
|
||||
addCaptureTiming(stats, { totalMs: 1200, ocrMs: 900 }, 1500);
|
||||
addCaptureTiming(stats, { totalMs: 800, ocrMs: 500 }, 1000);
|
||||
updateScanTiming(stats, 1000, 9000);
|
||||
expect(stats.elapsedMs).toBe(8000);
|
||||
expect(stats.activeScanMs).toBe(8000);
|
||||
@@ -33,6 +33,10 @@ describe("scannerSession helpers", () => {
|
||||
expect(stats.projectedMsFor100).toBe(200000);
|
||||
expect(stats.activeProjectedMsFor100).toBe(200000);
|
||||
expect(stats.captureMs).toBe(2000);
|
||||
expect(stats.captureRoundTripMs).toBe(2500);
|
||||
expect(stats.averageCaptureRoundTripMs).toBe(1250);
|
||||
expect(stats.captureRoundTripOverheadMs).toBe(500);
|
||||
expect(stats.averageCaptureRoundTripOverheadMs).toBe(250);
|
||||
expect(stats.ocrMs).toBe(1400);
|
||||
expect(stats.averageCaptureMs).toBe(1000);
|
||||
expect(stats.averageOcrMs).toBe(700);
|
||||
|
||||
@@ -18,9 +18,19 @@ export type AutoScanStats = {
|
||||
projectedMsFor100: number;
|
||||
activeProjectedMsFor100: number;
|
||||
captureMs: number;
|
||||
captureRoundTripMs: number;
|
||||
captureRoundTripOverheadMs: number;
|
||||
ocrMs: number;
|
||||
averageCaptureMs: number;
|
||||
averageCaptureRoundTripMs: number;
|
||||
averageCaptureRoundTripOverheadMs: number;
|
||||
averageOcrMs: number;
|
||||
clickMs: number;
|
||||
averageClickMs: number;
|
||||
parseMs: number;
|
||||
averageParseMs: number;
|
||||
loopOverheadMs: number;
|
||||
averageLoopOverheadMs: number;
|
||||
captureP50Ms: number;
|
||||
captureP90Ms: number;
|
||||
ocrP50Ms: number;
|
||||
@@ -60,9 +70,19 @@ export const emptyAutoScanStats: AutoScanStats = {
|
||||
projectedMsFor100: 0,
|
||||
activeProjectedMsFor100: 0,
|
||||
captureMs: 0,
|
||||
captureRoundTripMs: 0,
|
||||
captureRoundTripOverheadMs: 0,
|
||||
ocrMs: 0,
|
||||
averageCaptureMs: 0,
|
||||
averageCaptureRoundTripMs: 0,
|
||||
averageCaptureRoundTripOverheadMs: 0,
|
||||
averageOcrMs: 0,
|
||||
clickMs: 0,
|
||||
averageClickMs: 0,
|
||||
parseMs: 0,
|
||||
averageParseMs: 0,
|
||||
loopOverheadMs: 0,
|
||||
averageLoopOverheadMs: 0,
|
||||
captureP50Ms: 0,
|
||||
captureP90Ms: 0,
|
||||
ocrP50Ms: 0,
|
||||
@@ -101,7 +121,14 @@ export function updateScanTiming(
|
||||
stats.projectedMsFor100 = stats.averageMsPerParsed > 0 ? stats.averageMsPerParsed * 100 : 0;
|
||||
stats.activeProjectedMsFor100 = stats.activeAverageMsPerParsed > 0 ? stats.activeAverageMsPerParsed * 100 : 0;
|
||||
stats.averageCaptureMs = stats.verified > 0 ? Math.round(stats.captureMs / stats.verified) : 0;
|
||||
stats.averageCaptureRoundTripMs = stats.verified > 0 ? Math.round(stats.captureRoundTripMs / stats.verified) : 0;
|
||||
stats.captureRoundTripOverheadMs = Math.max(0, stats.captureRoundTripMs - stats.captureMs);
|
||||
stats.averageCaptureRoundTripOverheadMs = stats.verified > 0 ? Math.round(stats.captureRoundTripOverheadMs / stats.verified) : 0;
|
||||
stats.averageOcrMs = stats.verified > 0 ? Math.round(stats.ocrMs / stats.verified) : 0;
|
||||
stats.averageClickMs = stats.clicked > 0 ? Math.round(stats.clickMs / stats.clicked) : 0;
|
||||
stats.averageParseMs = stats.parsed > 0 ? Math.round(stats.parseMs / stats.parsed) : 0;
|
||||
stats.loopOverheadMs = Math.max(0, stats.activeScanMs - stats.captureMs - stats.clickMs - stats.parseMs - stats.cardReadyMs - stats.scrollReadyMs);
|
||||
stats.averageLoopOverheadMs = stats.parsed > 0 ? Math.round(stats.loopOverheadMs / stats.parsed) : 0;
|
||||
const samples = timingSamples.get(stats);
|
||||
stats.captureP50Ms = percentile(samples?.captureMs, 50);
|
||||
stats.captureP90Ms = percentile(samples?.captureMs, 90);
|
||||
@@ -115,11 +142,15 @@ export function updateScanTiming(
|
||||
export function addCaptureTiming(
|
||||
stats: AutoScanStats,
|
||||
timing?: { totalMs?: number; ocrMs?: number } | null,
|
||||
elapsedMs?: number | null,
|
||||
) {
|
||||
if (!timing) return stats;
|
||||
const captureMs = Math.max(0, Math.round(timing.totalMs ?? 0));
|
||||
const ocrMs = Math.max(0, Math.round(timing.ocrMs ?? 0));
|
||||
stats.captureMs += captureMs;
|
||||
if (typeof elapsedMs === "number" && Number.isFinite(elapsedMs)) {
|
||||
stats.captureRoundTripMs += Math.max(0, Math.round(elapsedMs));
|
||||
}
|
||||
stats.ocrMs += ocrMs;
|
||||
const samples = timingSamples.get(stats) ?? { captureMs: [], ocrMs: [] };
|
||||
samples.captureMs.push(captureMs);
|
||||
|
||||
+2471
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,164 @@
|
||||
/* Diagnose / Dev view - all developer info, separated from the Scan workspace. */
|
||||
.diagnose-view {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-right: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.diagnose-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.diagnose-header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.diagnose-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.diagnose-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.015)),
|
||||
rgba(18, 12, 35, 0.7);
|
||||
box-shadow: var(--glass-shadow);
|
||||
backdrop-filter: blur(18px);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.diagnose-card-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.diagnose-card-heading h3 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.app-diagnosis-card {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.diagnosis-source {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||
border-radius: 999px;
|
||||
padding: 7px 10px;
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-diagnosis-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-diagnosis-section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
border-left: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
padding: 11px 12px;
|
||||
}
|
||||
|
||||
.app-diagnosis-section.ok {
|
||||
border-left-color: var(--mint);
|
||||
}
|
||||
|
||||
.app-diagnosis-section.warn {
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
|
||||
.app-diagnosis-section.risk {
|
||||
border-left-color: #ff6b8a;
|
||||
}
|
||||
|
||||
.app-diagnosis-section.next {
|
||||
border-left-color: var(--cyan);
|
||||
}
|
||||
|
||||
.app-diagnosis-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.app-diagnosis-title strong {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-diagnosis-section ul {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.app-diagnosis-section li::marker {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.diagnose-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-diagnosis-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.scan-evidence-capture {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.scan-evidence-images {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-diagnosis-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.diagnosis-source {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2634
File diff suppressed because it is too large
Load Diff
Vendored
+30
@@ -89,7 +89,18 @@ export interface CaptureResult {
|
||||
text: string;
|
||||
};
|
||||
locked?: boolean;
|
||||
lockSignal?: {
|
||||
ratio: number;
|
||||
threshold: number;
|
||||
rect: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
sanctified?: boolean;
|
||||
elapsedMs?: number;
|
||||
layout?: {
|
||||
aspect: string;
|
||||
isSixteenNine: boolean;
|
||||
@@ -192,6 +203,25 @@ export type ScannerCommand =
|
||||
|
||||
export interface ScannerLearningRulePayload {
|
||||
textReplacements?: Record<string, string>;
|
||||
fieldAliases?: Record<string, Record<string, string>>;
|
||||
constrainedFixes?: Record<string, string>;
|
||||
cropAdjustments?: Record<string, {
|
||||
dx?: number;
|
||||
dy?: number;
|
||||
dw?: number;
|
||||
dh?: number;
|
||||
reason?: string;
|
||||
approved?: boolean;
|
||||
}>;
|
||||
uiProfileAdjustments?: Record<string, {
|
||||
fieldId: string;
|
||||
dx?: number;
|
||||
dy?: number;
|
||||
dw?: number;
|
||||
dh?: number;
|
||||
reason?: string;
|
||||
approved?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LoadScannerLearningRulesResult {
|
||||
|
||||
Reference in New Issue
Block a user