From 877d604fc340930bbe9dc94a49de80be51e6fb52 Mon Sep 17 00:00:00 2001 From: Pyronewbic Date: Mon, 18 May 2026 23:25:50 +0530 Subject: [PATCH] feat: track-prices expansion, multi-pass grading, fixture-based tests, 266 tests - track-prices now tracks portfolio cards across all users (not just 3 hardcoded) - POST /api/grade accepts passes param (1-3), median per subgrade, consistency report - Exported parsing functions for eBay, Magi, SNKRDUNK, PSA - Fixture-based tests with real API response shapes - yarn test:coverage via c8 --- .gitignore | 1 + api.js | 64 +++-- lib/grading/grading.js | 74 ++++++ lib/grading/psa.js | 2 +- lib/sources/ebay.js | 6 +- lib/sources/magi.js | 4 +- lib/sources/snkrdunk.js | 2 +- package-lock.json | 342 ++++++++++++++++++++++++- package.json | 2 + test/fixtures/ebay-browse-item.json | 24 ++ test/fixtures/ebay-insights-entry.json | 7 + test/fixtures/psa-pop-response.json | 16 ++ test/fixtures/snkrdunk-listing.json | 13 + test/unit-test.js | 263 ++++++++++++++++++- 14 files changed, 795 insertions(+), 25 deletions(-) create mode 100644 test/fixtures/ebay-browse-item.json create mode 100644 test/fixtures/ebay-insights-entry.json create mode 100644 test/fixtures/psa-pop-response.json create mode 100644 test/fixtures/snkrdunk-listing.json diff --git a/.gitignore b/.gitignore index 1e04558..db45736 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ images/**/*.spdx.json .DS_Store *.swp *.swo +coverage/ diff --git a/api.js b/api.js index df4d655..63490b5 100644 --- a/api.js +++ b/api.js @@ -11,7 +11,7 @@ import { searchSnkrdunk } from "./lib/sources/snkrdunk.js"; import { searchMagi } from "./lib/sources/magi.js"; import { searchYahooAuctions } from "./lib/sources/yahooauctions.js"; import { getPsaGradingSignal } from "./lib/grading/psa.js"; -import { gradeImage } from "./lib/grading/grading.js"; +import { gradeImage, medianGrade } from "./lib/grading/grading.js"; import { parseListingLanguagesFromInput, filterByCondition, detectCondition, flagPriceOutliers, filterRelevantResults, isGradedCard } from "./lib/search/filters.js"; import { buildEbaySearchQuery } from "./lib/search/listingQuery.js"; import { EBAY_CATEGORY_TCG_SINGLE_CARDS_US } from "./lib/search/ebayCategories.js"; @@ -438,19 +438,39 @@ app.get("/api/psa", apiAuthMiddleware, (req, res, next) => { req._errorType = "p // POST /api/grade app.post("/api/grade", authMiddleware, (req, res, next) => { req._errorType = "grade"; next(); }, async (req, res) => { - const { imageUrl, extraImages, provider, model, cardName, cardId, source, listingId, listingPrice, condition, centeringHint } = req.body; + const { imageUrl, extraImages, provider, model, cardName, cardId, source, listingId, listingPrice, condition, centeringHint, passes: rawPasses } = req.body; if (!imageUrl) return res.status(400).json({ error: "Missing required field: imageUrl" }); + const passes = Math.min(3, Math.max(1, Number(rawPasses) || 1)); try { - const config = { - aiGrading: { - enabled: true, - mode: "llm", - llm: { provider: provider || "claude", model: model || "claude-opus-4-7", maxTokens: 500 }, - cacheGrades: true, - }, - }; const extras = (extraImages || []).map(u => ({ imageUrl: u })); - const grade = await gradeImage(imageUrl, config, extras, centeringHint); + + let grade; + if (passes > 1) { + const results = []; + for (let i = 0; i < passes; i++) { + const config = { + aiGrading: { + enabled: true, + mode: "llm", + llm: { provider: provider || "claude", model: model || "claude-opus-4-7", maxTokens: 500 }, + cacheGrades: false, + }, + }; + const r = await gradeImage(imageUrl, config, extras, centeringHint); + if (r && !r.error) results.push(r); + } + grade = results.length ? medianGrade(results) : { error: "All passes failed" }; + } else { + const config = { + aiGrading: { + enabled: true, + mode: "llm", + llm: { provider: provider || "claude", model: model || "claude-opus-4-7", maxTokens: 500 }, + cacheGrades: true, + }, + }; + grade = await gradeImage(imageUrl, config, extras, centeringHint); + } let gradeId = null; if (grade && !grade.error) { @@ -464,12 +484,17 @@ app.post("/api/grade", authMiddleware, (req, res, next) => { req._errorType = "g listingId: listingId || null, imageUrl, extraImages: extraImages || [], - provider: config.aiGrading.llm.provider, - model: config.aiGrading.llm.model, + provider: (provider || "claude"), + model: (model || "claude-opus-4-7"), grade, listingPrice: listingPrice || null, condition: condition || null, }); + if (passes === 1) { + const { cacheGrade } = await import("./lib/grading/grading.js"); + const cacheConfig = { aiGrading: { mode: "llm", llm: { provider: provider || "claude", model: model || "claude-opus-4-7" }, cacheGrades: true } }; + await cacheGrade(imageUrl, cacheConfig, grade).catch(() => {}); + } } res.json({ grade, gradeId, stored: !!(grade && !grade.error) }); @@ -2007,7 +2032,16 @@ app.post("/api/track-prices", authMiddleware, async (req, res) => { alertCards = [...new Set(alerts.map(a => a.query).filter(Boolean))]; } catch {} - const cards = req.body?.cards || [...new Set([...defaultCards, ...alertCards])]; + let portfolioQueries = []; + try { + const userIds = await listPortfolioUserIds(); + for (const uid of userIds.slice(0, 100)) { + const pCards = await getPortfolio(uid); + portfolioQueries.push(...pCards.map(c => c.query).filter(Boolean)); + } + } catch {} + + const cards = req.body?.cards || [...new Set([...defaultCards, ...alertCards, ...portfolioQueries])]; const hasEbay = !!(clientId && clientSecret); const results = []; for (const card of cards) { @@ -2138,7 +2172,7 @@ app.post("/api/track-prices", authMiddleware, async (req, res) => { } refreshCardDatabase().catch(() => {}); - res.json({ tracked: results.length, results, portfolioSnapshots, portfolioWarmed, frequencyWarmed }); + res.json({ tracked: results.length, results, portfolioSnapshots, portfolioWarmed, portfolioCardsTracked: portfolioQueries.length, frequencyWarmed }); }); // GET /api/arbitrage — cross-source price comparison for a card diff --git a/lib/grading/grading.js b/lib/grading/grading.js index 3f397a9..3fd071a 100644 --- a/lib/grading/grading.js +++ b/lib/grading/grading.js @@ -599,6 +599,80 @@ async function gradeSubgrade(subgrade, imageBlocks, config, extraBlocks = [], pr return result; } +function median(arr) { + const s = [...arr].sort((a, b) => a - b); + const mid = Math.floor(s.length / 2); + return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; +} + +function stddev(arr) { + const avg = arr.reduce((a, b) => a + b, 0) / arr.length; + return Math.sqrt(arr.reduce((s, v) => s + (v - avg) ** 2, 0) / arr.length); +} + +const SUBGRADE_KEYS = ["centering_front", "centering_back", "corners_front", "corners_back", "edges_front", "edges_back", "surface_front", "surface_back"]; + +export function medianGrade(grades) { + if (!grades.length) return null; + if (grades.length === 1) return grades[0]; + + const medianScores = {}; + const consistency = {}; + for (const key of SUBGRADE_KEYS) { + const scores = grades.map(g => g.subgradeDetails?.[key]?.score).filter(s => s != null); + if (scores.length) { + medianScores[key] = median(scores); + consistency[key] = Math.round(stddev(scores) * 100) / 100; + } + } + + const frontKeys = ["centering_front", "corners_front", "edges_front", "surface_front"]; + const backKeys = ["centering_back", "corners_back", "edges_back", "surface_back"]; + const frontAvg = frontKeys.reduce((s, k) => s + (medianScores[k] || 0), 0) / frontKeys.length; + const backAvg = backKeys.reduce((s, k) => s + (medianScores[k] || 0), 0) / backKeys.length; + const rawOverall = frontAvg * 0.6 + backAvg * 0.4; + const lowestScore = Math.min(...Object.values(medianScores)); + const overall = roundGrade(Math.min(rawOverall, lowestScore + 1)); + + const avgConf = grades.reduce((s, g) => s + (g.confidence || 0), 0) / grades.length; + const totalInput = grades.reduce((s, g) => s + (g.tokenUsage?.input || 0), 0); + const totalOutput = grades.reduce((s, g) => s + (g.tokenUsage?.output || 0), 0); + + const subgradeDetails = {}; + for (const key of SUBGRADE_KEYS) { + const details = grades.map(g => g.subgradeDetails?.[key]).filter(Boolean); + if (details.length) { + const medScore = medianScores[key]; + const closest = details.reduce((a, b) => Math.abs(b.score - medScore) < Math.abs(a.score - medScore) ? b : a); + subgradeDetails[key] = { ...closest, score: medScore }; + } + } + + const lowestKey = Object.entries(medianScores).sort((a, b) => a[1] - b[1])[0]; + + return { + provider: grades[0].provider, + mode: "llm-detailed-v3", + overall, + frontOverall: roundGrade(frontAvg), + backOverall: roundGrade(backAvg), + centering: Math.min(medianScores.centering_front || 0, medianScores.centering_back || 0), + corners: Math.min(medianScores.corners_front || 0, medianScores.corners_back || 0), + edges: Math.min(medianScores.edges_front || 0, medianScores.edges_back || 0), + surface: Math.min(medianScores.surface_front || 0, medianScores.surface_back || 0), + confidence: clampConf(avgConf), + notes: `Median of ${grades.length} passes. Grade limiter: ${lowestKey?.[0]} (${lowestKey?.[1]})`, + limitations: grades[0].limitations || "", + subgradeDetails, + gradeDistribution: computeGradeDistribution(overall, clampConf(avgConf)), + cardDetection: grades[0].cardDetection, + tokenUsage: { input: totalInput, output: totalOutput }, + estimatedCost: (totalInput * 3 + totalOutput * 15) / 1_000_000, + passes: grades.length, + consistency, + }; +} + export function computeGradeDistribution(overall, confidence) { const grades = [10, 9.5, 9, 8.5, 8, 7.5, 7, 6.5, 6, 5.5, 5]; const idx = grades.indexOf(overall); diff --git a/lib/grading/psa.js b/lib/grading/psa.js index 6301d10..cbac045 100644 --- a/lib/grading/psa.js +++ b/lib/grading/psa.js @@ -34,7 +34,7 @@ function tokenize(str) { } // ── API response parsing ───────────────────────────────────────────── -function parseSpecPopItem(json) { +export function parseSpecPopItem(json) { const pop = json?.PSAPop; if (!pop) return null; return { pop10: pop.Grade10 ?? null, pop9: pop.Grade9 ?? null, popTotal: pop.Total ?? null }; diff --git a/lib/sources/ebay.js b/lib/sources/ebay.js index 6ea8be2..b13d0b9 100644 --- a/lib/sources/ebay.js +++ b/lib/sources/ebay.js @@ -272,7 +272,7 @@ function upgradeEbayImageRes(url) { return url.replace(/\/s-l\d+\.(jpg|png|webp)/gi, "/s-l1600.$1"); } -function normalizeBrowseItem(item) { +export function normalizeBrowseItem(item) { const priceVal = parseFloat(item.price?.value ?? "NaN"); const currency = item.price?.currency ?? "USD"; let shippingCost = 0; @@ -332,7 +332,7 @@ function normalizeBrowseItem(item) { }; } -function insightsToSold(entry) { +export function insightsToSold(entry) { const price = parseFloat( entry.lastSoldPrice?.value ?? @@ -753,7 +753,7 @@ function sanitizeScrapedListingTitle(title) { .trim(); } -function parseSoldTilesFromHtml(html) { +export function parseSoldTilesFromHtml(html) { const $ = cheerio.load(html); const items = []; diff --git a/lib/sources/magi.js b/lib/sources/magi.js index 4bf8e45..565e31f 100644 --- a/lib/sources/magi.js +++ b/lib/sources/magi.js @@ -69,12 +69,12 @@ function magiUrl(keyword, { status = "presented", sort = "price_asc", page = 1 } return `${MAGI_BASE}?${p}`; } -function parseJPY(text) { +export function parseJPY(text) { const m = text?.match(/¥\s*([\d,]+)/); return m ? parseInt(m[1].replace(/,/g, ""), 10) : null; } -function gradeFromTitle(title) { +export function gradeFromTitle(title) { const m1 = title?.match(/【\s*([A-Za-z]+)\s*(\d+(?:\.\d+)?)/); if (m1) return `${m1[1].toUpperCase()} ${m1[2]}`; const m2 = title?.match(/\b(PSA|BGS|CGC|TAG|SGC|HGA|ACE)\s?(\d+(?:\.\d+)?)\b/i); diff --git a/lib/sources/snkrdunk.js b/lib/sources/snkrdunk.js index 7d57177..384e8e3 100644 --- a/lib/sources/snkrdunk.js +++ b/lib/sources/snkrdunk.js @@ -80,7 +80,7 @@ async function fetchListingImages(listingUID) { try { return JSON.parse(`{${m[0]}}`).imageUrls; } catch { return []; } } -function normalizeActive(raw, productName) { +export function normalizeActive(raw, productName) { const info = conditionInfo(raw.condition); const desc = (raw.description || "").trim(); const title = desc || productName || ""; diff --git a/package-lock.json b/package-lock.json index dc538e6..47872cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "casecomp", - "version": "1.0.0-beta.1", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "casecomp", - "version": "1.0.0-beta.1", + "version": "1.2.0", "dependencies": { "@google-cloud/firestore": "^8.5.0", "@google-cloud/storage": "^7.19.0", @@ -27,6 +27,7 @@ "tough-cookie": "^5.1.2" }, "devDependencies": { + "c8": "^11.0.0", "lockfile-lint": "^5.0.0" }, "engines": { @@ -58,6 +59,15 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/runtime": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", @@ -802,6 +812,40 @@ "node": ">=12" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@js-sdsl/ordered-map": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", @@ -959,6 +1003,12 @@ "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", "license": "MIT" }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true + }, "node_modules/@types/node": { "version": "25.6.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz", @@ -1198,6 +1248,16 @@ } } }, + "node_modules/axios-cookiejar-support/node_modules/undici": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "optional": true, + "peer": true, + "engines": { + "node": ">=18.17" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1307,6 +1367,39 @@ "node": ">= 0.8" } }, + "node_modules/c8": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz", + "integrity": "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": "20 || >=22" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1568,6 +1661,12 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -2177,6 +2276,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -2500,6 +2615,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2560,6 +2684,12 @@ ], "license": "MIT" }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -2774,6 +2904,42 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -2849,6 +3015,21 @@ "dev": true, "license": "MIT" }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lockfile-lint": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/lockfile-lint/-/lockfile-lint-5.0.0.tgz", @@ -2909,6 +3090,21 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3142,6 +3338,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -3232,6 +3443,15 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/path-expression-matcher": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", @@ -4018,6 +4238,18 @@ "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==" }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/svix": { "version": "1.92.2", "resolved": "https://registry.npmjs.org/svix/-/svix-1.92.2.tgz", @@ -4063,6 +4295,98 @@ "node": ">=18" } }, + "node_modules/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -4195,6 +4519,20 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index c1f41d5..cd5ad87 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test:api": "node test/api-test.js", "test:live": "API_URL=https://api.casecomp.xyz node test/api-test.js", "test:api:local": "bash scripts/test-api-local.sh", + "test:coverage": "npx c8 --exclude=test/** --exclude=node_modules/** --exclude=public/** --exclude=extension/** --reporter=text --reporter=text-summary node test/unit-test.js", "test:smoke": "node test/smoke-test.js", "scan": "node scan.js", "psa": "node scripts/psa-report.js", @@ -41,6 +42,7 @@ }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", "devDependencies": { + "c8": "^11.0.0", "lockfile-lint": "^5.0.0" } } diff --git a/test/fixtures/ebay-browse-item.json b/test/fixtures/ebay-browse-item.json new file mode 100644 index 0000000..cc8f5ff --- /dev/null +++ b/test/fixtures/ebay-browse-item.json @@ -0,0 +1,24 @@ +{ + "itemId": "v1|366397520173|0", + "title": "Pokemon Card Umbreon ex SAR 217/187 SV8a Terastal Festival ex Japanese", + "price": { "value": "400.00", "currency": "USD" }, + "shippingOptions": [{ "shippingCost": { "value": "0.00", "currency": "USD" }, "type": "FIXED" }], + "image": { "imageUrl": "https://i.ebayimg.com/images/g/XYkAAeSw8fBp9JS-/s-l500.jpg" }, + "additionalImages": [ + { "imageUrl": "https://i.ebayimg.com/images/g/dvIAAeSwCB9p3n5z/s-l500.jpg" } + ], + "itemWebUrl": "https://www.ebay.com/itm/366397520173", + "condition": "Ungraded", + "conditionId": "3000", + "localizedAspects": [ + { "type": "STRING", "name": "Language", "value": "Japanese" }, + { "type": "STRING", "name": "Condition", "value": "Ungraded" } + ], + "seller": { + "username": "card_seller_jp", + "feedbackPercentage": "99.5", + "feedbackScore": 1523 + }, + "itemLocation": { "country": "JP", "city": "Tokyo" }, + "leafCategoryIds": ["183454"] +} diff --git a/test/fixtures/ebay-insights-entry.json b/test/fixtures/ebay-insights-entry.json new file mode 100644 index 0000000..aebe641 --- /dev/null +++ b/test/fixtures/ebay-insights-entry.json @@ -0,0 +1,7 @@ +{ + "title": "Pokemon Card Umbreon ex SAR 217/187 PSA 10 Gem Mint", + "itemWebUrl": "https://www.ebay.com/itm/366352980097", + "lastSoldPrice": { "value": "610.00", "currency": "USD" }, + "lastSoldDate": "2026-04-25T00:00:00.000Z", + "image": { "imageUrl": "https://i.ebayimg.com/images/g/bmkAAeSw7pFp4npL/s-l500.jpg" } +} diff --git a/test/fixtures/psa-pop-response.json b/test/fixtures/psa-pop-response.json new file mode 100644 index 0000000..41762ae --- /dev/null +++ b/test/fixtures/psa-pop-response.json @@ -0,0 +1,16 @@ +{ + "PSAPop": { + "Grade10": 5415, + "Grade9": 620, + "Grade8": 98, + "Grade7": 22, + "Grade6": 8, + "Grade5": 3, + "Grade4": 1, + "Grade3": 0, + "Grade2": 0, + "Grade1": 0, + "Total": 6182, + "Authentic": 15 + } +} diff --git a/test/fixtures/snkrdunk-listing.json b/test/fixtures/snkrdunk-listing.json new file mode 100644 index 0000000..87dcd6e --- /dev/null +++ b/test/fixtures/snkrdunk-listing.json @@ -0,0 +1,13 @@ +{ + "listingUID": "01KQZ9GKY0QSTY33XF8REM0X8C", + "condition": "A", + "description": "Mint condition, pack fresh", + "priceAmount": 46800, + "currency": "JPY", + "imageUrls": [ + "https://cdn.snkrdunk.com/apparel_used_listings/e7caf458-db53-44cc-b9c6-c6dec1ace529/980404.jpeg", + "https://cdn.snkrdunk.com/apparel_used_listings/434c07ea-98b5-480d-baef-ff4dcae38e7e/980404.jpeg" + ], + "thumbnailUrl": "https://cdn.snkrdunk.com/apparel_used_listings/e7caf458-db53-44cc-b9c6-c6dec1ace529/980404.jpeg", + "id": "980404" +} diff --git a/test/unit-test.js b/test/unit-test.js index 360800c..a049ca2 100644 --- a/test/unit-test.js +++ b/test/unit-test.js @@ -1,4 +1,9 @@ -import { parseGradeJSON, roundGrade, validateAndShape, computeGradeDistribution } from "../lib/grading/grading.js"; +import fs from "fs"; +import { parseGradeJSON, roundGrade, validateAndShape, computeGradeDistribution, medianGrade } from "../lib/grading/grading.js"; +import { normalizeBrowseItem, insightsToSold } from "../lib/sources/ebay.js"; +import { parseJPY, gradeFromTitle } from "../lib/sources/magi.js"; +import { normalizeActive as normalizeSnkrdunk } from "../lib/sources/snkrdunk.js"; +import { parseSpecPopItem } from "../lib/grading/psa.js"; import { buildSignal } from "../lib/grading/psa.js"; import { deriveEra } from "../lib/cards/card-database.js"; import { cornerCropsToImageBlocks, imageBlockFromUrl, imageBlockFromBase64, parseAnthropicResponse, parseTogetherResponse } from "../lib/grading/preprocessing.js"; @@ -1765,6 +1770,113 @@ test("gradeDistribution: non-standard grade snaps to nearest", () => { eq(total, 100); }); +// ── medianGrade ── + +console.log("\n\x1b[1m=== medianGrade ===\x1b[0m"); + +const mockGrade = (scores) => ({ + provider: "claude", + mode: "llm-detailed-v3", + overall: 8, + confidence: 0.75, + limitations: "", + cardDetection: { front: null, back: null }, + tokenUsage: { input: 15000, output: 700 }, + estimatedCost: 0.06, + subgradeDetails: { + centering_front: { score: scores[0], confidence: 0.8, detail: "test" }, + centering_back: { score: scores[1], confidence: 0.7, detail: "test" }, + corners_front: { score: scores[2], confidence: 0.7, detail: "test" }, + corners_back: { score: scores[3], confidence: 0.6, detail: "test" }, + edges_front: { score: scores[4], confidence: 0.8, detail: "test" }, + edges_back: { score: scores[5], confidence: 0.6, detail: "test" }, + surface_front: { score: scores[6], confidence: 0.7, detail: "test" }, + surface_back: { score: scores[7], confidence: 0.6, detail: "test" }, + }, +}); + +test("medianGrade: single pass returns same grade", () => { + const g = mockGrade([9, 8, 8, 7, 9, 7, 9, 8]); + const result = medianGrade([g]); + eq(result, g); +}); + +test("medianGrade: 3 passes takes median per subgrade", () => { + const g1 = mockGrade([9, 8, 8, 7, 9, 7, 9, 8]); + const g2 = mockGrade([8, 7, 9, 8, 8, 8, 8, 7]); + const g3 = mockGrade([9, 8, 8, 7, 9, 7, 9, 8]); + const result = medianGrade([g1, g2, g3]); + eq(result.subgradeDetails.centering_front.score, 9); + eq(result.subgradeDetails.centering_back.score, 8); + eq(result.subgradeDetails.corners_front.score, 8); + eq(result.subgradeDetails.corners_back.score, 7); + eq(result.passes, 3); +}); + +test("medianGrade: consistency shows stddev", () => { + const g1 = mockGrade([9, 8, 8, 7, 9, 7, 9, 8]); + const g2 = mockGrade([7, 8, 8, 7, 9, 7, 9, 8]); + const g3 = mockGrade([9, 8, 8, 7, 9, 7, 9, 8]); + const result = medianGrade([g1, g2, g3]); + assert(result.consistency.centering_front > 0, "centering_front should have stddev > 0"); + eq(result.consistency.corners_back, 0); +}); + +test("medianGrade: token usage sums across passes", () => { + const g1 = mockGrade([9, 8, 8, 7, 9, 7, 9, 8]); + const g2 = mockGrade([9, 8, 8, 7, 9, 7, 9, 8]); + const result = medianGrade([g1, g2]); + eq(result.tokenUsage.input, 30000); + eq(result.tokenUsage.output, 1400); +}); + +test("medianGrade: notes mentions pass count", () => { + const g1 = mockGrade([9, 8, 8, 7, 9, 7, 9, 8]); + const g2 = mockGrade([9, 8, 8, 7, 9, 7, 9, 8]); + const result = medianGrade([g1, g2]); + assert(result.notes.includes("2 passes"), `notes should mention passes: ${result.notes}`); +}); + +test("medianGrade: empty array returns null", () => { + eq(medianGrade([]), null); +}); + +test("medianGrade: all identical scores gives 0 stddev", () => { + const g1 = mockGrade([8, 8, 8, 8, 8, 8, 8, 8]); + const g2 = mockGrade([8, 8, 8, 8, 8, 8, 8, 8]); + const g3 = mockGrade([8, 8, 8, 8, 8, 8, 8, 8]); + const result = medianGrade([g1, g2, g3]); + for (const key of Object.keys(result.consistency)) { + eq(result.consistency[key], 0); + } +}); + +test("medianGrade: 2 passes averages (even count median)", () => { + const g1 = mockGrade([8, 8, 8, 8, 8, 8, 8, 8]); + const g2 = mockGrade([10, 10, 10, 10, 10, 10, 10, 10]); + const result = medianGrade([g1, g2]); + eq(result.subgradeDetails.centering_front.score, 9); + eq(result.subgradeDetails.corners_back.score, 9); +}); + +test("medianGrade: max variance across passes", () => { + const g1 = mockGrade([5, 5, 5, 5, 5, 5, 5, 5]); + const g2 = mockGrade([10, 10, 10, 10, 10, 10, 10, 10]); + const g3 = mockGrade([7, 7, 7, 7, 7, 7, 7, 7]); + const result = medianGrade([g1, g2, g3]); + eq(result.subgradeDetails.centering_front.score, 7); + assert(result.consistency.centering_front > 2, "high variance expected"); +}); + +test("medianGrade: has gradeDistribution", () => { + const g1 = mockGrade([9, 8, 8, 7, 9, 7, 9, 8]); + const g2 = mockGrade([9, 8, 8, 7, 9, 7, 9, 8]); + const result = medianGrade([g1, g2]); + assert(result.gradeDistribution, "should have gradeDistribution"); + const total = Object.values(result.gradeDistribution).reduce((a, b) => a + b, 0); + eq(total, 100); +}); + // ── centering hint ── console.log("\n\x1b[1m=== centering hint ===\x1b[0m"); @@ -1794,6 +1906,155 @@ test("centering hint: missing back hint produces empty suffix", () => { eq(suffix, ""); }); +// ── fixture: eBay normalizeBrowseItem ── + +console.log("\n\x1b[1m=== fixture: normalizeBrowseItem ===\x1b[0m"); + +const ebayFixture = JSON.parse(fs.readFileSync("test/fixtures/ebay-browse-item.json", "utf8")); + +test("normalizeBrowseItem: extracts itemId", () => { + const r = normalizeBrowseItem(ebayFixture); + eq(r.itemId, "v1|366397520173|0"); +}); + +test("normalizeBrowseItem: extracts price as number", () => { + const r = normalizeBrowseItem(ebayFixture); + eq(r.price, 400); + eq(r.priceCurrency, "USD"); +}); + +test("normalizeBrowseItem: extracts shipping cost", () => { + const r = normalizeBrowseItem(ebayFixture); + eq(r.shippingCost, 0); + eq(r.totalCost, 400); +}); + +test("normalizeBrowseItem: extracts seller info", () => { + const r = normalizeBrowseItem(ebayFixture); + eq(r.seller.username, "card_seller_jp"); + eq(r.seller.feedbackScore, 1523); +}); + +test("normalizeBrowseItem: upgrades image to s-l1600", () => { + const r = normalizeBrowseItem(ebayFixture); + assert(r.imageUrl.includes("s-l1600"), `expected s-l1600, got ${r.imageUrl}`); +}); + +test("normalizeBrowseItem: handles missing price (NaN)", () => { + const r = normalizeBrowseItem({ ...ebayFixture, price: null }); + assert(Number.isNaN(r.price), `expected NaN, got ${r.price}`); +}); + +test("normalizeBrowseItem: handles missing seller", () => { + const r = normalizeBrowseItem({ ...ebayFixture, seller: null }); + eq(r.seller, null); +}); + +// ── fixture: eBay insightsToSold ── + +console.log("\n\x1b[1m=== fixture: insightsToSold ===\x1b[0m"); + +const insightsFixture = JSON.parse(fs.readFileSync("test/fixtures/ebay-insights-entry.json", "utf8")); + +test("insightsToSold: extracts price from lastSoldPrice", () => { + const r = insightsToSold(insightsFixture); + eq(r.price, 610); + eq(r.currency, "USD"); +}); + +test("insightsToSold: extracts date", () => { + const r = insightsToSold(insightsFixture); + assert(r.endedDate, "should have endedDate"); +}); + +test("insightsToSold: extracts image", () => { + const r = insightsToSold(insightsFixture); + assert(r.imageUrl.includes("ebayimg"), "should have eBay image"); +}); + +test("insightsToSold: handles missing price fields", () => { + const r = insightsToSold({ title: "test", itemWebUrl: "https://ebay.com" }); + eq(r.price, null); +}); + +// ── fixture: parseJPY ── + +console.log("\n\x1b[1m=== fixture: parseJPY ===\x1b[0m"); + +test("parseJPY: standard format", () => { eq(parseJPY("¥46,800"), 46800); }); +test("parseJPY: with space", () => { eq(parseJPY("¥ 1,000"), 1000); }); +test("parseJPY: no yen sign", () => { eq(parseJPY("46800"), null); }); +test("parseJPY: empty string", () => { eq(parseJPY(""), null); }); +test("parseJPY: null input", () => { eq(parseJPY(null), null); }); +test("parseJPY: free text", () => { eq(parseJPY("Free"), null); }); +test("parseJPY: large amount", () => { eq(parseJPY("¥1,234,567"), 1234567); }); + +// ── fixture: gradeFromTitle ── + +console.log("\n\x1b[1m=== fixture: gradeFromTitle ===\x1b[0m"); + +test("gradeFromTitle: JP bracket format", () => { eq(gradeFromTitle("【PSA10】Umbreon ex SAR"), "PSA 10"); }); +test("gradeFromTitle: English format", () => { eq(gradeFromTitle("Umbreon ex SAR PSA 10 Gem Mint"), "PSA 10"); }); +test("gradeFromTitle: BGS with decimal", () => { eq(gradeFromTitle("BGS 9.5 Umbreon ex"), "BGS 9.5"); }); +test("gradeFromTitle: CGC", () => { eq(gradeFromTitle("CGC 10 Pristine Pikachu"), "CGC 10"); }); +test("gradeFromTitle: no grade", () => { eq(gradeFromTitle("Umbreon ex SAR 217/187 raw"), null); }); +test("gradeFromTitle: null input", () => { eq(gradeFromTitle(null), null); }); + +// ── fixture: SNKRDUNK normalizeActive ── + +console.log("\n\x1b[1m=== fixture: normalizeActive (SNKRDUNK) ===\x1b[0m"); + +const snkrFixture = JSON.parse(fs.readFileSync("test/fixtures/snkrdunk-listing.json", "utf8")); + +test("normalizeActive: extracts itemId", () => { + const r = normalizeSnkrdunk(snkrFixture, "Mega Greninja ex SAR"); + assert(r.itemId, "should have itemId"); +}); + +test("normalizeActive: extracts price", () => { + const r = normalizeSnkrdunk(snkrFixture, "Mega Greninja ex SAR"); + eq(r.price, 46800); + eq(r.priceCurrency, "JPY"); +}); + +test("normalizeActive: extracts image", () => { + const r = normalizeSnkrdunk(snkrFixture, "Mega Greninja ex SAR"); + assert(r.imageUrl.includes("snkrdunk"), "should have SNKRDUNK image"); +}); + +test("normalizeActive: builds title with product name", () => { + const r = normalizeSnkrdunk(snkrFixture, "Mega Greninja ex SAR"); + assert(r.title.includes("Mega Greninja"), `title should include product name: ${r.title}`); +}); + +// ── fixture: parseSpecPopItem ── + +console.log("\n\x1b[1m=== fixture: parseSpecPopItem ===\x1b[0m"); + +const psaFixture = JSON.parse(fs.readFileSync("test/fixtures/psa-pop-response.json", "utf8")); + +test("parseSpecPopItem: extracts pop data", () => { + const r = parseSpecPopItem(psaFixture); + eq(r.pop10, 5415); + eq(r.pop9, 620); + eq(r.popTotal, 6182); +}); + +test("parseSpecPopItem: handles missing PSAPop", () => { + eq(parseSpecPopItem({}), null); +}); + +test("parseSpecPopItem: handles null input", () => { + eq(parseSpecPopItem(null), null); +}); + +test("parseSpecPopItem: handles partial data", () => { + const r = parseSpecPopItem({ PSAPop: { Grade10: 100, Total: 500 } }); + eq(r.pop10, 100); + eq(r.pop9, null); + eq(r.popTotal, 500); +}); + // ── Summary ── console.log(`\n\x1b[1m=== ${passed} passed, ${failed} failed ===\x1b[0m\n`);