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
14 changes: 12 additions & 2 deletions src/data/__tests__/exportService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,13 +187,23 @@ describe('generateReportHTML — currency-aware amounts', () => {
expect(html).toContain(`${kwd.symbol}1.500`);
});

it('applies the user decimal separator (comma)', () => {
it('applies the user number-format profile (comma: dot groups, symbol suffixed)', () => {
const html = generateReportHTML(
2026, 2,
[makeTx({ amount: 200050, type: TransactionType.INCOME })],
wallets, categories, usd, 'comma',
);
expect(html).toContain(`${usd.symbol}2.000,50`);
// The comma profile suffixes the symbol behind a U+00A0 gap.
expect(html).toContain(`2.000,50\u00A0${usd.symbol}`);
});

it('applies the user number-format profile (space: U+00A0 groups, symbol suffixed)', () => {
const html = generateReportHTML(
2026, 2,
[makeTx({ amount: 200050, type: TransactionType.INCOME })],
wallets, categories, usd, 'space',
);
expect(html).toContain(`2\u00A0000,50\u00A0${usd.symbol}`);
});

// DoD-5: totals use the same exponent as the line items (no mixed scaling).
Expand Down
214 changes: 214 additions & 0 deletions src/data/__tests__/numberFormatDerivation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/**
* Number-format profile derivation tests (V-20 / F-14).
*
* Two things are under test and they are not the same thing:
*
* 1. numberFormatForLocale - a pure locale-tag -> profile mapping.
* 2. WHEN that mapping is allowed to run. It runs on first launch and never
* again, so a user's explicit choice can never be overwritten by their
* device locale. That guarantee is the point of the feature, and the second
* half of this file exists to hold it in place.
*/

// ─── Mocks ────────────────────────────────────────────────────────────

jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock')
);

// Mutable so a test can change the device locale between launches. The getter
// means every read of NativeModules picks up the current value rather than a
// value captured when the module was first imported.
const mockDeviceLocale = { value: 'en_US' };

jest.mock('react-native', () => ({
Platform: { OS: 'ios' },
NativeModules: {
SettingsManager: {
get settings() {
return {
AppleLocale: mockDeviceLocale.value,
AppleLanguages: [mockDeviceLocale.value],
};
},
},
},
}));

import AsyncStorage from '@react-native-async-storage/async-storage';
import {
getDefaultSettings,
getInitialSettings,
loadSettings,
updateSetting,
} from '../../data/services/settingsService';
import { getDeviceLocale } from '../../domain/constants/languages';
import { numberFormatForLocale } from '../../domain/constants/numberFormats';

const SETTINGS_KEY = '@valto:settings';

beforeEach(async () => {
await AsyncStorage.clear();
mockDeviceLocale.value = 'en_US';
jest.clearAllMocks();
});

// ─── The pure mapping ─────────────────────────────────────────────────

describe('numberFormatForLocale', () => {
it('maps French locales to the space profile', () => {
expect(numberFormatForLocale('fr')).toBe('space');
expect(numberFormatForLocale('fr-FR')).toBe('space');
expect(numberFormatForLocale('fr_CI')).toBe('space');
expect(numberFormatForLocale('fr-SN')).toBe('space');
});

it('maps English locales to the dot profile', () => {
expect(numberFormatForLocale('en')).toBe('dot');
expect(numberFormatForLocale('en_US')).toBe('dot');
expect(numberFormatForLocale('en-GB')).toBe('dot');
});

it('maps continental European locales to the comma profile', () => {
expect(numberFormatForLocale('de')).toBe('comma');
expect(numberFormatForLocale('es-ES')).toBe('comma');
expect(numberFormatForLocale('pt-BR')).toBe('comma');
expect(numberFormatForLocale('it')).toBe('comma');
});

it('applies region overrides where the language default is wrong', () => {
expect(numberFormatForLocale('es-MX')).toBe('dot');
expect(numberFormatForLocale('es_US')).toBe('dot');
expect(numberFormatForLocale('pt-PT')).toBe('space');
expect(numberFormatForLocale('de-CH')).toBe('dot');
});

it('skips a script subtag when looking for the region', () => {
expect(numberFormatForLocale('zh-Hans-CN')).toBe('dot');
expect(numberFormatForLocale('sr-Latn-RS')).toBe('comma');
});

it('falls back to the dot profile for anything unrecognised', () => {
expect(numberFormatForLocale(null)).toBe('dot');
expect(numberFormatForLocale(undefined)).toBe('dot');
expect(numberFormatForLocale('')).toBe('dot');
expect(numberFormatForLocale('xx-YY')).toBe('dot');
});
});

// ─── Reading the device locale ────────────────────────────────────────

describe('getDeviceLocale', () => {
it('returns the full tag, region included', () => {
mockDeviceLocale.value = 'fr_CI';
expect(getDeviceLocale()).toBe('fr_CI');
});
});

// ─── First launch only ────────────────────────────────────────────────

describe('first-launch profile derivation', () => {
it('derives the space profile on a French device', async () => {
mockDeviceLocale.value = 'fr_FR';
const settings = await loadSettings();
expect(settings.decimalSeparator).toBe('space');
});

it('derives the dot profile on an English device', async () => {
mockDeviceLocale.value = 'en_US';
const settings = await loadSettings();
expect(settings.decimalSeparator).toBe('dot');
});

it('derives from the region, not only the language', async () => {
mockDeviceLocale.value = 'fr_CI';
expect((await loadSettings()).decimalSeparator).toBe('space');

await AsyncStorage.clear();
mockDeviceLocale.value = 'es_MX';
expect((await loadSettings()).decimalSeparator).toBe('dot');
});

it('getInitialSettings is the only entry point that reads the device', () => {
mockDeviceLocale.value = 'fr_FR';
// getDefaultSettings stays static, so nothing that merges over defaults
// can accidentally pull the device locale in.
expect(getDefaultSettings().decimalSeparator).toBe('dot');
expect(getInitialSettings().decimalSeparator).toBe('space');
});
});

// ─── Never again ──────────────────────────────────────────────────────

describe('a stored choice always wins over the device locale', () => {
it('does not overwrite an explicit choice on the next launch', async () => {
// Launch 1 on a French device: derived.
mockDeviceLocale.value = 'fr_FR';
expect((await loadSettings()).decimalSeparator).toBe('space');

// The user disagrees and picks dot.
await updateSetting('decimalSeparator', 'dot');

// Launch 2, same French device: the stored choice stands.
expect((await loadSettings()).decimalSeparator).toBe('dot');

// Launch 3, and a fourth for good measure - still no drift.
expect((await loadSettings()).decimalSeparator).toBe('dot');
expect((await loadSettings()).decimalSeparator).toBe('dot');
});

it('does not re-derive when the device locale changes under a stored choice', async () => {
mockDeviceLocale.value = 'en_US';
await updateSetting('decimalSeparator', 'dot');

// User travels, changes phone language, restores a backup - whatever.
mockDeviceLocale.value = 'fr_FR';
expect((await loadSettings()).decimalSeparator).toBe('dot');
});

it('keeps a stored space choice on an English device', async () => {
mockDeviceLocale.value = 'fr_FR';
await updateSetting('decimalSeparator', 'space');

mockDeviceLocale.value = 'en_US';
expect((await loadSettings()).decimalSeparator).toBe('space');
});

it('does not derive for an existing install whose stored settings predate the field', async () => {
// Storage exists but carries no decimalSeparator. This is NOT a first
// launch, so the static default applies rather than the device locale -
// an upgrade must not silently restyle every amount in the app.
mockDeviceLocale.value = 'fr_FR';
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify({
theme: 'system',
currency: 'XOF',
currencyLocked: true,
notificationsEnabled: false,
language: 'fr',
dateFormat: 'DD/MM/YYYY',
firstDayOfWeek: 'monday',
onboardingCompleted: true,
}));

expect((await loadSettings()).decimalSeparator).toBe('dot');
});

it('sanitises a corrupted profile to the static default, not to the device locale', async () => {
mockDeviceLocale.value = 'fr_FR';
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify({
...getDefaultSettings(),
decimalSeparator: 'semicolon',
}));

expect((await loadSettings()).decimalSeparator).toBe('dot');
});

it('accepts a stored space profile through sanitisation', async () => {
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify({
...getDefaultSettings(),
decimalSeparator: 'space',
}));

expect((await loadSettings()).decimalSeparator).toBe('space');
});
});
7 changes: 6 additions & 1 deletion src/data/__tests__/settingsFeatures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,12 @@ describe('Formatting Utilities', () => {
});

it('formatAmount with comma separator', () => {
expect(formatAmount(200050, '$', 'comma')).toBe('$2.000,50');
// The comma profile suffixes the symbol behind a U+00A0 gap.
expect(formatAmount(200050, '$', 'comma')).toBe('2.000,50\u00A0$');
});

it('formatAmount with space separator', () => {
expect(formatAmount(200050, '$', 'space')).toBe('2\u00A0000,50\u00A0$');
});

it('formatDate DD/MM/YYYY', () => {
Expand Down
34 changes: 29 additions & 5 deletions src/data/services/settingsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
*/

import { DEFAULT_CURRENCY_CODE } from '../../domain/constants/currencies';
import { DEFAULT_LANGUAGE_CODE, getDeviceLanguage, isSupportedLanguage } from '../../domain/constants/languages';
import { DEFAULT_LANGUAGE_CODE, getDeviceLanguage, getDeviceLocale, isSupportedLanguage } from '../../domain/constants/languages';
import { DEFAULT_NUMBER_FORMAT, NUMBER_FORMAT_PROFILES, numberFormatForLocale } from '../../domain/constants/numberFormats';
import { asyncStorageAdapter, StorageKeys } from '../storage';

// ─── Types ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -34,16 +35,37 @@ export function getDefaultSettings(): AppSettings {
language: getDeviceLanguage(),
dateFormat: 'MM/DD/YYYY',
firstDayOfWeek: 'monday',
decimalSeparator: 'dot',
decimalSeparator: DEFAULT_NUMBER_FORMAT,
onboardingCompleted: false,
};
}

/**
* Settings for an install that has never stored any.
*
* This is the ONLY place the device locale reaches the number-format profile, and
* it runs on exactly one condition: the settings storage key is absent, which by
* definition is first launch. Every later load takes the merge path in
* loadSettings, where a stored `decimalSeparator` overrides the default, so a
* user's explicit choice can never be overwritten by their device locale - not on
* a language change, not on a device migration, not on an app update.
*
* Deliberately NOT used by the catch path in loadSettings: a transient storage
* read failure is not a first launch, and re-deriving there could flip a chosen
* format on a bad read.
*/
export function getInitialSettings(): AppSettings {
return {
...getDefaultSettings(),
decimalSeparator: numberFormatForLocale(getDeviceLocale()),
};
}

// ─── Validation Helpers ───────────────────────────────────────────────

const VALID_DATE_FORMATS: DateFormatPreference[] = ['DD/MM/YYYY', 'MM/DD/YYYY', 'YYYY-MM-DD'];
const VALID_FIRST_DAYS: FirstDayOfWeek[] = ['monday', 'sunday'];
const VALID_DECIMAL_SEPS: DecimalSeparator[] = ['dot', 'comma'];
const VALID_DECIMAL_SEPS: DecimalSeparator[] = NUMBER_FORMAT_PROFILES;

// ─── Load ─────────────────────────────────────────────────────────────

Expand All @@ -56,7 +78,9 @@ export async function loadSettings(): Promise<AppSettings> {
try {
const stored = await asyncStorageAdapter.get<Partial<AppSettings>>(StorageKeys.SETTINGS);
if (!stored) {
return getDefaultSettings();
// Nothing has ever been persisted: first launch, and the only moment
// the device locale is allowed to choose the number-format profile.
return getInitialSettings();
}
const defaults = getDefaultSettings();
const merged = { ...defaults, ...stored };
Expand Down Expand Up @@ -87,7 +111,7 @@ export async function loadSettings(): Promise<AppSettings> {
merged.firstDayOfWeek = 'monday';
}
if (!VALID_DECIMAL_SEPS.includes(merged.decimalSeparator)) {
merged.decimalSeparator = 'dot';
merged.decimalSeparator = DEFAULT_NUMBER_FORMAT;
}

// Validate onboardingCompleted
Expand Down
11 changes: 10 additions & 1 deletion src/domain/__tests__/validators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,16 @@ describe('validateSettings', () => {
});

it('throws for invalid decimalSeparator', () => {
expect(() => validateSettings({ ...validSettings, decimalSeparator: 'space' as any }))
expect(() => validateSettings({ ...validSettings, decimalSeparator: 'semicolon' as any }))
.toThrow(ValidationError);
});

it('accepts every number-format profile', () => {
// 'space' became a real profile (grouping U+00A0, comma decimal, suffixed
// symbol), so it must validate rather than throw.
for (const profile of ['dot', 'comma', 'space'] as const) {
expect(() => validateSettings({ ...validSettings, decimalSeparator: profile }))
.not.toThrow();
}
});
});
2 changes: 1 addition & 1 deletion src/domain/constants/currencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export const SUPPORTED_CURRENCIES: CurrencyDefinition[] = [
{ code: 'WST', symbol: 'WS$', name: 'Samoan Tala', decimals: 2 },
{ code: 'XAF', symbol: 'FCFA', name: 'Central African CFA Franc', decimals: 0 },
{ code: 'XCD', symbol: 'EC$', name: 'East Caribbean Dollar', decimals: 2 },
{ code: 'XOF', symbol: 'CFA', name: 'West African CFA Franc', decimals: 0 },
{ code: 'XOF', symbol: 'FCFA', name: 'West African CFA Franc', decimals: 0 },
{ code: 'XPF', symbol: '₣', name: 'CFP Franc', decimals: 0 },
{ code: 'YER', symbol: '﷼', name: 'Yemeni Rial', decimals: 2 },
{ code: 'ZAR', symbol: 'R', name: 'South African Rand', decimals: 2 },
Expand Down
Loading
Loading