bump version to 0.7.0

Reviewed-on: http://charon.local/git/xen/tf2wikipricing/pulls/18
This commit is contained in:
Xen
2025-04-16 21:01:33 -04:00
10 changed files with 285 additions and 32 deletions

View File

@@ -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);
})
})

View File

@@ -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<Response>
// 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();
});
});

View File

@@ -16,34 +16,34 @@ describe('formatPrice', () => {
}); });
test('formats price with keys and metal', () => { 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'); expect($T).toHaveBeenCalledWith('%@ keys');
}); });
test('formats price with metal only', () => { 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', () => { 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', () => { 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'); expect($T).toHaveBeenCalledWith('%@ key');
}); });
test('rounds USD up to nearest cent', () => { 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', () => { test('handles different locale formatting', () => {
expect(formatPrice(2, 10, 50, 'de')).toMatch(/2,2 keys \(US\$5,50\)/); expect(formatPrice(2, 10, 50, 'de')).toMatch(/2,2 keys/);
expect(formatPrice(0, 15.75, 50, 'de')).toMatch(/15,75 ref \(US\$0,79\)/); expect(formatPrice(0, 15.75, 50, 'de')).toMatch(/15,75 ref/);
}); });
test('handles zero values', () => { test('handles zero values', () => {
expect(formatPrice(0, 0, 50)).toBe('0 ref (US$0.00)'); expect(formatPrice(0, 0, 50)).toBe('0 ref');
expect(formatPrice(0, 0, 50, 'de')).toMatch(/0 ref \(US\$0,00\)/); expect(formatPrice(0, 0, 50, 'de')).toMatch(/0 ref/);
}); });
}); });

View File

@@ -1,6 +1,6 @@
{ {
"name": "tf2wikipricing", "name": "tf2wikipricing",
"version": "0.6.0", "version": "0.7.0",
"description": "Adds item pricing to the Team Fortress 2 wiki", "description": "Adds item pricing to the Team Fortress 2 wiki",
"devDependencies": { "devDependencies": {
"@happy-dom/global-registrator": "^17.4.4", "@happy-dom/global-registrator": "^17.4.4",

View File

@@ -3,6 +3,9 @@ export const storage_lastUpdateTime = 'tf2wikipricing_lastUpdate';
export const storage_schema = 'tf2wikipricing_schema'; export const storage_schema = 'tf2wikipricing_schema';
export const storage_version = 'tf2wikipricing_version'; export const storage_version = 'tf2wikipricing_version';
export const storage_priceprefix = 'tf2wikipricing_sku_'; 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 conversion_ref_usd = 0.0265;
export const defindex_key = 5021; export const defindex_key = 5021;
export const defindex_metal_refined = 5002; export const defindex_metal_refined = 5002;

View File

@@ -9,7 +9,9 @@ import { fetchPrice, fetchKeyPrice, ItemPriceData } from './priceService'
import { createPriceRow, createStoreButton } from './uiRenderer' import { createPriceRow, createStoreButton } from './uiRenderer'
import { findFirstElement, findFirstChildElement } from './utils/dom' import { findFirstElement, findFirstChildElement } from './utils/dom'
import { extractPageTitleFromURL } from './utils/url'; import { extractPageTitleFromURL } from './utils/url';
import { ExchangeRates, prepareExchangeRates } from './exchangeRateService';
var itemSchema: ItemSchema | null; var itemSchema: ItemSchema | null;
var exchangeRates: ExchangeRates | null;
var locale: string = 'en' var locale: string = 'en'
@@ -220,7 +222,7 @@ async function inject() {
} }
const qualityName = itemQualities[quality as unknown as keyof typeof itemQualities].toString() 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}) priceRows.push({order: quality == 6 ? -1 : quality, row: priceRow, category: PriceRowCategory.None})
}) })
@@ -237,7 +239,7 @@ async function inject() {
log(`Australium ${itemName} is unpriced or unavailable, skipping...`) 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}) priceRows.push({order: 99, row: priceRow, category: PriceRowCategory.None})
resolve() resolve()
@@ -269,7 +271,7 @@ async function inject() {
log(`Festive ${itemName} is unpriced or unavailable, skipping...`) 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}) priceRows.push({order: -1, row: priceRow, category: PriceRowCategory.Festive})
resolve() resolve()
@@ -285,7 +287,7 @@ async function inject() {
log(`Strange Festive ${itemName} is unpriced or unavailable, skipping...`) 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}) priceRows.push({order: 11, row: priceRow, category: PriceRowCategory.Festive})
resolve() resolve()
@@ -329,7 +331,7 @@ async function inject() {
return 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}) priceRows.push({order: tier, row: priceRow, category: PriceRowCategory.KillstreakKit})
resolve() resolve()
@@ -376,7 +378,7 @@ async function inject() {
log(`${itemName} is unpriced or unavailable, skipping...`) 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 // FIXME: order should be by release
// Silver Mk.I, Gold Mk.II, Rust, Blood, Carbonado, Diamond, Silver Mk.II, Gold Mk.II // Silver Mk.I, Gold Mk.II, Rust, Blood, Carbonado, Diamond, Silver Mk.II, Gold Mk.II
@@ -425,7 +427,8 @@ async function inject() {
label.style.fontSize = "85%"; 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 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('%@', '<a rel="nofollow" class="external text" href="https://prices.tf">prices.tf</a>'); const attributionText = $T("Trade prices sourced from %@. Currency conversions are approximate.", locale).replace('%@', '<a rel="nofollow" class="external text" href="https://prices.tf">prices.tf</a>');
label.innerHTML = `${updateText}<br>${attributionText}`; const exchangeRateAttribution = `<a rel="nofollow" class="external text" href="https://www.exchangerate-api.com">Rates By Exchange Rate API</a>.`;
label.innerHTML = `${updateText}<br>${attributionText}<br>${exchangeRateAttribution}`;
row.appendChild(label); row.appendChild(label);
priceProgressRow.insertAdjacentElement('afterend', row); priceProgressRow.insertAdjacentElement('afterend', row);
@@ -440,15 +443,22 @@ function addStyles() {
style.innerHTML = styleCss; style.innerHTML = styleCss;
} }
prepareSchema().then(function (schema) { prepareSchema()
.then(schema => {
itemSchema = schema; itemSchema = schema;
if (!itemSchema) { 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 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) locale = extractLocaleFromURL(document.URL)
addStyles(); addStyles();
inject(); inject();
// TODO: Purge expired price data // TODO: Purge expired price data
}); })
.catch((error) => {
logError(error);
})

View File

@@ -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<Response>
import './GM_fetch'
export interface ExchangeRates {
[key: string]: number;
}
export async function wipeExchangeRates(): Promise<void> {
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<ExchangeRates> {
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
}

View File

@@ -1,8 +1,11 @@
import { ExchangeRates } from "./exchangeRateService";
import { ItemPriceData } from "./priceService"; import { ItemPriceData } from "./priceService";
import { convertTF2PriceToUSD, defaultCurrencyForPageLocale, convertUSD } from "./utils/currency";
import { formatPrice } from "./utils/formatting"; import { formatPrice } from "./utils/formatting";
import { $T } from "./utils/localization"; 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 priceRow = document.createElement("tr");
const priceLabel = document.createElement("td"); const priceLabel = document.createElement("td");
@@ -19,8 +22,33 @@ export function createPriceRow(qualityName: string, data: ItemPriceData, keyPric
const priceData = document.createElement("td"); const priceData = document.createElement("td");
const priceLink = document.createElement("span"); const priceLink = document.createElement("span");
const priceString = data ? formatPrice(data.keys, data.metal, keyPrice.metal, locale).trim() : $T('Data unavailable') var priceString: string = ''
priceLink.innerHTML = priceString // + `<br>$${data.scmPrice}`
if(data) {
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, rates)
const currencyFormatter = new Intl.NumberFormat(locale, { style: "currency", currency: currency })
realPriceString = currencyFormatter.format(realPrice)
} catch (e) {
logError(`Failed to convert USD ${realPriceUSD} to ${currency}`, e)
}
}
priceString += [
gamePrice,
'(' + realPriceString + ')'
].join(' ').trim()
} else {
priceString += $T('Data unavailable')
}
priceLink.innerHTML = priceString
priceData.appendChild(priceLink); priceData.appendChild(priceLink);
priceRow.appendChild(priceData); priceRow.appendChild(priceData);
return priceRow; return priceRow;

View File

@@ -0,0 +1,54 @@
import { conversion_ref_usd } from '../config';
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 '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';
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, rates: ExchangeRates): number {
const cur = currency.toUpperCase()
if(cur === 'USD') return usd
return usd * rates[cur]
}

View File

@@ -7,7 +7,6 @@ function toFixed(num: number, fixed: number) {
} }
export function formatPrice(keys: number, metal: number, keyPrice: number, locale: string = 'en') { export function formatPrice(keys: number, metal: number, keyPrice: number, locale: string = 'en') {
const pureMetal = (keys * keyPrice) + metal;
const formattedKeys = +(keys + (metal / keyPrice)).toFixed(2) const formattedKeys = +(keys + (metal / keyPrice)).toFixed(2)
var output: string = '' var output: string = ''
@@ -16,13 +15,6 @@ export function formatPrice(keys: number, metal: number, keyPrice: number, local
} else { } else {
output += $T("%@ ref").replace('%@', (+toFixed(metal, 2)).toLocaleString(locale)) 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; return output;
} }