Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 41 additions & 12 deletions Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
let daily = FixedRateClient.quotes(now: now)
var effective = daily
effective["BTC"] = FixedRateClient.coinMetricsQuote(now: now)
effective["ETH"] = FixedRateClient.coinMetricsQuote(now: now, priceUSD: 100)
try FileCoordination.write(at: directory.appendingPathComponent("rates.json")) {
try RateCache(directory: directory)
.save(
Expand All @@ -91,21 +92,45 @@
try seedHistory(
base: "EUR", quote: "XAU", values: [0.009, 0.01],
directory: directory, now: now)
try seedHistory(
base: "BTC", quote: "USD", values: [90_000, 100_000],
directory: directory, now: now)
let end = Date(timeIntervalSince1970: floor(now.timeIntervalSince1970 / 300) * 300)
let intraday = HistorySeries(
for reference in ["USD", "EUR", "CZK", "CHF", "VND"] {
guard let referenceRate = daily[reference], let usd = daily["USD"] else {
throw StartupError.seedVerificationFailed
}
let factor = NSDecimalNumber(decimal: referenceRate.value / usd.value).doubleValue
try seedHistory(
base: "BTC", quote: reference, values: [45_000 * factor, 50_000 * factor],
directory: directory, now: now)
let conversion: RateConversion? =
reference == "USD"
? nil
: .init(
provider: .fawaz, published: FixedRateClient.day(now),
value: referenceRate.value / usd.value, base: "USD", quote: reference)
let intraday = HistorySeries(
points: (0...288)
.map { index in
HistoryPoint(
date: end.addingTimeInterval(Double(index - 288) * 300),
value: (45_000 + Double(index) * 5_000 / 288) * factor)
},
source: .init(
provider: .coinMetrics, observation: .minuteReference, timeZone: .gmt,
conversion: conversion), fetchedAt: end)
try writeHistory(
intraday, filename: "history-v3-BTC-\(reference)-1.json", directory: directory)
}
try seedHistory(
base: "ETH", quote: "BTC", values: [0.0018, 0.002], directory: directory, now: now)
let cryptoPair = HistorySeries(
points: (0...288)
.map { index in
HistoryPoint(
date: end.addingTimeInterval(Double(index - 288) * 300),
value: 45_000 + Double(index) * 5_000 / 288)
value: 0.0018 + Double(index) * 0.0002 / 288)
}, source: .init(provider: .coinMetrics, observation: .minuteReference, timeZone: .gmt),
fetchedAt: end)
try writeHistory(
intraday, filename: "history-v3-BTC-USD-1.json",
directory: directory)
try writeHistory(cryptoPair, filename: "history-v3-ETH-BTC-1.json", directory: directory)
let files = [
"input.json", "onboarding.json", "widget-location-refresh.json",
"widget-location.json", "widget-location-status.json"
Expand Down Expand Up @@ -221,7 +246,10 @@
let assets = items.first { $0.name == "assets" }?.value?.split(separator: ",") ?? []
let end = Date(timeIntervalSince1970: floor(Date.now.timeIntervalSince1970 / 60) * 60)
let rows = assets.map { asset in
["asset": String(asset), "time": end.ISO8601Format(), "ReferenceRateUSD": "50000"]
[
"asset": String(asset), "time": end.ISO8601Format(),
"ReferenceRateUSD": asset == "eth" ? "100" : "50000"
]
}
return try JSONSerialization.data(withJSONObject: ["data": rows])
}
Expand All @@ -244,7 +272,8 @@

static func quotes(now: Date) -> [String: ExchangeRate] {
let values: [String: Decimal] = [
"EUR": 1, "USD": 2, "CHF": 0.5, "CZK": 25, "XAU": 0.01, "BTC": 0.00002
"EUR": 1, "USD": 2, "CHF": 0.5, "CZK": 25, "XAU": 0.01, "BTC": 0.00002,
"ETH": 0.01, "VND": 50_000
]
return values.mapValues {
ExchangeRate(
Expand All @@ -253,9 +282,9 @@
}
}

static func coinMetricsQuote(now: Date) -> ExchangeRate {
static func coinMetricsQuote(now: Date, priceUSD: Decimal = 50_000) -> ExchangeRate {
ExchangeRate(
0.00004, published: day(now),
2 / priceUSD, published: day(now),
source: .init(
provider: .coinMetrics, observation: .minuteReference,
conversion: RateConversion(
Expand Down
8 changes: 5 additions & 3 deletions Apps/Currency/Tests/E2E/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,12 @@ These are representative outcomes, not exhaustive coverage of each feature ID.
| CONV-02–03, CONV-05 | [currency-selection](tests/currency-selection.e2e.ts) | Change source, add/remove currencies, exact amounts and restart |
| CONV-01, HIST-01–04, PREF-04 | [provider-policy](tests/provider-policy.e2e.ts) | Configured Fawaz/Coin Metrics conversion, 24-hour minute-reference provenance, All monthly sources, no spurious USD approximation and retained non-USD daily-FX notices, 1D capability, source credits and saved-cache compatibility after restart |
| HIST-02 | [history-archive](tests/history-archive.e2e.ts) | With the seeded All cache removed, the real bundled BTC/USD history starts in 2010 and exposes monthly source/date information |
| HIST-02, HIST-04, RATE-01 | [fiat-history-live](tests/fiat-history-live.e2e.ts) | Refresh production rates, load complete Frankfurter USD/EUR and AED/EUR years and bundled monthly All archive from 2010, record cold/All/warm times and prove exact yearly cache reuse after relaunch; requires network |
| HIST-02–03, WID-08 | [converted-crypto-history](tests/converted-crypto-history.e2e.ts) | An installed BTC/EUR History widget fetches a complete live day with the app closed and retains its historical USD/EUR rate, provider and publication date; requires network |
| RATE-01–02, HIST-01–04, CONV-05, PREF-04 | [live-app-provider](tests/live-app-provider.e2e.ts) | Disable the fixture network adapter, force-refresh all 22 crypto quotes, inspect the real phone 1D chart and bundled All history from 2010, then relaunch and verify saved input/chart reuse; requires network |
| RATE-01–02, HIST-01–04, CONV-05, PREF-04 | [live-app-provider](tests/live-app-provider.e2e.ts) | Disable the fixture network adapter, force-refresh all 22 crypto quotes, inspect the real phone USD and EUR 1D charts and bundled All history from 2010, then relaunch and verify saved input/chart reuse; requires network |
| HIST-02, WID-08 | [widget-live-provider](tests/widget-live-provider.e2e.ts) | With the app terminated and the seeded 1D cache removed, an installed widget fetches and persists a complete live Coin Metrics day; requires network |
| HIST-01–04 | [converter-details](tests/converter-details.e2e.ts) | Rate, period selection, source explanation and return |
| CONV-02–03, CONV-05, HIST-01–04 | [details-reference](tests/details-reference.e2e.ts) | Bitcoin Details retains EUR/USD/CZK/CHF references and non-USD approximation provenance; ETH/BTC and VND/BTC stay nonzero at exact current and 1M/1D values; selected sources and pairs survive restart |
| PREF-05, ONB-01–02 | [metal-widget-preview](tests/metal-widget-preview.e2e.ts) | Replayed Calculator and Board showcase previews retain the saved grams preference and converted Gold amount |
| PREF-05, WID-04–08 | [metal-widget-display](tests/metal-widget-display.e2e.ts) | Board, Cash, Pocket, Mental Math and History show Gold amounts and visible measurement units |
| PREF-05, WID-02, WID-10 | [metal-widget](tests/metal-widget.e2e.ts) | Saved grams preference reaches Calculator; keypad edits return to app with matching metal conversion |
Expand All @@ -79,9 +81,9 @@ For widget/shared-data changes, run the affected widget and both Calculator shar

## Fixtures, widgets and cache

Start each case once with a matching fixture. `fresh-onboarding` and `ready-converter` cover general flows; `ready-metals` adds Gold as a destination and `ready-metal-source` starts with one troy ounce of Gold for measurement checks. The first launch resets real stores; restart keeps edits. Fixed rates are EUR 1 / USD 2 / CHF 0.5 / CZK 25 / XAU 0.01 troy oz, with versioned reference-history caches for EUR/USD, EUR/Gold and BTC/USD. `ready-crypto` starts USD 1 β†’ Bitcoin: 0.00002 from Coin Metrics, with a distinct 0.00001 Fawaz daily fallback stored alongside it. Restart omits fixture state and preserves the real stores. Keep `-CurrencyE2EState` out of default launch arguments.
Start each case once with a matching fixture. `fresh-onboarding` and `ready-converter` cover general flows; `ready-metals` adds Gold as a destination and `ready-metal-source` starts with one troy ounce of Gold for measurement checks. The first launch resets real stores; restart keeps edits. Fixed rates are EUR 1 / USD 2 / CHF 0.5 / CZK 25 / XAU 0.01 troy oz, with versioned reference-history caches for EUR/USD, EUR/Gold, BTC against USD/EUR/CZK/CHF/VND and ETH/BTC. Ethereum has distinct live/daily fixture quotes; VND exercises tiny crypto reference values. `ready-crypto` starts USD 1 β†’ Bitcoin: 0.00002 from Coin Metrics, with a distinct 0.00001 Fawaz daily fallback stored alongside it. Restart omits fixture state and preserves the real stores. Keep `-CurrencyE2EState` out of default launch arguments.

Run the provider journey with the normal app build. It verifies the standard Coin Metrics current rate, 1D capability, source credits and restart behavior. The fixture cache identity is `currency-fixtures-v9-CM`; rebuild after app or fixture changes.
Run the provider journey with the normal app build. It verifies the standard Coin Metrics current rate, 1D capability, source credits and restart behavior. The fixture cache identity is `currency-fixtures-v10-pairs`; rebuild after app or fixture changes.

```sh
npm run test:e2e -- tests/provider-policy.e2e.ts
Expand Down
2 changes: 1 addition & 1 deletion Apps/Currency/Tests/E2E/e2e.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default {
name: targetName,
engine: mobile({ platform: 'ios', device: udid, session: `currency-e2e-${targetName}` }),
app: {
identity: 'currency-fixtures-v9-CM',
identity: 'currency-fixtures-v10-pairs',
bundleId: 'com.dimasike.currency',
appPath: process.env.CURRENCY_E2E_APP_PATH ?? '../../../../.validation/e2e/DerivedData/Build/Products/Debug-iphonesimulator/Currency.app',
launchArguments: ['-AppleLanguages', '(en)', '-AppleLocale', 'en_US', '-CurrencyE2E'],
Expand Down
115 changes: 115 additions & 0 deletions Apps/Currency/Tests/E2E/tests/details-reference.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { expect } from 'e2e';
import { test } from '../fixtures.js';

const references = [
{ code: 'EUR', name: 'Euro', value: '25,000 EUR' },
{ code: 'USD', name: 'US Dollar', value: '50,000 USD' },
{ code: 'CZK', name: 'Czech Koruna', value: '625,000 CZK' },
{ code: 'CHF', name: 'Swiss Franc', value: '12,500 CHF' },
] as const;

for (const reference of references) {
test(`Bitcoin Details preserves the ${reference.code} converter reference`, { tags: ['core', 'providers'] }, async ({ start, app, agent, screen }) => {
await start('ready-crypto');
if (reference.code !== 'USD') {
await agent.act(`Open the source currency picker. Use its Search field to search ${reference.code}, then select the ${reference.name} result and return to the converter without editing the amount.`);
}
const sourceCurrency = screen.getByTestId('converter.source');
await expect(sourceCurrency).toHaveAccessibleName(new RegExp(reference.name, 'i'));
await expect(screen.getByRole('button', `Edit amount in ${reference.code}`)).toHaveValue('1');

const assertPair = async () => {
await expect(screen.getByTestId('currency.details.rate')).toHaveAccessibleName(`1 BTC in ${reference.code}`);
await expect(screen.getByTestId('currency.details.rate')).toHaveValue(reference.value);
};
await agent.act('Open Details & history for the Bitcoin destination currency.');
await expect(screen.getByTestId('currency.details.BTC')).toBeVisible();
await app.screenshot(`bitcoin-details-${reference.code.toLowerCase()}-opened`);
await assertPair();

const ranges = screen.getByTestId('currency.details.historyRange');
const month = ranges.getByRole('tab', 'One month', { exact: true });
const day = ranges.getByRole('tab', 'One day', { exact: true });
await month.tap();
await expect(month).toBeSelected();
await assertPair();
await expect(screen.getByText(reference.value, { exact: true })).toBeVisible();
await day.tap();
await expect(day).toBeSelected();
await assertPair();
await expect(screen.getByText(reference.value, { exact: true })).toBeVisible();

const source = screen.getByTestId('currency.details.sourceDisclosure')
.filter({ hasText: /Coin Metrics.*minute reference prices/i });
await expect(source).toBeVisible();
if (reference.code !== 'USD') {
await expect(source.filter({ hasText: /approximate conversion/i })).toBeVisible();
}
await source.tap();
await expect(source).toHaveValue('Expanded');
await agent.act('Scroll in Details until the exact text "Crypto history combines daily and monthly reference data with intraday prices when available. Converted prices may be approximate. Historical and current sources can differ." is visible. Use that exact text when scrolling to text. Keep Details open.');
if (reference.code === 'USD') {
await expect(screen.getByText(/Coin Metrics.*minute reference prices.*approximate conversion/i)).toBeHidden();
await expect(screen.getByText(/^Approximate conversion:/i)).toBeHidden();
} else {
await expect(screen.getByText(new RegExp(`^Approximate conversion: 1 USD = .* ${reference.code}`, 'i')).first()).toBeVisible();
}
await app.screenshot(`bitcoin-details-${reference.code.toLowerCase()}-day-source`);

await screen.getByTestId('currency.details.close').tap();
await expect(screen.getByTestId('currency.details.close')).toBeHidden();
await expect(sourceCurrency).toHaveAccessibleName(new RegExp(reference.name, 'i'));
await app.restart();
await expect(sourceCurrency).toHaveAccessibleName(new RegExp(reference.name, 'i'));
await expect(screen.getByRole('button', `Edit amount in ${reference.code}`)).toHaveValue('1');
await agent.act('Open Details & history for the Bitcoin destination currency.');
await expect(screen.getByTestId('currency.details.BTC')).toBeVisible();
await assertPair();
await app.screenshot(`bitcoin-details-${reference.code.toLowerCase()}-after-restart`);
});
}

const bitcoinReferences = [
{ code: 'ETH', name: 'Ethereum', value: '0.002 BTC' },
{ code: 'VND', name: 'Vietnamese Dong', value: '0.0000000008 BTC' },
] as const;

for (const destination of bitcoinReferences) {
test(`${destination.code} Details preserves a nonzero BTC reference`, { tags: ['core', 'providers'] }, async ({ start, app, agent, screen }) => {
await start('ready-crypto');
await agent.act('Open the source currency picker. Use its Search field to search BTC, then select the Bitcoin result and return to the converter without editing the amount.');
await expect(screen.getByTestId('converter.source')).toHaveAccessibleName(/Bitcoin/i);
await expect(screen.getByRole('button', 'Edit amount in BTC')).toHaveValue('1');
await agent.act(`Add ${destination.code} as a destination currency using Add currency and its Search field. Select the ${destination.name} result and finish on the converter. Keep Bitcoin as the source currency.`);
await expect(screen.getByRole('button', new RegExp(`^${destination.name},`, 'i'))).toBeVisible();

const assertPair = async () => {
await expect(screen.getByTestId('currency.details.rate')).toHaveAccessibleName(`1 ${destination.code} in BTC`);
await expect(screen.getByTestId('currency.details.rate')).toHaveValue(destination.value);
};
await agent.act(`Open Details & history for the ${destination.name} destination currency.`);
await expect(screen.getByTestId(`currency.details.${destination.code}`)).toBeVisible();
await app.screenshot(`${destination.code.toLowerCase()}-details-btc-opened`);
await assertPair();
const ranges = screen.getByTestId('currency.details.historyRange');
for (const title of ['One month', 'One day']) {
const range = ranges.getByRole('tab', title, { exact: true });
await range.tap();
await expect(range).toBeSelected();
await assertPair();
await expect(screen.getByText(destination.value, { exact: true })).toBeVisible();
}
await app.screenshot(`${destination.code.toLowerCase()}-details-btc-day-nonzero`);
await screen.getByTestId('currency.details.close').tap();
await expect(screen.getByTestId('currency.details.close')).toBeHidden();
if (destination.code === 'ETH') {
await app.restart();
await expect(screen.getByTestId('converter.source')).toHaveAccessibleName(/Bitcoin/i);
await expect(screen.getByRole('button', 'Edit amount in BTC')).toHaveValue('1');
await agent.act('Open Details & history for the Ethereum destination currency.');
await expect(screen.getByTestId('currency.details.ETH')).toBeVisible();
await assertPair();
await app.screenshot('eth-details-btc-after-restart');
}
});
}
Loading
Loading