Files

415 lines
13 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import {
cleanItemName,
combineRects,
displayCaptureIssue,
isCalibrationReady,
isRectInside,
merchantGroups,
parseFieldValue,
parseStoreHeaderText,
parseTooltipText,
rectFromPoints,
resolveFieldRect,
toExportableResults,
} from "../src/parser.js";
import {
gridSlotRects,
chooseUniqueBest,
findTextRunEnd,
fitOcrTextRect,
occupiedSlotScore,
isColoredItemTextPixel,
isWhiteTextPixel,
isYellowTextPixel,
} from "../src/vision.js";
import {
activateShop,
recordSaleMiss,
resetShopProgress,
shouldReadStoreHeader,
} 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(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("keeps blue and purple item names without accepting brown UI pixels", () => {
assert.equal(isColoredItemTextPixel(80, 145, 230), true);
assert.equal(isColoredItemTextPixel(190, 90, 220), true);
assert.equal(isColoredItemTextPixel(220, 215, 195), false);
assert.equal(isColoredItemTextPixel(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("fits colored item-name rows only when item colors are enabled", () => {
const data = new Uint8ClampedArray(20 * 3 * 4);
for (let column = 0; column < 4; column += 1) {
const index = (20 + column) * 4;
data[index] = 190;
data[index + 1] = 90;
data[index + 2] = 220;
data[index + 3] = 255;
}
const canvas = {
width: 40,
height: 20,
getContext() {
return { getImageData: () => ({ data }) };
},
};
const rect = { x: 20, y: 5, width: 50, height: 3 };
assert.equal(fitOcrTextRect(canvas, rect), null);
assert.deepEqual(fitOcrTextRect(canvas, rect, { includeItemColors: true }), {
x: 20,
y: 5,
width: 10,
height: 3,
});
});
test("keeps only letters, digits and spaces in OCR item names", () => {
assert.equal(cleanItemName(" ~~ Soulshot: S-grade ` "), "Soulshot S grade");
assert.equal(cleanItemName("Кристалл № 12"), "Кристалл 12");
});
test("explains why display capture is unavailable", () => {
assert.equal(displayCaptureIssue({ secure: true, supported: true, topLevel: true }), null);
assert.match(displayCaptureIssue({ secure: false, supported: false, topLevel: true }).detail, /HTTPS/);
assert.match(displayCaptureIssue({ secure: true, supported: true, topLevel: false }).title, /встроенном/);
});
test("normalizes a reverse drag into a positive rectangle", () => {
assert.deepEqual(rectFromPoints({ x: 140, y: 90 }, { x: 20, y: 30 }), {
x: 20,
y: 30,
width: 120,
height: 60,
});
});
test("moves calibrated fields together with the detected anchor", () => {
assert.deepEqual(
resolveFieldRect(
{ x: 400, y: 250 },
{ offsetX: 32, offsetY: 80, width: 180, height: 24 },
),
{ x: 432, y: 330, width: 180, height: 24 },
);
});
test("combines the calibrated name and price rows into one OCR window", () => {
assert.deepEqual(
combineRects(
{ x: 100, y: 40, width: 220, height: 18 },
{ x: 120, y: 90, width: 160, height: 18 },
),
{ x: 100, y: 40, width: 220, height: 68 },
);
});
test("parses formatted prices and rejects empty numeric OCR", () => {
assert.equal(parseFieldValue("price", "1 250,000 adena"), 1250000);
assert.equal(parseFieldValue("quantity", "not found"), null);
assert.equal(parseFieldValue("item", " Soulshot: S-grade\n"), "Soulshot: S-grade");
});
test("requires both anchors and exact OCR regions", () => {
assert.equal(isCalibrationReady({ saleAnchor: null }), false);
assert.equal(
isCalibrationReady({
saleAnchor: { image: "data:image/png;base64,x", width: 20, height: 10 },
storeHeaderRegion: { offsetX: -50, offsetY: -300, width: 250, height: 24 },
tooltipSearchRegion: { offsetX: -100, offsetY: -300, width: 300, height: 100 },
tooltipAnchor: { image: "data:image/png;base64,y", width: 30, height: 12 },
itemNameRegion: { offsetX: 0, offsetY: -18, width: 220, height: 18 },
itemPriceRegion: { offsetX: 0, offsetY: 0, width: 160, height: 18 },
}),
true,
);
});
test("detects OCR rectangles outside the captured frame", () => {
assert.equal(isRectInside({ x: 10, y: 10, width: 20, height: 20 }, 100, 100), true);
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("builds one merchant board and keeps the latest price per item", () => {
const groups = merchantGroups([
{
source: "L2 window 1",
saleFound: true,
side: "sell",
merchant: "Dwa",
items: [
{ itemId: 1864, name: "Stem", side: "sell", merchant: "Dwa", priceAdena: 100, seenAt: "2026-08-12T10:00:00.000Z" },
{ itemId: 1864, name: "Stem", side: "sell", merchant: "Dwa", priceAdena: 120, seenAt: "2026-08-12T10:01:00.000Z" },
],
},
{
source: "L2 window 2",
saleFound: false,
items: [{ itemId: 57, name: "Adena", side: "buy", merchant: "Gnumli", priceAdena: 1, seenAt: "2026-08-12T10:02:00.000Z" }],
},
]);
assert.equal(groups.length, 2);
assert.equal(groups[0].merchant, "Dwa");
assert.equal(groups[0].active, true);
assert.deepEqual(groups[0].items.map(({ priceAdena }) => priceAdena), [120]);
assert.equal(groups[1].merchant, "Gnumli");
});
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("does not read a store header while a Price tooltip is visible", () => {
assert.equal(shouldReadStoreHeader(true), false);
assert.equal(shouldReadStoreHeader(false), true);
});
test("parses a hovered item tooltip", () => {
assert.deepEqual(
parseTooltipText("Tears of Eva\nPrice : 3,000,000 Adena\n(3 Million Adena)"),
{
name: "Tears of Eva",
quantity: 1,
priceAdena: 3000000,
rawText: "Tears of Eva\nPrice : 3,000,000 Adena\n(3 Million Adena)",
},
);
assert.deepEqual(parseTooltipText("Animal Skin (18)\nFor Each 400 Adena"), {
name: "Animal Skin",
quantity: 18,
priceAdena: 400,
rawText: "Animal Skin (18)\nFor Each 400 Adena",
});
assert.equal(parseTooltipText("Animal Skin (18)\n400")?.priceAdena, 400);
assert.equal(parseTooltipText("Animal Skin (18)\nPrice : 400")?.priceAdena, 400);
assert.equal(parseTooltipText("Blessed Spiritshot D (5,600)\nFor Each 56 Adena")?.quantity, 5600);
assert.deepEqual(parseTooltipText("Sword of Valhalla\nPrice :\nFor Each 56 Adena"), {
name: "Sword of Valhalla",
quantity: 1,
priceAdena: 56,
rawText: "Sword of Valhalla\nPrice :\nFor Each 56 Adena",
});
assert.deepEqual(parseTooltipText("Animal Skin (18)\nWeight 0\nPrice :\nFor Each 400 Adena"), {
name: "Animal Skin",
quantity: 18,
priceAdena: 400,
rawText: "Animal Skin (18)\nWeight 0\nPrice :\nFor Each 400 Adena",
});
assert.deepEqual(
parseTooltipText("Blessed Spiritshot: D-Grade (D) (5,600)\nFor Each 56 Adena"),
{
name: "Blessed Spiritshot D Grade D",
quantity: 5600,
priceAdena: 56,
rawText: "Blessed Spiritshot: D-Grade (D) (5,600)\nFor Each 56 Adena",
},
);
assert.deepEqual(parseTooltipText("~~ Animal Skin (18),,.\nFor Each 400 Adena"), {
name: "Animal Skin",
quantity: 18,
priceAdena: 400,
rawText: "~~ Animal Skin (18),,.\nFor Each 400 Adena",
});
assert.equal(parseTooltipText("Purchase List\nAdena 5,403,160"), null);
});
test("parses trade side and merchant from the store header", () => {
assert.deepEqual(parseStoreHeaderText("Private Store(Buy) - Hikvision"), {
side: "buy",
merchant: "Hikvision",
rawText: "Private Store(Buy) - Hikvision",
});
assert.deepEqual(parseStoreHeaderText(" Private Store({Sell) - Dwa \n"), {
side: "sell",
merchant: "Dwa",
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);
});