import test from "node:test"; import assert from "node:assert/strict"; import { cleanItemName, combineRects, displayCaptureIssue, isCalibrationReady, isRectInside, parseFieldValue, parseStoreHeaderText, parseTooltipText, rectFromPoints, resolveFieldRect, toExportableResults, } from "../src/parser.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(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", () => { 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("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)"), { 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); });