From dcf45c2740b02ed04d72122f927e6010f97ada99 Mon Sep 17 00:00:00 2001 From: xenticore Date: Wed, 16 Apr 2025 14:04:39 -0400 Subject: [PATCH 1/8] refactor: split currency and key/metal formatting into separate units this simplifies functions and tests, and will make currency optional/variable later --- __tests__/currency.test.ts | 17 +++++++++++++++++ __tests__/formatting.test.ts | 18 +++++++++--------- src/content/uiRenderer.ts | 19 +++++++++++++++++-- src/content/utils/currency.ts | 8 ++++++++ src/content/utils/formatting.ts | 8 -------- 5 files changed, 51 insertions(+), 19 deletions(-) create mode 100644 __tests__/currency.test.ts create mode 100644 src/content/utils/currency.ts diff --git a/__tests__/currency.test.ts b/__tests__/currency.test.ts new file mode 100644 index 0000000..d38d9ff --- /dev/null +++ b/__tests__/currency.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test, jest, mock, beforeEach } from "bun:test"; +import { convertTF2PriceToUSD } from '../src/content/utils/currency' + +mock.module('../src/content/config', () => ({ + conversion_ref_usd: 0.05 // Mock conversion rate +})) + + +describe('Currency Service', () => { + beforeEach(() => { + jest.clearAllMocks() + }); + + test('convertTF2PriceToUSD returns correct price', () => { + expect(convertTF2PriceToUSD(1, 10, 50)).toBe(3); + }) +}) diff --git a/__tests__/formatting.test.ts b/__tests__/formatting.test.ts index a2b2bbc..9f3085c 100644 --- a/__tests__/formatting.test.ts +++ b/__tests__/formatting.test.ts @@ -16,34 +16,34 @@ describe('formatPrice', () => { }); test('formats price with keys and metal', () => { - expect(formatPrice(2, 10, 50)).toBe('2.2 keys (US$5.50)'); + expect(formatPrice(2, 10, 50)).toBe('2.2 keys'); expect($T).toHaveBeenCalledWith('%@ keys'); }); test('formats price with metal only', () => { - expect(formatPrice(0, 15.75, 50)).toBe('15.75 ref (US$0.79)'); + expect(formatPrice(0, 15.75, 50)).toBe('15.75 ref'); }); test('formats price with metal only and whole number', () => { - expect(formatPrice(0, 3, 50)).toBe('3 ref (US$0.16)'); + expect(formatPrice(0, 3, 50)).toBe('3 ref'); }); test('uses singular key form', () => { - expect(formatPrice(1, 0, 50)).toBe('1 key (US$2.50)'); + expect(formatPrice(1, 0, 50)).toBe('1 key'); expect($T).toHaveBeenCalledWith('%@ key'); }); test('rounds USD up to nearest cent', () => { - expect(formatPrice(3, 7.33, 35)).toBe('3.21 keys (US$5.62)'); // (3 * 35 + 7.33) * 0.05 = 5.6165 → 5.62 + expect(formatPrice(3, 7.33, 35)).toBe('3.21 keys'); // (3 * 35 + 7.33) * 0.05 = 5.6165 → 5.62 }); test('handles different locale formatting', () => { - expect(formatPrice(2, 10, 50, 'de')).toMatch(/2,2 keys \(US\$5,50\)/); - expect(formatPrice(0, 15.75, 50, 'de')).toMatch(/15,75 ref \(US\$0,79\)/); + expect(formatPrice(2, 10, 50, 'de')).toMatch(/2,2 keys/); + expect(formatPrice(0, 15.75, 50, 'de')).toMatch(/15,75 ref/); }); test('handles zero values', () => { - expect(formatPrice(0, 0, 50)).toBe('0 ref (US$0.00)'); - expect(formatPrice(0, 0, 50, 'de')).toMatch(/0 ref \(US\$0,00\)/); + expect(formatPrice(0, 0, 50)).toBe('0 ref'); + expect(formatPrice(0, 0, 50, 'de')).toMatch(/0 ref/); }); }); \ No newline at end of file diff --git a/src/content/uiRenderer.ts b/src/content/uiRenderer.ts index 3d65acf..c62f1ba 100644 --- a/src/content/uiRenderer.ts +++ b/src/content/uiRenderer.ts @@ -1,4 +1,5 @@ import { ItemPriceData } from "./priceService"; +import { convertTF2PriceToUSD } from "./utils/currency"; import { formatPrice } from "./utils/formatting"; import { $T } from "./utils/localization"; @@ -19,8 +20,22 @@ export function createPriceRow(qualityName: string, data: ItemPriceData, keyPric const priceData = document.createElement("td"); const priceLink = document.createElement("span"); - const priceString = data ? formatPrice(data.keys, data.metal, keyPrice.metal, locale).trim() : $T('Data unavailable') - priceLink.innerHTML = priceString // + `
$${data.scmPrice}` + var priceString: string = '' + + if(data) { + const currencyFormatter = new Intl.NumberFormat(locale, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + + priceString += [ + formatPrice(data.keys, data.metal, keyPrice.metal, locale).trim(), + '(US$' + currencyFormatter.format(convertTF2PriceToUSD(data.keys, data.metal, keyPrice.metal)) + ')' + ].join(' ').trim() + } else { + priceString += $T('Data unavailable') + } + priceLink.innerHTML = priceString priceData.appendChild(priceLink); priceRow.appendChild(priceData); return priceRow; diff --git a/src/content/utils/currency.ts b/src/content/utils/currency.ts new file mode 100644 index 0000000..a84202f --- /dev/null +++ b/src/content/utils/currency.ts @@ -0,0 +1,8 @@ +import { conversion_ref_usd } from '../config'; + +export function convertTF2PriceToUSD(keys: number, metal: number, keyPrice: number): number { + const pureMetal = (keys * keyPrice) + metal; + + // Round price up to nearest cent + return Math.ceil(pureMetal * conversion_ref_usd * 100) / 100 +} diff --git a/src/content/utils/formatting.ts b/src/content/utils/formatting.ts index 3ac4b9c..4c27a1e 100644 --- a/src/content/utils/formatting.ts +++ b/src/content/utils/formatting.ts @@ -7,7 +7,6 @@ function toFixed(num: number, fixed: number) { } export function formatPrice(keys: number, metal: number, keyPrice: number, locale: string = 'en') { - const pureMetal = (keys * keyPrice) + metal; const formattedKeys = +(keys + (metal / keyPrice)).toFixed(2) var output: string = '' @@ -16,13 +15,6 @@ export function formatPrice(keys: number, metal: number, keyPrice: number, local } else { output += $T("%@ ref").replace('%@', (+toFixed(metal, 2)).toLocaleString(locale)) } - const currencyFormatter = new Intl.NumberFormat(locale, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); - // Round price up to nearest cent - const price = Math.ceil(pureMetal * conversion_ref_usd * 100) / 100 - output += ` (US$${currencyFormatter.format(price)})` return output; } From 84daf5b2d67ba29bb77c29156a7d95dd781520a6 Mon Sep 17 00:00:00 2001 From: xenticore Date: Wed, 16 Apr 2025 14:53:34 -0400 Subject: [PATCH 2/8] fix, feat: add currency conversion stub uses proper JS api for currency formatting and preparations for showing other currencies based on page locale --- src/content/uiRenderer.ts | 26 ++++++++++++++++------- src/content/utils/currency.ts | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/content/uiRenderer.ts b/src/content/uiRenderer.ts index c62f1ba..0b268e0 100644 --- a/src/content/uiRenderer.ts +++ b/src/content/uiRenderer.ts @@ -1,7 +1,8 @@ import { ItemPriceData } from "./priceService"; -import { convertTF2PriceToUSD } from "./utils/currency"; +import { convertTF2PriceToUSD, defaultCurrencyForPageLocale, convertUSD } from "./utils/currency"; import { formatPrice } from "./utils/formatting"; import { $T } from "./utils/localization"; +import { logError } from "./utils/log"; export function createPriceRow(qualityName: string, data: ItemPriceData, keyPrice: ItemPriceData, locale: string, wikiPage: string = null): HTMLTableRowElement { const priceRow = document.createElement("tr"); @@ -23,14 +24,25 @@ export function createPriceRow(qualityName: string, data: ItemPriceData, keyPric var priceString: string = '' if(data) { - const currencyFormatter = new Intl.NumberFormat(locale, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); + const gamePrice = formatPrice(data.keys, data.metal, keyPrice.metal, locale).trim() + const realPriceUSD = convertTF2PriceToUSD(data.keys, data.metal, keyPrice.metal) + + const USDFormatter = new Intl.NumberFormat(locale, { style: "currency", currency: 'USD' }) + var realPriceString = USDFormatter.format(realPriceUSD) + const currency = defaultCurrencyForPageLocale(locale) ?? 'USD' + if(currency !== 'USD') { + try { + const realPrice = convertUSD(realPriceUSD, currency) + const currencyFormatter = new Intl.NumberFormat(locale, { style: "currency", currency: currency }) + realPriceString = currencyFormatter.format(realPrice).replace(/\s+/g, '') + } catch (e) { + logError(`Failed to convert USD ${realPriceUSD} to ${currency}`, e) + } + } priceString += [ - formatPrice(data.keys, data.metal, keyPrice.metal, locale).trim(), - '(US$' + currencyFormatter.format(convertTF2PriceToUSD(data.keys, data.metal, keyPrice.metal)) + ')' + gamePrice, + '(' + realPriceString + ')' ].join(' ').trim() } else { priceString += $T('Data unavailable') diff --git a/src/content/utils/currency.ts b/src/content/utils/currency.ts index a84202f..8a7f122 100644 --- a/src/content/utils/currency.ts +++ b/src/content/utils/currency.ts @@ -1,8 +1,47 @@ import { conversion_ref_usd } from '../config'; +export function defaultCurrencyForPageLocale(locale: string): string | null { + switch (locale) { + case 'de': + case 'fr': + case 'it': + case 'nl': + case 'hu': + return 'EUR'; + case 'ja': + return 'JPY'; + case 'tr': + return 'TRY'; + case 'da': + return 'DKK'; + case 'pt-br': + return 'BRL'; + case 'zh-hans': + case 'zh-hant': + return 'CNY'; + case 'ko': + return 'KRW'; + case 'cs': + return 'CZK'; + case 'ru': + return 'RUB'; + case 'pl': + return 'PLN'; + default: + return null; + } +} + export function convertTF2PriceToUSD(keys: number, metal: number, keyPrice: number): number { const pureMetal = (keys * keyPrice) + metal; // Round price up to nearest cent return Math.ceil(pureMetal * conversion_ref_usd * 100) / 100 } + +export function convertUSD(usd: number, currency: string): number { + const cur = currency.toUpperCase() + if(cur === 'USD') return usd + + throw new Error(`Not implemented`) +} From fcf077c87717555f95b5d91385f4e2657edf6798 Mon Sep 17 00:00:00 2001 From: xenticore Date: Wed, 16 Apr 2025 18:23:07 -0400 Subject: [PATCH 3/8] feat: fetch currency exchange rate exchange rates are fetched at script startup, and at most once daily, then cached. attribution is added as per ExchangeRate-API's requirements --- src/content/config.ts | 3 ++ src/content/content.ts | 20 ++++++++--- src/content/exchangeRateService.ts | 55 ++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 src/content/exchangeRateService.ts diff --git a/src/content/config.ts b/src/content/config.ts index 41e0477..282de8d 100644 --- a/src/content/config.ts +++ b/src/content/config.ts @@ -3,6 +3,9 @@ export const storage_lastUpdateTime = 'tf2wikipricing_lastUpdate'; export const storage_schema = 'tf2wikipricing_schema'; export const storage_version = 'tf2wikipricing_version'; export const storage_priceprefix = 'tf2wikipricing_sku_'; +export const storage_exchangerates = 'tf2wikipricing_exchangerates'; +export const storage_exchangerates_update = 'tf2wikipricing_exchangerates_update'; +export const storage_exchangerates_next = 'tf2wikipricing_exchangerates_next'; export const conversion_ref_usd = 0.0265; export const defindex_key = 5021; export const defindex_metal_refined = 5002; diff --git a/src/content/content.ts b/src/content/content.ts index 1d8cbb5..288fe02 100644 --- a/src/content/content.ts +++ b/src/content/content.ts @@ -9,7 +9,9 @@ import { fetchPrice, fetchKeyPrice, ItemPriceData } from './priceService' import { createPriceRow, createStoreButton } from './uiRenderer' import { findFirstElement, findFirstChildElement } from './utils/dom' import { extractPageTitleFromURL } from './utils/url'; +import { ExchangeRates, prepareExchangeRates } from './exchangeRateService'; var itemSchema: ItemSchema | null; +var exchangeRates: ExchangeRates | null; var locale: string = 'en' @@ -425,7 +427,8 @@ async function inject() { label.style.fontSize = "85%"; const updateText = $T("Updated %@.", locale).replace('%@', updateTime.toLocaleString(locale, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' })) const attributionText = $T("Trade prices sourced from %@. Currency conversions are approximate.", locale).replace('%@', 'prices.tf'); - label.innerHTML = `${updateText}
${attributionText}`; + const exchangeRateAttribution = `Rates By Exchange Rate API.`; + label.innerHTML = `${updateText}
${attributionText}
${exchangeRateAttribution}`; row.appendChild(label); priceProgressRow.insertAdjacentElement('afterend', row); @@ -440,15 +443,22 @@ function addStyles() { style.innerHTML = styleCss; } -prepareSchema().then(function (schema) { +prepareSchema() +.then(schema => { itemSchema = schema; if (!itemSchema) { - logError("No item schema ready, exiting."); wipeSchema(); // FIXME: ugly hack. requires additional page reload. if prepareSchema returns null, we should handle it properly - return; + throw new Error("No item schema ready"); } +}) +.then(prepareExchangeRates) +.then(rates => exchangeRates = rates) +.then(() => { locale = extractLocaleFromURL(document.URL) addStyles(); inject(); // TODO: Purge expired price data -}); \ No newline at end of file +}) +.catch((error) => { + logError(error); +}) \ No newline at end of file diff --git a/src/content/exchangeRateService.ts b/src/content/exchangeRateService.ts new file mode 100644 index 0000000..fe3a24a --- /dev/null +++ b/src/content/exchangeRateService.ts @@ -0,0 +1,55 @@ +import { getStorageValue, setStorageValue } from './storage' +import { logDebug, log, logError } from './utils/log' +import { storage_exchangerates, storage_exchangerates_next, storage_exchangerates_update } from './config' +declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise +import './GM_fetch' + +export interface ExchangeRates { + [key: string]: number; +} + +export async function wipeExchangeRates(): Promise { + await setStorageValue(storage_exchangerates, null) + await setStorageValue(storage_exchangerates_update, new Date().toISOString()) + await setStorageValue(storage_exchangerates_next, new Date().toISOString()) + logDebug(`Exchange rates wiped`) +} + +export async function prepareExchangeRates(): Promise { + var needsUpdate: Boolean = false + var rates: ExchangeRates | null = null + + rates = await getStorageValue(storage_exchangerates, null); + const update = await getStorageValue(storage_exchangerates_update, null) + const nextUpdate = await getStorageValue(storage_exchangerates_next, null) + if (update && nextUpdate) { + const lastUpdateTime = new Date(update); + const nextUpdateTime = new Date(nextUpdate); + log(`Exchange rates updated at ${lastUpdateTime}`); + if (rates == null || Object.keys(rates).length === 0 || lastUpdateTime.getTime() > nextUpdateTime.getTime()) { + needsUpdate = true + } + } else { + needsUpdate = true + } + + if(needsUpdate) { + log("Exchange rates out of Date. Rebuilding..."); + const url = "https://open.er-api.com/v6/latest/USD" + const response = await GM_fetch(url); + if (response.ok) { + await setStorageValue(storage_exchangerates_update, new Date().toISOString()) + var json = await response.json() + if(json != null){ + rates = json['rates'] + await setStorageValue(storage_exchangerates, rates) + await setStorageValue(storage_exchangerates_next, json['time_next_update_utc']) + } + logDebug(`Exchange rates updated at ${new Date()}`) + } else { + logError(`Failed to fetch exchange rates. Status code: ${response.status}`, response) + } + } + + return rates +} From 1d92d9e20cd83736727886bfc4a282303c95f8ef Mon Sep 17 00:00:00 2001 From: xenticore Date: Wed, 16 Apr 2025 18:24:27 -0400 Subject: [PATCH 4/8] feat: add currency conversions to price table Exchange rates are loaded once per page and passed to price row generation, so we don't hit the cache more than once. --- src/content/content.ts | 12 ++++++------ src/content/uiRenderer.ts | 5 +++-- src/content/utils/currency.ts | 5 +++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/content/content.ts b/src/content/content.ts index 288fe02..b0a233f 100644 --- a/src/content/content.ts +++ b/src/content/content.ts @@ -222,7 +222,7 @@ async function inject() { } const qualityName = itemQualities[quality as unknown as keyof typeof itemQualities].toString() - const priceRow = createPriceRow(qualityName, data, keyPrice, locale) + const priceRow = createPriceRow(qualityName, data, keyPrice, exchangeRates, locale) priceRows.push({order: quality == 6 ? -1 : quality, row: priceRow, category: PriceRowCategory.None}) }) @@ -239,7 +239,7 @@ async function inject() { log(`Australium ${itemName} is unpriced or unavailable, skipping...`) } - const priceRow = createPriceRow($T("Australium"), data, keyPrice, locale, "https://wiki.teamfortress.com/wiki/Australium_weapons") + const priceRow = createPriceRow($T("Australium"), data, keyPrice, exchangeRates, locale, "https://wiki.teamfortress.com/wiki/Australium_weapons") priceRows.push({order: 99, row: priceRow, category: PriceRowCategory.None}) resolve() @@ -271,7 +271,7 @@ async function inject() { log(`Festive ${itemName} is unpriced or unavailable, skipping...`) } - const priceRow = createPriceRow($T("Unique"), data, keyPrice, locale) + const priceRow = createPriceRow($T("Unique"), data, keyPrice, exchangeRates, locale) priceRows.push({order: -1, row: priceRow, category: PriceRowCategory.Festive}) resolve() @@ -287,7 +287,7 @@ async function inject() { log(`Strange Festive ${itemName} is unpriced or unavailable, skipping...`) } - const priceRow = createPriceRow($T("Strange"), data, keyPrice, locale) + const priceRow = createPriceRow($T("Strange"), data, keyPrice, exchangeRates, locale) priceRows.push({order: 11, row: priceRow, category: PriceRowCategory.Festive}) resolve() @@ -331,7 +331,7 @@ async function inject() { return } - const priceRow = createPriceRow($T(`kt-${tier}`), data, keyPrice, locale, "https://wiki.teamfortress.com/wiki/Killstreak_Kit") + const priceRow = createPriceRow($T(`kt-${tier}`), data, keyPrice, exchangeRates, locale, "https://wiki.teamfortress.com/wiki/Killstreak_Kit") priceRows.push({order: tier, row: priceRow, category: PriceRowCategory.KillstreakKit}) resolve() @@ -378,7 +378,7 @@ async function inject() { log(`${itemName} is unpriced or unavailable, skipping...`) } - const priceRow = createPriceRow($T(variantName), data, keyPrice, locale, "https://wiki.teamfortress.com/wiki/Botkiller_weapons") + const priceRow = createPriceRow($T(variantName), data, keyPrice, exchangeRates, locale, "https://wiki.teamfortress.com/wiki/Botkiller_weapons") // FIXME: order should be by release // Silver Mk.I, Gold Mk.II, Rust, Blood, Carbonado, Diamond, Silver Mk.II, Gold Mk.II diff --git a/src/content/uiRenderer.ts b/src/content/uiRenderer.ts index 0b268e0..6c3f722 100644 --- a/src/content/uiRenderer.ts +++ b/src/content/uiRenderer.ts @@ -1,10 +1,11 @@ +import { ExchangeRates } from "./exchangeRateService"; import { ItemPriceData } from "./priceService"; import { convertTF2PriceToUSD, defaultCurrencyForPageLocale, convertUSD } from "./utils/currency"; import { formatPrice } from "./utils/formatting"; import { $T } from "./utils/localization"; import { logError } from "./utils/log"; -export function createPriceRow(qualityName: string, data: ItemPriceData, keyPrice: ItemPriceData, locale: string, wikiPage: string = null): HTMLTableRowElement { +export function createPriceRow(qualityName: string, data: ItemPriceData, keyPrice: ItemPriceData, rates: ExchangeRates, locale: string, wikiPage: string = null): HTMLTableRowElement { const priceRow = document.createElement("tr"); const priceLabel = document.createElement("td"); @@ -32,7 +33,7 @@ export function createPriceRow(qualityName: string, data: ItemPriceData, keyPric const currency = defaultCurrencyForPageLocale(locale) ?? 'USD' if(currency !== 'USD') { try { - const realPrice = convertUSD(realPriceUSD, currency) + const realPrice = convertUSD(realPriceUSD, currency, rates) const currencyFormatter = new Intl.NumberFormat(locale, { style: "currency", currency: currency }) realPriceString = currencyFormatter.format(realPrice).replace(/\s+/g, '') } catch (e) { diff --git a/src/content/utils/currency.ts b/src/content/utils/currency.ts index 8a7f122..69b7914 100644 --- a/src/content/utils/currency.ts +++ b/src/content/utils/currency.ts @@ -1,4 +1,5 @@ import { conversion_ref_usd } from '../config'; +import { ExchangeRates } from '../exchangeRateService'; export function defaultCurrencyForPageLocale(locale: string): string | null { switch (locale) { @@ -39,9 +40,9 @@ export function convertTF2PriceToUSD(keys: number, metal: number, keyPrice: numb return Math.ceil(pureMetal * conversion_ref_usd * 100) / 100 } -export function convertUSD(usd: number, currency: string): number { +export function convertUSD(usd: number, currency: string, rates: ExchangeRates): number { const cur = currency.toUpperCase() if(cur === 'USD') return usd - throw new Error(`Not implemented`) + return usd * rates[cur] } From e6eceed56860577ce0d1da53b50610c926b60344 Mon Sep 17 00:00:00 2001 From: xenticore Date: Wed, 16 Apr 2025 19:31:38 -0400 Subject: [PATCH 5/8] test: add exchange rate service tests --- __tests__/exchangeRateService.test.ts | 94 +++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 __tests__/exchangeRateService.test.ts diff --git a/__tests__/exchangeRateService.test.ts b/__tests__/exchangeRateService.test.ts new file mode 100644 index 0000000..c9c153c --- /dev/null +++ b/__tests__/exchangeRateService.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, jest, mock, beforeEach, beforeAll } from "bun:test"; +import { prepareExchangeRates, wipeExchangeRates } from '../src/content/exchangeRateService.ts' +import { getStorageValue, setStorageValue } from '../src/content/storage' +import { storage_exchangerates, storage_exchangerates_next, storage_exchangerates_update } from "../src/content/config"; +import { logDebug, logError } from '../src/content/utils/log'; +declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise + +// Mock the storage functions +mock.module('../src/content/storage', () => ({ + setStorageValue: mock(async (key: string, value: any) => {}), + getStorageValue: mock(async (key: string, defaultValue: any) => defaultValue) +})); + +// Mock the log functions +mock.module('../src/content/utils/log', () => ({ + logDebug: mock(() => {}), + log: mock(() => {}), + logError: mock(() => {}) +})); + +describe('wipeExchangeRates', () => { + it('should clear exchange rate storage and set update timestamps', async () => { + await wipeExchangeRates(); + + expect(setStorageValue).toHaveBeenCalledWith(storage_exchangerates, null); + expect(setStorageValue).toHaveBeenCalledWith(storage_exchangerates_update, expect.any(String)); + expect(setStorageValue).toHaveBeenCalledWith(storage_exchangerates_next, expect.any(String)); + expect(logDebug).toHaveBeenCalled(); + }); +}); + +describe('prepareExchangeRates', () => { + const mockRates = { USD: 1, EUR: 0.85, GBP: 0.73 }; + + beforeAll(() => { + jest + .spyOn(global, 'GM_fetch') + .mockImplementation(() => { + return Promise.resolve({ + ok: false, + status: 500 + } as Response); + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return stored rates if they are up to date', async () => { + (getStorageValue as jest.Mock).mockImplementation(async (key) => { + if (key === storage_exchangerates) return mockRates; + if (key === storage_exchangerates_update) return new Date().toISOString(); + if (key === storage_exchangerates_next) return new Date(Date.now() + 100000).toISOString(); + return null; + }); + + const rates = await prepareExchangeRates(); + expect(rates).toEqual(mockRates); + expect(GM_fetch).not.toHaveBeenCalled(); + }); + + it('should fetch new rates when they are expired', async () => { + (getStorageValue as jest.Mock).mockImplementation(async (key) => { + if (key === storage_exchangerates_update) return new Date(Date.now() - 100000).toISOString(); + if (key === storage_exchangerates_next) return new Date(Date.now() - 50000).toISOString(); + return null; + }); + (GM_fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ + rates: mockRates, + time_next_update_utc: new Date(Date.now() + 100000).toISOString() + }) + }); + + const rates = await prepareExchangeRates(); + expect(rates).toEqual(mockRates); + expect(GM_fetch).toHaveBeenCalled(); + expect(setStorageValue).toHaveBeenCalledWith(storage_exchangerates, mockRates); + }); + + it('should handle fetch errors gracefully', async () => { + (getStorageValue as jest.Mock).mockResolvedValue(null); + (GM_fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500 + } as Response); + + const rates = await prepareExchangeRates(); + expect(rates).toBeNull(); + expect(logError).toHaveBeenCalled(); + }); +}); From 90ddc075df5e793ad465a35be26a6878fd3070c4 Mon Sep 17 00:00:00 2001 From: xenticore Date: Wed, 16 Apr 2025 20:43:12 -0400 Subject: [PATCH 6/8] feat: add more currencies for page languages --- src/content/utils/currency.ts | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/content/utils/currency.ts b/src/content/utils/currency.ts index 69b7914..c63de37 100644 --- a/src/content/utils/currency.ts +++ b/src/content/utils/currency.ts @@ -3,31 +3,37 @@ import { ExchangeRates } from '../exchangeRateService'; export function defaultCurrencyForPageLocale(locale: string): string | null { switch (locale) { + case 'cs': + return 'CZK'; + case 'da': + return 'DKK'; case 'de': + case 'fi': case 'fr': case 'it': case 'nl': case 'hu': + case 'pt': return 'EUR'; case 'ja': return 'JPY'; - case 'tr': - return 'TRY'; - case 'da': - return 'DKK'; + case 'ko': + return 'KRW'; + case 'pl': + return 'PLN'; case 'pt-br': return 'BRL'; + case 'ro': + return 'RON'; + case 'ru': + return 'RUB'; + case 'sv': + return 'SEK'; + case 'tr': + return 'TRY'; case 'zh-hans': case 'zh-hant': return 'CNY'; - case 'ko': - return 'KRW'; - case 'cs': - return 'CZK'; - case 'ru': - return 'RUB'; - case 'pl': - return 'PLN'; default: return null; } From 21fcb579693b10ed9f7d5ad11b98c808f714f464 Mon Sep 17 00:00:00 2001 From: xenticore Date: Wed, 16 Apr 2025 20:57:28 -0400 Subject: [PATCH 7/8] fix: keep spacing in currency formatting --- src/content/uiRenderer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content/uiRenderer.ts b/src/content/uiRenderer.ts index 6c3f722..f555759 100644 --- a/src/content/uiRenderer.ts +++ b/src/content/uiRenderer.ts @@ -35,7 +35,7 @@ export function createPriceRow(qualityName: string, data: ItemPriceData, keyPric try { const realPrice = convertUSD(realPriceUSD, currency, rates) const currencyFormatter = new Intl.NumberFormat(locale, { style: "currency", currency: currency }) - realPriceString = currencyFormatter.format(realPrice).replace(/\s+/g, '') + realPriceString = currencyFormatter.format(realPrice) } catch (e) { logError(`Failed to convert USD ${realPriceUSD} to ${currency}`, e) } From cb782fb57250e05257192143ae9f659fc48271e4 Mon Sep 17 00:00:00 2001 From: xenticore Date: Wed, 16 Apr 2025 20:58:17 -0400 Subject: [PATCH 8/8] bump version to 0.7.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5baf872..3c08e35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tf2wikipricing", - "version": "0.6.0", + "version": "0.7.0", "description": "Adds item pricing to the Team Fortress 2 wiki", "devDependencies": { "@happy-dom/global-registrator": "^17.4.4",