Improve L2 market parsing and data handling
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createCatalogResolver } from "../src/catalog-resolver.js";
|
||||
|
||||
test("chooses the strongest catalog response across OCR alternatives", async () => {
|
||||
const resolver = createCatalogResolver(async (name) => ({
|
||||
id: name,
|
||||
name: name === "garbled" ? "Wrong" : "Ancient Adena",
|
||||
iconUrl: `/${name}.png`,
|
||||
matchScore: name === "garbled" ? 0.4 : 0.92,
|
||||
}));
|
||||
const result = await resolver(
|
||||
{ name: "garbled", quantity: 1 },
|
||||
[{ name: "garbled", quantity: 1 }, { name: "Ancient Adena", quantity: 7 }],
|
||||
);
|
||||
assert.equal(result.name, "Ancient Adena");
|
||||
assert.equal(result.quantity, 7);
|
||||
assert.equal(result.matchScore, 0.92);
|
||||
});
|
||||
|
||||
test("keeps OCR data when every catalog response is below threshold", async () => {
|
||||
const resolver = createCatalogResolver(async () => ({ matchScore: 0.2 }));
|
||||
const item = { name: "Unknown", priceAdena: 3 };
|
||||
assert.equal(await resolver(item), item);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { access, readdir, readFile } from "node:fs/promises";
|
||||
|
||||
const dataUrl = new URL("../data/", import.meta.url);
|
||||
|
||||
async function loadManifest() {
|
||||
return JSON.parse(await readFile(new URL("fixtures.json", dataUrl), "utf8"));
|
||||
}
|
||||
|
||||
test("fixture manifest covers every screenshot exactly once", async () => {
|
||||
const manifest = await loadManifest();
|
||||
const files = (await readdir(dataUrl)).filter((file) => file.toLowerCase().endsWith(".jpg")).sort();
|
||||
const declared = manifest.cases.map((fixture) => fixture.file).sort();
|
||||
|
||||
assert.equal(manifest.version, 1);
|
||||
assert.deepEqual(manifest.frame, { width: 1560, height: 1360 });
|
||||
assert.equal(new Set(manifest.cases.map((fixture) => fixture.id)).size, manifest.cases.length);
|
||||
assert.deepEqual(declared, files);
|
||||
});
|
||||
|
||||
test("fixture groups have deterministic order and reviewed slot transitions", async () => {
|
||||
const { cases } = await loadManifest();
|
||||
const groups = new Map();
|
||||
cases.forEach((fixture) => {
|
||||
const group = groups.get(fixture.groupId) || [];
|
||||
group.push(fixture);
|
||||
groups.set(fixture.groupId, group);
|
||||
});
|
||||
|
||||
for (const fixtures of groups.values()) {
|
||||
const orders = fixtures.map((fixture) => fixture.order).sort((left, right) => left - right);
|
||||
assert.deepEqual(orders, [...Array(fixtures.length).keys()]);
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
const expected = fixture.expected;
|
||||
const occupied = expected.occupiedSlots;
|
||||
const classified = [...expected.pendingSlots, ...expected.foundSlots].sort((a, b) => a - b);
|
||||
assert.deepEqual(classified, occupied);
|
||||
assert.equal(new Set(classified).size, classified.length);
|
||||
assert.ok(classified.every((slot) => Number.isInteger(slot) && slot >= 0 && slot < 18));
|
||||
if (expected.item) {
|
||||
assert.ok(fixture.catalogAssetId);
|
||||
assert.ok(expected.foundSlots.includes(expected.hoveredSlot));
|
||||
assert.ok(expected.item.name && expected.item.displayName);
|
||||
assert.ok(Number.isSafeInteger(expected.item.quantity) && expected.item.quantity >= 0);
|
||||
assert.ok(Number.isSafeInteger(expected.item.priceAdena) && expected.item.priceAdena > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("contextual tooltip fixtures own their initial shop state", async () => {
|
||||
const { cases } = await loadManifest();
|
||||
const contextual = cases.filter((fixture) => fixture.mode === "contextual");
|
||||
assert.ok(contextual.length > 0);
|
||||
contextual.forEach((fixture) => {
|
||||
assert.equal(fixture.initialState.side, fixture.expected.side);
|
||||
assert.equal(fixture.initialState.merchant, fixture.expected.merchant);
|
||||
assert.ok(fixture.initialState.activeShopKey);
|
||||
});
|
||||
});
|
||||
|
||||
test("fixture calibration and every declared catalog asset are committed", async () => {
|
||||
const manifest = await loadManifest();
|
||||
const calibration = JSON.parse(await readFile(new URL(manifest.calibration, dataUrl), "utf8"));
|
||||
const catalog = JSON.parse(
|
||||
await readFile(new URL("fixture-assets/catalog.json", dataUrl), "utf8"),
|
||||
);
|
||||
|
||||
assert.deepEqual(calibration.frame, manifest.frame);
|
||||
for (const field of [
|
||||
"saleAnchor",
|
||||
"storeHeaderRegion",
|
||||
"tooltipSearchRegion",
|
||||
"tooltipAnchor",
|
||||
"itemNameRegion",
|
||||
"itemPriceRegion",
|
||||
"sellItemGridRegion",
|
||||
"buyItemGridRegion",
|
||||
]) {
|
||||
assert.ok(calibration[field], `missing fixture calibration field: ${field}`);
|
||||
}
|
||||
await access(new URL(calibration.saleAnchor.image, dataUrl));
|
||||
await access(new URL(calibration.tooltipAnchor.image, dataUrl));
|
||||
|
||||
for (const fixture of manifest.cases.filter((entry) => entry.catalogAssetId)) {
|
||||
const item = catalog.items[fixture.catalogAssetId];
|
||||
assert.ok(item, `missing catalog fixture: ${fixture.catalogAssetId}`);
|
||||
await access(new URL(item.iconUrl.replace(/^\//, "../"), dataUrl));
|
||||
}
|
||||
});
|
||||
+193
-2
@@ -11,13 +11,123 @@ import {
|
||||
parseTooltipText,
|
||||
rectFromPoints,
|
||||
resolveFieldRect,
|
||||
toExportableResults,
|
||||
} from "../src/parser.js";
|
||||
import { isWhiteTextPixel } from "../src/vision.js";
|
||||
import {
|
||||
gridSlotRects,
|
||||
chooseUniqueBest,
|
||||
findTextRunEnd,
|
||||
fitOcrTextRect,
|
||||
occupiedSlotScore,
|
||||
isWhiteTextPixel,
|
||||
isYellowTextPixel,
|
||||
} from "../src/vision.js";
|
||||
import { activateShop, recordSaleMiss, resetShopProgress } from "../src/frame-analyzer.js";
|
||||
|
||||
test("keeps white text pixels and removes colored or dark pixels", () => {
|
||||
assert.equal(isWhiteTextPixel(220, 215, 195), true);
|
||||
assert.equal(isWhiteTextPixel(120, 120, 120), true);
|
||||
assert.equal(isWhiteTextPixel(210, 165, 80), false);
|
||||
assert.equal(isWhiteTextPixel(120, 120, 120), false);
|
||||
assert.equal(isWhiteTextPixel(119, 119, 119), false);
|
||||
});
|
||||
|
||||
test("keeps yellow tooltip prices without accepting brown UI pixels", () => {
|
||||
assert.equal(isYellowTextPixel(255, 255, 0), true);
|
||||
assert.equal(isYellowTextPixel(174, 173, 6), true);
|
||||
assert.equal(isYellowTextPixel(190, 120, 40), false);
|
||||
});
|
||||
|
||||
test("stops a tooltip text run before distant UI noise", () => {
|
||||
const columns = Array(48).fill(false);
|
||||
for (let index = 2; index <= 7; index += 1) columns[index] = true;
|
||||
for (let index = 15; index <= 25; index += 1) columns[index] = true;
|
||||
columns[42] = true;
|
||||
|
||||
assert.equal(findTextRunEnd(columns), 25);
|
||||
assert.equal(findTextRunEnd(Array(20).fill(false)), -1);
|
||||
assert.equal(findTextRunEnd(Array(40).fill(true)), -1);
|
||||
});
|
||||
|
||||
test("splits the calibrated item grid into stable 6 by 3 frame rectangles", () => {
|
||||
const slots = gridSlotRects({ x: 10, y: 20, width: 222, height: 111 });
|
||||
assert.equal(slots.length, 18);
|
||||
assert.deepEqual(slots[0], {
|
||||
slotId: 0,
|
||||
row: 0,
|
||||
column: 0,
|
||||
frameRect: { x: 10, y: 20, width: 37, height: 37 },
|
||||
});
|
||||
assert.deepEqual(slots[17], {
|
||||
slotId: 17,
|
||||
row: 2,
|
||||
column: 5,
|
||||
frameRect: { x: 195, y: 94, width: 37, height: 37 },
|
||||
});
|
||||
});
|
||||
|
||||
test("classifies a bright icon interior above an empty slot", () => {
|
||||
const image = {
|
||||
width: 12,
|
||||
height: 6,
|
||||
data: new Uint8ClampedArray(12 * 6 * 4).fill(255),
|
||||
};
|
||||
for (let y = 0; y < 6; y += 1) {
|
||||
for (let x = 0; x < 6; x += 1) {
|
||||
const index = (y * image.width + x) * 4;
|
||||
image.data[index] = 20;
|
||||
image.data[index + 1] = 20;
|
||||
image.data[index + 2] = 20;
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
occupiedSlotScore(image, { x: 6, y: 0, width: 6, height: 6 }, 1) >
|
||||
occupiedSlotScore(image, { x: 0, y: 0, width: 6, height: 6 }, 1),
|
||||
);
|
||||
});
|
||||
|
||||
test("selects only a unique catalog score above threshold and margin", () => {
|
||||
assert.equal(
|
||||
chooseUniqueBest(
|
||||
[{ slotId: 0, score: 0.91 }, { slotId: 1, score: 0.7 }],
|
||||
{ threshold: 0.72, margin: 0.06 },
|
||||
),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
chooseUniqueBest(
|
||||
[{ slotId: 0, score: 0.91 }, { slotId: 1, score: 0.89 }],
|
||||
{ threshold: 0.72, margin: 0.06 },
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("fits a short row at the frame edge even when the saved width is wider", () => {
|
||||
const data = new Uint8ClampedArray(20 * 3 * 4);
|
||||
for (let column = 0; column < 4; column += 1) {
|
||||
const index = (20 + column) * 4;
|
||||
data[index] = 220;
|
||||
data[index + 1] = 220;
|
||||
data[index + 2] = 220;
|
||||
data[index + 3] = 255;
|
||||
}
|
||||
const canvas = {
|
||||
width: 40,
|
||||
height: 20,
|
||||
getContext() {
|
||||
return {
|
||||
getImageData(x, y, width, height) {
|
||||
assert.deepEqual([x, y, width, height], [20, 5, 20, 3]);
|
||||
return { data };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
fitOcrTextRect(canvas, { x: 20, y: 5, width: 50, height: 3 }),
|
||||
{ x: 20, y: 5, width: 10, height: 3 },
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps only letters, digits and spaces in OCR item names", () => {
|
||||
@@ -86,6 +196,74 @@ test("detects OCR rectangles outside the captured frame", () => {
|
||||
assert.equal(isRectInside({ x: 90, y: 10, width: 20, height: 20 }, 100, 100), false);
|
||||
});
|
||||
|
||||
test("keeps transient grid images and slot state out of copied JSON", () => {
|
||||
const [exported] = toExportableResults([
|
||||
{
|
||||
source: "Lineage II",
|
||||
saleFound: true,
|
||||
side: "sell",
|
||||
merchant: "Gnumli",
|
||||
iconSlots: [{ slotId: 0, thumbnail: { pixels: true } }],
|
||||
gridCaptured: true,
|
||||
items: [{ key: "internal", name: "Soulshot C grade", priceAdena: 20 }],
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(exported, {
|
||||
source: "Lineage II",
|
||||
side: "sell",
|
||||
merchant: "Gnumli",
|
||||
saleFound: true,
|
||||
items: [{ name: "Soulshot C grade", priceAdena: 20 }],
|
||||
});
|
||||
assert.doesNotMatch(JSON.stringify(exported), /iconSlots|gridCaptured|thumbnail|internal/);
|
||||
});
|
||||
|
||||
test("resets only transient shop progress when a shop closes or changes", () => {
|
||||
const result = {
|
||||
activeShopKey: "sell:gnumli",
|
||||
iconSlots: [{ slotId: 0, status: "found" }],
|
||||
gridCaptured: true,
|
||||
slotMatchStatus: "matched",
|
||||
items: [{ name: "Soulshot C grade" }],
|
||||
};
|
||||
resetShopProgress(result);
|
||||
assert.deepEqual(result, {
|
||||
activeShopKey: "",
|
||||
iconSlots: [],
|
||||
gridCaptured: false,
|
||||
slotMatchStatus: "",
|
||||
items: [{ name: "Soulshot C grade" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps shop progress through two misses and clears it on the third", () => {
|
||||
const result = {
|
||||
side: "sell",
|
||||
merchant: "Gnumli",
|
||||
storeRawText: "Private Store(Sell) - Gnumli",
|
||||
storeConfidence: 80,
|
||||
misses: 0,
|
||||
activeShopKey: "sell:gnumli",
|
||||
iconSlots: [{ slotId: 0, status: "found" }],
|
||||
gridCaptured: true,
|
||||
slotMatchStatus: "matched",
|
||||
items: [{ name: "Soulshot C grade" }],
|
||||
};
|
||||
recordSaleMiss(result);
|
||||
recordSaleMiss(result);
|
||||
assert.equal(result.activeShopKey, "sell:gnumli");
|
||||
recordSaleMiss(result);
|
||||
assert.equal(result.activeShopKey, "");
|
||||
assert.equal(result.side, "");
|
||||
assert.deepEqual(result.items, [{ name: "Soulshot C grade" }]);
|
||||
|
||||
activateShop(result, "sell", "Gnumli");
|
||||
result.gridCaptured = true;
|
||||
activateShop(result, "buy", "RAKOT");
|
||||
assert.equal(result.activeShopKey, "buy:rakot");
|
||||
assert.equal(result.gridCaptured, false);
|
||||
});
|
||||
|
||||
test("parses a hovered item tooltip", () => {
|
||||
assert.deepEqual(
|
||||
parseTooltipText("Tears of Eva\nPrice : 3,000,000 Adena\n(3 Million Adena)"),
|
||||
@@ -147,5 +325,18 @@ test("parses trade side and merchant from the store header", () => {
|
||||
rawText: "Private Store({Sell) - Dwa",
|
||||
});
|
||||
assert.equal(parseStoreHeaderText("Private Store(Buy) - Hik vision")?.merchant, "Hikvision");
|
||||
assert.deepEqual(parseStoreHeaderText("«Private Store(Sell) - BancoFrances»"), {
|
||||
side: "sell",
|
||||
merchant: "BancoFrances",
|
||||
rawText: "«Private Store(Sell) - BancoFrances»",
|
||||
});
|
||||
assert.equal(parseStoreHeaderText("Sell - BancoFrances")?.merchant, "BancoFrances");
|
||||
assert.deepEqual(parseStoreHeaderText("Brivata Stora Sally Gnumli"), {
|
||||
side: "sell",
|
||||
merchant: "Gnumli",
|
||||
rawText: "Brivata Stora Sally Gnumli",
|
||||
});
|
||||
assert.equal(parseStoreHeaderText("Private Store(Sell) - Banco_Frances"), null);
|
||||
assert.equal(parseStoreHeaderText("Trade - BancoFrances"), null);
|
||||
assert.equal(parseStoreHeaderText("Items on Sale"), null);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user