diff --git a/apps/desktop-tauri/src-tauri/src/commands/currency.rs b/apps/desktop-tauri/src-tauri/src/commands/currency.rs index 6ea6956a4e5..3f508c59819 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/currency.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/currency.rs @@ -1,8 +1,7 @@ use std::{collections::HashMap, path::PathBuf}; use codexbar::currency::{ - PreferredCurrency, SUPPORTED_CURRENCY_CODES, convert_amount, fetch_exchange_rates, - sanitize_rates, + CURRENCIES, PreferredCurrency, convert_amount, fetch_exchange_rates, sanitize_rates, }; use serde::{Deserialize, Serialize}; use tauri::State; @@ -91,7 +90,7 @@ pub async fn get_currency_rates( Ok(CurrencyRatesSnapshot { rates: state.rates.clone(), - supported_codes: SUPPORTED_CURRENCY_CODES.to_vec(), + supported_codes: CURRENCIES.iter().map(|c| c.code).collect(), preferred_code: preferred.raw(), }) } @@ -184,16 +183,15 @@ fn persist_rates(cached: &PersistedRates) { #[cfg(test)] mod tests { use super::*; - use codexbar::currency::FALLBACK_RATES; #[test] fn fallback_table_covers_every_preferred_currency() { let rates = codexbar::currency::fallback_rates(); - assert_eq!(rates.len(), FALLBACK_RATES.len()); - for code in SUPPORTED_CURRENCY_CODES { + assert_eq!(rates.len(), CURRENCIES.len()); + for currency in CURRENCIES { assert!( rates - .get(*code) + .get(currency.code) .is_some_and(|rate| rate.is_finite() && *rate > 0.0) ); } @@ -222,7 +220,7 @@ mod tests { let rates = merged_rates(Some(&cached)); assert_eq!(rates.get("TRY"), Some(&40.0)); // Unsupported codes in the cache never widen the table. - assert_eq!(rates.len(), FALLBACK_RATES.len()); + assert_eq!(rates.len(), CURRENCIES.len()); } #[test] diff --git a/apps/desktop-tauri/src/lib/currency.test.ts b/apps/desktop-tauri/src/lib/currency.test.ts index 1f231474fd8..12e8a5e38f6 100644 --- a/apps/desktop-tauri/src/lib/currency.test.ts +++ b/apps/desktop-tauri/src/lib/currency.test.ts @@ -8,6 +8,19 @@ const RATES: Record = { GBP: 0.79, EUR: 0.92, TRY: 48.5, + // The twelve 0.67.0 additions (upstream fallback rates, 2026-09-24). + NZD: 1.761, + SEK: 9.908, + NOK: 9.48, + DKK: 6.554, + PLN: 3.838, + BRL: 5.117, + MXN: 17.47, + ZAR: 16.36, + THB: 33.37, + IDR: 17836, + VND: 25962, + UAH: 44.86, }; describe("preferred currency display", () => { @@ -17,6 +30,29 @@ describe("preferred currency display", () => { expect(display).toMatch(/485/); }); + it("converts each added currency through the USD pivot", () => { + for (const code of ["NZD", "SEK", "NOK", "DKK", "PLN", "BRL", "MXN", "ZAR", "THB", "IDR", "VND", "UAH"]) { + const rate = RATES[code]; + // The rendered string carries the Intl symbol prefix (e.g. "NZ$17.61", + // "17 618 Rp"), so assert on the exact converted digits. + const expected = new Intl.NumberFormat(undefined, { + style: "currency", + currency: code, + }).format(10 * rate); + expect(formatDisplayCurrency(10, "USD", code, RATES)).toBe(expected); + // Round trip back through the pivot. + expect(formatDisplayCurrency(10 * rate, code, "USD", RATES)).toMatch(/10(\.00)?$/); + } + expect(formatDisplayCurrency(10, "EUR", "SEK", RATES)).toMatch(/107\.7/); + }); + + it("formats zero-decimal currencies without fractional units", () => { + const vnd = formatDisplayCurrency(0.01, "USD", "VND", RATES); + expect(vnd).toMatch(/260/); + expect(vnd).not.toMatch(/[.,]\d{1,2}\D*$/); + expect(formatDisplayCurrency(1, "USD", "JPY", { ...RATES, JPY: 154 })).toMatch(/154(?![.,]\d)/); + }); + it("keeps AUTO, credits, unknown units, and missing-rate values in source units", () => { expect(formatDisplayCurrency(4.25, "USD", "AUTO", RATES)).toMatch(/4\.25/); expect(formatDisplayCurrency(4.25, "Credits", "TRY", RATES)).toBe("4.25 Credits"); @@ -54,4 +90,20 @@ describe("preferred currency display", () => { expect(result.included).toBe(1); expect(result.total).toBe(10); }); + + it("sums a row in each added currency into the TRY total", () => { + const result = sumDisplayCurrencyAmounts( + [ + { amount: 10, currency: "NZD" }, + { amount: 10, currency: "SEK" }, + { amount: 10, currency: "UAH" }, + ], + "TRY", + RATES, + ); + expect(result.included).toBe(3); + expect(result.considered).toBe(3); + const expected = ((10 / 1.761) + (10 / 9.908) + (10 / 44.86)) * 48.5; + expect(result.total).toBeCloseTo(expected, 6); + }); }); diff --git a/apps/desktop-tauri/src/lib/currency.ts b/apps/desktop-tauri/src/lib/currency.ts index 34d40315ced..110744de74e 100644 --- a/apps/desktop-tauri/src/lib/currency.ts +++ b/apps/desktop-tauri/src/lib/currency.ts @@ -1,16 +1,36 @@ -/** - * Preferred display-currency formatting for the web UI. - * - * Rust owns the currency model (supported codes, fallback rates, conversion, - * rate sanitizing — see `codexbar::currency`); `get_currency_rates` returns - * the merged, sanitized table plus the normalized preference, so this module - * only renders strings through `Intl`. - */ +import { CURRENCY_CATALOG } from "./currencyCatalog.generated"; -export interface CurrencyRatesSnapshot { - rates: Record; - supportedCodes: readonly string[]; - preferredCode: string; +// The catalog (order, symbols, offline rates) is generated from rust/src/currency.rs. +export const SUPPORTED_CURRENCIES: readonly string[] = CURRENCY_CATALOG.map((entry) => entry.code); + +export const FALLBACK_CURRENCY_RATES: Record = Object.fromEntries( + CURRENCY_CATALOG.map((entry) => [entry.code, entry.fallbackRate]), +); + +export const CURRENCY_PICKER_OPTIONS: ReadonlyArray<{ value: string; label: string }> = + CURRENCY_CATALOG.map((entry) => ({ value: entry.code, label: `${entry.code} (${entry.symbol})` })); + +export function normalizePreferredCurrency(value: string | null | undefined): string { + const code = value?.trim().toUpperCase() || "AUTO"; + return code === "AUTO" || SUPPORTED_CURRENCIES.includes(code) ? code : "AUTO"; +} + +export function convertCurrencyAmount( + amount: number, + sourceCode: string, + targetCode: string, + rates: Record, +): number | null { + if (!Number.isFinite(amount)) return null; + const source = sourceCode.trim().toUpperCase(); + const target = targetCode.trim().toUpperCase(); + if (!SUPPORTED_CURRENCIES.includes(source) || !SUPPORTED_CURRENCIES.includes(target)) return null; + if (source === target) return amount; + const sourceRate = source === "USD" ? 1 : rates[source]; + const targetRate = target === "USD" ? 1 : rates[target]; + if (!Number.isFinite(sourceRate) || sourceRate <= 0 || !Number.isFinite(targetRate) || targetRate <= 0) return null; + const result = (amount / sourceRate) * targetRate; + return Number.isFinite(result) ? result : null; } function formatOriginal(amount: number, code: string, symbol?: string | null): string { @@ -34,35 +54,37 @@ export function formatDisplayCurrency( const trimmedSource = sourceCode.trim(); const source = trimmedSource.toUpperCase(); const sourceLabel = /^[A-Za-z]{3}$/.test(trimmedSource) ? source : trimmedSource; - const preferred = preferredCode.trim().toUpperCase() || "AUTO"; + const preferred = normalizePreferredCurrency(preferredCode); if (preferred === "AUTO") return formatOriginal(amount, sourceLabel, sourceSymbol); - if (preferred === source) return formatOriginal(amount, sourceLabel, sourceSymbol); - const sourceRate = source === "USD" ? 1 : rates[source]; - const targetRate = preferred === "USD" ? 1 : rates[preferred]; - const usable = - Number.isFinite(amount) && - Number.isFinite(sourceRate) && sourceRate! > 0 && - Number.isFinite(targetRate) && targetRate! > 0; - if (!usable) return formatOriginal(amount, sourceLabel, sourceSymbol); - const converted = (amount / sourceRate!) * targetRate!; - if (!Number.isFinite(converted)) return formatOriginal(amount, sourceLabel, sourceSymbol); + const converted = convertCurrencyAmount(amount, source, preferred, rates); + if (converted == null) return formatOriginal(amount, sourceLabel, sourceSymbol); try { return new Intl.NumberFormat(undefined, { style: "currency", currency: preferred, - maximumFractionDigits: 2, }).format(converted); } catch { return `${converted.toFixed(2)} ${preferred}`; } } +export function mergeValidCurrencyRates(input: Record): Record { + const rates = { ...FALLBACK_CURRENCY_RATES }; + for (const code of SUPPORTED_CURRENCIES) { + const value = input[code]; + if (Number.isFinite(value) && value > 0 && (code !== "USD" || Math.abs(value - 1) <= Number.EPSILON)) { + rates[code] = value; + } + } + return rates; +} + export function sumDisplayCurrencyAmounts( rows: Array<{ amount: number | null | undefined; currency: string }>, preferredCode: string, rates: Record, ): { total: number | null; included: number; considered: number } { - const target = preferredCode.trim().toUpperCase() || "AUTO"; + const target = normalizePreferredCurrency(preferredCode); let total = 0; let included = 0; for (const row of rows) { @@ -71,14 +93,7 @@ export function sumDisplayCurrencyAmounts( if (target === "AUTO") { amount = row.currency.trim().toUpperCase() === "USD" ? row.amount : null; } else { - const source = row.currency.trim().toUpperCase() || "USD"; - const sourceRate = source === "USD" ? 1 : rates[source]; - const targetRate = target === "USD" ? 1 : rates[target]; - const usable = - Number.isFinite(sourceRate) && sourceRate! > 0 && - Number.isFinite(targetRate) && targetRate! > 0; - amount = usable ? (row.amount / sourceRate!) * targetRate! : null; - if (amount != null && !Number.isFinite(amount)) amount = null; + amount = convertCurrencyAmount(row.amount, row.currency || "USD", target, rates); } if (amount == null) continue; total += amount; diff --git a/apps/desktop-tauri/src/lib/currencyCatalog.generated.ts b/apps/desktop-tauri/src/lib/currencyCatalog.generated.ts new file mode 100644 index 00000000000..8ca3e7089fa --- /dev/null +++ b/apps/desktop-tauri/src/lib/currencyCatalog.generated.ts @@ -0,0 +1,32 @@ +// Generated from rust/src/currency.rs (CURRENCIES). Do not edit by hand. +// Regenerate (PowerShell): $env:UPDATE_CURRENCY_CATALOG = '1'; cargo test -p codexbar currency_catalog +export const CURRENCY_CATALOG = [ + { code: "USD", symbol: "$", fallbackRate: 1 }, + { code: "GBP", symbol: "£", fallbackRate: 0.79 }, + { code: "EUR", symbol: "€", fallbackRate: 0.92 }, + { code: "CZK", symbol: "Kč", fallbackRate: 21 }, + { code: "CNY", symbol: "¥", fallbackRate: 7.27 }, + { code: "JPY", symbol: "¥", fallbackRate: 154 }, + { code: "KRW", symbol: "₩", fallbackRate: 1428.9 }, + { code: "CAD", symbol: "$", fallbackRate: 1.38 }, + { code: "AUD", symbol: "$", fallbackRate: 1.55 }, + { code: "HKD", symbol: "$", fallbackRate: 7.8 }, + { code: "TWD", symbol: "NT$", fallbackRate: 32.3 }, + { code: "SGD", symbol: "$", fallbackRate: 1.34 }, + { code: "INR", symbol: "₹", fallbackRate: 84.5 }, + { code: "CHF", symbol: "Fr.", fallbackRate: 0.8 }, + { code: "AED", symbol: "د.إ", fallbackRate: 3.6725 }, + { code: "TRY", symbol: "₺", fallbackRate: 48.5 }, + { code: "NZD", symbol: "$", fallbackRate: 1.761 }, + { code: "SEK", symbol: "kr", fallbackRate: 9.908 }, + { code: "NOK", symbol: "kr", fallbackRate: 9.48 }, + { code: "DKK", symbol: "kr", fallbackRate: 6.554 }, + { code: "PLN", symbol: "zł", fallbackRate: 3.838 }, + { code: "BRL", symbol: "R$", fallbackRate: 5.117 }, + { code: "MXN", symbol: "$", fallbackRate: 17.47 }, + { code: "ZAR", symbol: "R", fallbackRate: 16.36 }, + { code: "THB", symbol: "฿", fallbackRate: 33.37 }, + { code: "IDR", symbol: "Rp", fallbackRate: 17836 }, + { code: "VND", symbol: "₫", fallbackRate: 25962 }, + { code: "UAH", symbol: "₴", fallbackRate: 44.86 }, +] as const; diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx index d17c651de3a..37b77c7d1b0 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx @@ -309,11 +309,16 @@ describe("GeneralTab language picker", () => { }); describe("GeneralTab preferred currency picker", () => { - it("offers AUTO and every supported currency with the TRY symbol", () => { + it("offers AUTO then every catalog currency as CODE (symbol) in catalog order", () => { render(); const select = screen.getByLabelText("PreferredCurrencyLabel"); const options = Array.from(select.querySelectorAll("option"), (option) => option.textContent); - expect(options).toEqual(["AUTO", "USD", "GBP", "EUR", "CZK", "CNY", "JPY", "KRW", "CAD", "AUD", "HKD", "TWD", "SGD", "INR", "CHF", "AED", "TRY (₺)"]); + expect(options).toEqual([ + "AUTO", "USD ($)", "GBP (£)", "EUR (€)", "CZK (Kč)", "CNY (¥)", "JPY (¥)", "KRW (₩)", + "CAD ($)", "AUD ($)", "HKD ($)", "TWD (NT$)", "SGD ($)", "INR (₹)", "CHF (Fr.)", + "AED (د.إ)", "TRY (₺)", "NZD ($)", "SEK (kr)", "NOK (kr)", "DKK (kr)", "PLN (zł)", + "BRL (R$)", "MXN ($)", "ZAR (R)", "THB (฿)", "IDR (Rp)", "VND (₫)", "UAH (₴)", + ]); }); it("persists an explicitly selected preferred currency", () => { diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx index bb3c84342fb..80391eac54c 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx @@ -14,6 +14,7 @@ import type { UsageThresholdOverride, } from "../../../types/bridge"; import type { LocaleKey } from "../../../i18n/keys"; +import { CURRENCY_PICKER_OPTIONS } from "../../../lib/currency"; import type { TabProps } from "../settingsTabs"; const FALLBACK_LANGUAGE_OPTIONS: LanguageOption[] = [ @@ -27,13 +28,6 @@ const FALLBACK_LANGUAGE_OPTIONS: LanguageOption[] = [ { value: "turkish", display: "Türkçe" }, ]; -// Mirror of the Rust `SUPPORTED_CURRENCY_CODES` list for the first render; -// the backend list stays the source of truth (`get_currency_rates` returns it). -const SUPPORTED_CURRENCY_CODES_FALLBACK: string[] = [ - "USD", "GBP", "EUR", "CZK", "CNY", "JPY", "KRW", "CAD", "AUD", "HKD", "TWD", "SGD", - "INR", "CHF", "AED", "TRY", -]; - const REFRESH_CADENCE_OPTIONS: { value: string; labelKey: LocaleKey }[] = [ { value: "0", labelKey: "RefreshIntervalManual" }, { value: "adaptive", labelKey: "RefreshIntervalAdaptive" }, @@ -199,10 +193,6 @@ export default function GeneralTab({ const [languageOptions, setLanguageOptions] = useState( FALLBACK_LANGUAGE_OPTIONS, ); - // The picker lists the backend-supported codes; this static mirror only - // covers the first render before `getCurrencyRates` resolves (the Rust - // `SUPPORTED_CURRENCY_CODES` list stays the source of truth). - const [currencyOptions] = useState(SUPPORTED_CURRENCY_CODES_FALLBACK); useEffect(() => { invoke("get_available_languages") @@ -284,10 +274,7 @@ export default function GeneralTab({ ariaLabel={t("PreferredCurrencyLabel")} options={[ { value: "AUTO", label: "AUTO" }, - ...currencyOptions.map((code) => ({ - value: code, - label: code === "TRY" ? "TRY (₺)" : code, - })), + ...CURRENCY_PICKER_OPTIONS, ]} onChange={(value) => set({ preferredCurrencyCode: value })} /> diff --git a/rust/src/currency.rs b/rust/src/currency.rs index 8c1017b6917..081290938d9 100644 --- a/rust/src/currency.rs +++ b/rust/src/currency.rs @@ -3,30 +3,84 @@ use std::collections::HashMap; use std::time::Duration; -pub const SUPPORTED_CURRENCY_CODES: &[&str] = &[ - "USD", "GBP", "EUR", "CZK", "CNY", "JPY", "KRW", "CAD", "AUD", "HKD", "TWD", "SGD", "INR", - "CHF", "AED", "TRY", -]; +/// One entry of the preferred-display-currency catalog. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CurrencyInfo { + pub code: &'static str, + pub symbol: &'static str, + /// Offline USD-pivot rate used until live rates are available. + pub fallback_rate: f64, +} + +const fn currency(code: &'static str, symbol: &'static str, fallback_rate: f64) -> CurrencyInfo { + CurrencyInfo { + code, + symbol, + fallback_rate, + } +} -pub const FALLBACK_RATES: &[(&str, f64)] = &[ - ("USD", 1.0), - ("GBP", 0.79), - ("EUR", 0.92), - ("CZK", 21.0), - ("CNY", 7.27), - ("JPY", 154.0), - ("KRW", 1428.90), - ("CAD", 1.38), - ("AUD", 1.55), - ("HKD", 7.80), - ("TWD", 32.30), - ("SGD", 1.34), - ("INR", 84.50), - ("CHF", 0.80), - ("AED", 3.6725), - ("TRY", 48.5), +/// Single source of truth for picker order, picker symbols, and offline rates. +/// The frontend copy (`apps/desktop-tauri/src/lib/currencyCatalog.generated.ts`) +/// is checked against this table by the catalog sync test. +pub const CURRENCIES: &[CurrencyInfo] = &[ + currency("USD", "$", 1.0), + currency("GBP", "£", 0.79), + currency("EUR", "€", 0.92), + currency("CZK", "Kč", 21.0), + currency("CNY", "¥", 7.27), + currency("JPY", "¥", 154.0), + currency("KRW", "₩", 1428.90), + currency("CAD", "$", 1.38), + currency("AUD", "$", 1.55), + currency("HKD", "$", 7.80), + currency("TWD", "NT$", 32.30), + currency("SGD", "$", 1.34), + currency("INR", "₹", 84.50), + currency("CHF", "Fr.", 0.80), + currency("AED", "د.إ", 3.6725), + currency("TRY", "₺", 48.5), + // Rates below are from open.er-api.com on 2026-09-24. + currency("NZD", "$", 1.761), + currency("SEK", "kr", 9.908), + currency("NOK", "kr", 9.480), + currency("DKK", "kr", 6.554), + currency("PLN", "zł", 3.838), + currency("BRL", "R$", 5.117), + currency("MXN", "$", 17.47), + currency("ZAR", "R", 16.36), + currency("THB", "฿", 33.37), + currency("IDR", "Rp", 17836.0), + currency("VND", "₫", 25962.0), + currency("UAH", "₴", 44.86), ]; +pub fn is_supported_currency(code: &str) -> bool { + CURRENCIES.iter().any(|currency| currency.code == code) +} + +/// Renders the catalog as the TypeScript module the settings picker and +/// offline converter import, so both sides share this table. This is only +/// needed by the sync test and the opt-in regeneration path. +#[cfg(test)] +fn render_typescript_catalog() -> String { + let mut out = String::from( + "// Generated from rust/src/currency.rs (CURRENCIES). Do not edit by hand.\n\ + // Regenerate (PowerShell): $env:UPDATE_CURRENCY_CATALOG = '1'; cargo test -p codexbar currency_catalog\n\ + export const CURRENCY_CATALOG = [\n", + ); + for currency in CURRENCIES { + let code = serde_json::to_string(currency.code).expect("currency codes serialize"); + let symbol = serde_json::to_string(currency.symbol).expect("currency symbols serialize"); + out.push_str(&format!( + " {{ code: {code}, symbol: {symbol}, fallbackRate: {} }},\n", + currency.fallback_rate + )); + } + out.push_str("] as const;\n"); + out +} + /// The normalized display-currency preference. `AUTO` keeps every surface in /// its source units; `Code` converts through the USD pivot. Serialized as the /// same `AUTO` / ISO-code strings the settings file has always used. @@ -45,9 +99,9 @@ impl PreferredCurrency { return Self::Auto; } Self::Code( - SUPPORTED_CURRENCY_CODES + CURRENCIES .iter() - .copied() + .map(|currency| currency.code) .find(|supported| *supported == code) .unwrap_or("USD"), ) @@ -68,7 +122,7 @@ impl PreferredCurrency { pub fn normalize_preferred_currency(value: &str) -> String { let code = value.trim().to_ascii_uppercase(); - if code == "AUTO" || SUPPORTED_CURRENCY_CODES.contains(&code.as_str()) { + if code == "AUTO" || is_supported_currency(&code) { code } else { "AUTO".to_string() @@ -80,12 +134,12 @@ pub fn normalize_preferred_currency(value: &str) -> String { /// without a correct USD pivot is unusable for conversion. pub fn sanitize_rates(rates: &HashMap) -> HashMap { let mut clean = HashMap::new(); - for code in SUPPORTED_CURRENCY_CODES { - if let Some(rate) = rates.get(*code).copied() + for currency in CURRENCIES { + if let Some(rate) = rates.get(currency.code).copied() && rate.is_finite() && rate > 0.0 { - clean.insert((*code).to_string(), rate); + clean.insert(currency.code.to_string(), rate); } } if clean @@ -98,9 +152,9 @@ pub fn sanitize_rates(rates: &HashMap) -> HashMap { } pub fn fallback_rates() -> HashMap { - FALLBACK_RATES + CURRENCIES .iter() - .map(|(code, rate)| ((*code).to_string(), *rate)) + .map(|currency| (currency.code.to_string(), currency.fallback_rate)) .collect() } @@ -113,12 +167,12 @@ pub fn parse_exchange_rates(payload: &[u8]) -> Option> { } let rates = value.get("rates")?.as_object()?; let mut parsed = HashMap::new(); - for code in SUPPORTED_CURRENCY_CODES { - let Some(rate) = rates.get(*code).and_then(serde_json::Value::as_f64) else { + for currency in CURRENCIES { + let Some(rate) = rates.get(currency.code).and_then(serde_json::Value::as_f64) else { continue; }; if rate.is_finite() && rate > 0.0 { - parsed.insert((*code).to_string(), rate); + parsed.insert(currency.code.to_string(), rate); } } if parsed @@ -143,9 +197,7 @@ pub fn convert_amount( } let source = source_code.trim().to_ascii_uppercase(); let target = target_code.trim().to_ascii_uppercase(); - if !SUPPORTED_CURRENCY_CODES.contains(&source.as_str()) - || !SUPPORTED_CURRENCY_CODES.contains(&target.as_str()) - { + if !is_supported_currency(&source) || !is_supported_currency(&target) { return None; } if source == target { @@ -275,4 +327,76 @@ mod tests { malformed.insert("TRY".into(), f64::NAN); assert_eq!(convert_amount(12.0, "USD", "TRY", &malformed), None); } + + const UPSTREAM_ADDED: &[(&str, &str, f64)] = &[ + ("NZD", "$", 1.761), + ("SEK", "kr", 9.908), + ("NOK", "kr", 9.480), + ("DKK", "kr", 6.554), + ("PLN", "zł", 3.838), + ("BRL", "R$", 5.117), + ("MXN", "$", 17.47), + ("ZAR", "R", 16.36), + ("THB", "฿", 33.37), + ("IDR", "Rp", 17836.0), + ("VND", "₫", 25962.0), + ("UAH", "₴", 44.86), + ]; + + #[test] + fn added_currencies_follow_try_in_upstream_order_with_upstream_rates() { + let codes: Vec<&str> = CURRENCIES.iter().map(|currency| currency.code).collect(); + let try_index = codes.iter().position(|code| *code == "TRY").unwrap(); + assert_eq!(codes.len(), try_index + 1 + UPSTREAM_ADDED.len()); + for (offset, (code, symbol, rate)) in UPSTREAM_ADDED.iter().enumerate() { + let entry = &CURRENCIES[try_index + 1 + offset]; + assert_eq!( + (entry.code, entry.symbol, entry.fallback_rate), + (*code, *symbol, *rate) + ); + } + } + + #[test] + fn added_currencies_convert_through_the_usd_pivot_and_normalize() { + let rates = fallback_rates(); + assert_eq!(rates.len(), CURRENCIES.len()); + for (code, _, rate) in UPSTREAM_ADDED { + assert_eq!(normalize_preferred_currency(&code.to_lowercase()), *code); + let from_usd = convert_amount(10.0, "USD", code, &rates).unwrap(); + assert!((from_usd - 10.0 * rate).abs() < 1e-9, "{code}"); + let back = convert_amount(from_usd, code, "USD", &rates).unwrap(); + assert!((back - 10.0).abs() < 1e-9, "{code}"); + } + let cross = convert_amount(10.0, "EUR", "SEK", &rates).unwrap(); + assert!((cross - (10.0 / 0.92) * 9.908).abs() < 1e-9); + } + + #[test] + fn live_rate_parsing_keeps_added_currencies() { + let rates = parse_exchange_rates( + br#"{"result":"success","base_code":"USD","rates":{"USD":1,"VND":26000,"IDR":17900,"UAH":45,"XXX":3}}"#, + ) + .unwrap(); + assert_eq!(rates.get("VND"), Some(&26000.0)); + assert_eq!(rates.get("IDR"), Some(&17900.0)); + assert_eq!(rates.get("UAH"), Some(&45.0)); + assert!(!rates.contains_key("XXX")); + } + + #[test] + fn currency_catalog_typescript_matches_the_checked_in_module() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../apps/desktop-tauri/src/lib/currencyCatalog.generated.ts"); + let expected = render_typescript_catalog(); + if std::env::var_os("UPDATE_CURRENCY_CATALOG").is_some() { + std::fs::write(&path, &expected).unwrap(); + } + let actual = std::fs::read_to_string(&path).unwrap_or_default(); + assert_eq!( + actual.replace("\r\n", "\n"), + expected, + "set UPDATE_CURRENCY_CATALOG=1, then rerun `cargo test -p codexbar currency_catalog`" + ); + } }