diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index b69627dd7..0203d33b9 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -111,6 +111,8 @@ "identityCheckProcessDescription": "Als nächstes müssen Sie Ihre Identität verifizieren. Bitte halten Sie Ihren Ausweis bereit und erlauben Sie den Kamerazugriff auf dem Gerät.", "identityCheckRequired": "Identitätsprüfung erforderlich", "imprint": "Impressum", + "invalidAmountFormatDescription": "Bitte geben Sie den Betrag zum Beispiel als 1000 oder 1000,50 ein.", + "invalidAmountFormatTitle": "Betrag nicht lesbar", "kyc": "Eröffnungsprozess", "kycAccountMergeDescription": "Ihre Identität wurde bereits in einem anderen Konto gefunden. Eine Zusammenführungsanfrage wurde erstellt. Bitte bestätigen Sie diese über die E-Mail, die Sie erhalten haben.", "kycAccountMergeTitle": "Kontozusammenführung erforderlich", @@ -465,4 +467,4 @@ "youPay": "Sie bezahlen", "youReceive": "Sie erhalten", "youSell": "Sie verkaufen" -} \ No newline at end of file +} diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 58a7d2b84..8bbfb792e 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -111,6 +111,8 @@ "identityCheckProcessDescription": "Next, you need to verify your identity. Please have your ID ready and allow camera access on your device.", "identityCheckRequired": "Identity check required", "imprint": "Imprint", + "invalidAmountFormatDescription": "Please enter the amount as 1000 or 1000.50, for example.", + "invalidAmountFormatTitle": "Amount is not readable", "kyc": "Onboarding process", "kycAccountMergeDescription": "Your identity was found in another account. A merge request has been created. Please confirm it via the email you received.", "kycAccountMergeTitle": "Account merge required", @@ -465,4 +467,4 @@ "youPay": "You pay", "youReceive": "You receive", "youSell": "You sell" -} \ No newline at end of file +} diff --git a/lib/packages/service/dfx/models/payment/payment_info_error.dart b/lib/packages/service/dfx/models/payment/payment_info_error.dart index 42409bbe6..ccf686d91 100644 --- a/lib/packages/service/dfx/models/payment/payment_info_error.dart +++ b/lib/packages/service/dfx/models/payment/payment_info_error.dart @@ -7,4 +7,5 @@ enum PaymentInfoError { bitboxDisconnected, priceSourceUnavailable, unknown, + invalidAmountFormat, } diff --git a/lib/packages/utils/fiat_amount.dart b/lib/packages/utils/fiat_amount.dart index cf732a473..4070938f3 100644 --- a/lib/packages/utils/fiat_amount.dart +++ b/lib/packages/utils/fiat_amount.dart @@ -7,6 +7,44 @@ double? tryParseFiatAmount(String input) { return double.tryParse(input.replaceAll(',', '.')); } +/// Consecutive groups of three digits with the same separator. +final _sameGrouping = RegExp(r'^(\d+)([.,])(\d{3}(?:\2\d{3})*)$'); + +/// Thousands groups plus 1–2 digits of the other separator. +final _mixedGrouping = RegExp(r'^(\d+)([.,])(\d{3}(?:\2\d{3})*)([.,])(\d{1,2})$'); + +/// Grouping separator to drop from [input], or null if [input] is unchanged. +String? _groupingSeparatorToStrip(String input) { + final same = _sameGrouping.firstMatch(input); + if (same != null) return same[2]!; + final mixed = _mixedGrouping.firstMatch(input); + if (mixed != null && mixed[2] != mixed[4]) return mixed[2]!; + return null; +} + +/// Strips thousands grouping (`1.000` / `1,000` / `1.000.000` → integer). +/// +/// EUR and CHF have two decimal places, so a separator followed by exactly +/// three digits cannot be a decimal. Consecutive groups with the same +/// separator are unambiguous thousands grouping, so a paste of `1.000.000` +/// matches what typing the same characters one by one already produced. +/// Mixed thousands + 1–2 decimal digits of the other separator (`1.000,50` / +/// `1,000.50`) drop the thousands separator and keep the decimal, so paste +/// matches typing. Real decimals (`300,75`) and partials (`1.`, `1.0`, +/// `1.00`) are returned unchanged, as is mixed input that is not uniquely +/// thousands-then-decimal (`1.000,000`, `3,5,7`). +String normalizeFiatInput(String input) { + final separator = _groupingSeparatorToStrip(input); + if (separator == null) return input; + return input.replaceAll(separator, ''); +} + +/// The grouping separator [normalizeFiatInput] strips from [input], or null +/// if [input] is left unchanged. +String? strippedFiatGroupingSeparator(String input) { + return _groupingSeparatorToStrip(input); +} + /// The whole-currency integer the backend charges for the raw [input] the user /// typed (e.g. `300,75` → `301`); empty input counts as zero. int chargedFiatAmount(String input) { diff --git a/lib/screens/buy/buy_page.dart b/lib/screens/buy/buy_page.dart index 2d827bf91..b62def54e 100644 --- a/lib/screens/buy/buy_page.dart +++ b/lib/screens/buy/buy_page.dart @@ -58,7 +58,15 @@ class _BuyViewState extends State { listener: (context, state) { _syncController(_amountController, state.fiatText); _syncController(_resultController, state.sharesText); - context.read().getPaymentInfo( + final paymentInfo = context.read(); + // An empty field is not an amount to quote. Fetching it would + // charge 0 and, while that request is in flight, leave a previous + // Success (and its confirm button) on screen. + if (_amountController.text.isEmpty) { + paymentInfo.clearQuote(); + return; + } + paymentInfo.getPaymentInfo( amount: _amountController.text, currency: state.currency, ); diff --git a/lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart b/lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart index 8fa7ac9a8..b5200767a 100644 --- a/lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart +++ b/lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart @@ -48,7 +48,7 @@ class BuyConverterCubit extends Cubit { } catch (e) { developer.log(e.toString()); if (isClosed || mySeq != _seq) return; - emit(state.copyWith(loading: false)); + emit(state.copyWith(loading: false, sharesText: '')); } }); } @@ -75,7 +75,7 @@ class BuyConverterCubit extends Cubit { } catch (e) { developer.log(e.toString()); if (isClosed || mySeq != _seq) return; - emit(state.copyWith(loading: false)); + emit(state.copyWith(loading: false, fiatText: '')); } }); } @@ -99,7 +99,7 @@ class BuyConverterCubit extends Cubit { } catch (e) { developer.log(e.toString()); if (isClosed || mySeq != _seq) return; - emit(state.copyWith(loading: false)); + emit(state.copyWith(loading: false, sharesText: '')); } } diff --git a/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart b/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart index e97acfa99..c29c7ed04 100644 --- a/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart +++ b/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart @@ -58,10 +58,26 @@ class BuyPaymentInfoCubit extends Cubit { emit(newState); } + /// Drops a landed quote so it cannot be confirmed after the amount it + /// belonged to has disappeared. Cancels an in-flight fetch to keep a + /// later completion from restoring the old Success. + void clearQuote() { + _completer?.cancel(); + if (isClosed) return; + emit(const BuyPaymentInfoInitial()); + } + Future _runGetPaymentInfo(String amount, Currency currency) async { + final int charged; + try { + charged = chargedFiatAmount(amount); + } on FormatException { + return const BuyPaymentInfoFailure(PaymentInfoError.invalidAmountFormat); + } + try { final paymentInfo = await _buyPaymentInfoService.getPaymentInfo( - chargedFiatAmount(amount), + charged, currency: currency, ); diff --git a/lib/screens/buy/widgets/payment_converter.dart b/lib/screens/buy/widgets/payment_converter.dart index 4f9d0f9c3..b0ba7279e 100644 --- a/lib/screens/buy/widgets/payment_converter.dart +++ b/lib/screens/buy/widgets/payment_converter.dart @@ -11,6 +11,7 @@ import 'package:realunit_wallet/screens/buy/cubits/buy_converter/buy_converter_c import 'package:realunit_wallet/setup/di.dart'; import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/styles/currency.dart'; +import 'package:realunit_wallet/widgets/fiat_input_formatter.dart'; class PaymentConverter extends StatefulWidget { const PaymentConverter({ @@ -100,7 +101,7 @@ class _PaymentConverterState extends State { child: TextField( controller: _amountController, keyboardType: const .numberWithOptions(decimal: true), - inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))], + inputFormatters: const [FiatInputFormatter()], decoration: const InputDecoration( border: .none, contentPadding: .symmetric( diff --git a/lib/screens/buy/widgets/payment_information.dart b/lib/screens/buy/widgets/payment_information.dart index dd1908b55..3312fdfa5 100644 --- a/lib/screens/buy/widgets/payment_information.dart +++ b/lib/screens/buy/widgets/payment_information.dart @@ -37,6 +37,11 @@ class PaymentInformation extends StatelessWidget { title: S.of(context).bitboxDisconnectedTitle, description: S.of(context).bitboxDisconnectedDescription, ); + } else if (error == PaymentInfoError.invalidAmountFormat) { + return PaymentActionRequired( + title: S.of(context).invalidAmountFormatTitle, + description: S.of(context).invalidAmountFormatDescription, + ); } else if (error == PaymentInfoError.priceSourceUnavailable || error == PaymentInfoError.unknown) { if (paymentInfoState.message.isEmpty) { diff --git a/lib/screens/sell/cubits/sell_converter/sell_converter_cubit.dart b/lib/screens/sell/cubits/sell_converter/sell_converter_cubit.dart index c9e337ce8..5106006d8 100644 --- a/lib/screens/sell/cubits/sell_converter/sell_converter_cubit.dart +++ b/lib/screens/sell/cubits/sell_converter/sell_converter_cubit.dart @@ -48,7 +48,7 @@ class SellConverterCubit extends Cubit { } catch (e) { developer.log(e.toString()); if (isClosed || mySeq != _seq) return; - emit(state.copyWith(loading: false)); + emit(state.copyWith(loading: false, sharesText: '')); } }); } @@ -75,7 +75,7 @@ class SellConverterCubit extends Cubit { } catch (e) { developer.log(e.toString()); if (isClosed || mySeq != _seq) return; - emit(state.copyWith(loading: false)); + emit(state.copyWith(loading: false, fiatText: '')); } }); } @@ -99,7 +99,7 @@ class SellConverterCubit extends Cubit { } catch (e) { developer.log(e.toString()); if (isClosed || mySeq != _seq) return; - emit(state.copyWith(loading: false)); + emit(state.copyWith(loading: false, fiatText: '')); } } diff --git a/lib/screens/sell/widgets/sell_converter.dart b/lib/screens/sell/widgets/sell_converter.dart index 8d4e064c4..b25e7862f 100644 --- a/lib/screens/sell/widgets/sell_converter.dart +++ b/lib/screens/sell/widgets/sell_converter.dart @@ -14,6 +14,7 @@ import 'package:realunit_wallet/screens/sell/widgets/sell_max_amount_button.dart import 'package:realunit_wallet/setup/di.dart'; import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/styles/currency.dart'; +import 'package:realunit_wallet/widgets/fiat_input_formatter.dart'; class SellConverter extends StatefulWidget { const SellConverter({ @@ -222,7 +223,7 @@ class _SellConverterState extends State { child: TextField( controller: _resultController, keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))], + inputFormatters: const [FiatInputFormatter()], decoration: const InputDecoration( border: InputBorder.none, contentPadding: EdgeInsets.symmetric( diff --git a/lib/widgets/fiat_input_formatter.dart b/lib/widgets/fiat_input_formatter.dart new file mode 100644 index 000000000..482428cdc --- /dev/null +++ b/lib/widgets/fiat_input_formatter.dart @@ -0,0 +1,36 @@ +import 'package:flutter/services.dart'; +import 'package:realunit_wallet/packages/utils/fiat_amount.dart'; + +/// Digits and separators only. A completed thousands group (`1.000` / +/// `1,000` / `1.000.000`) is rewritten to the integer; mixed thousands plus +/// decimal (`1.000,50` / `1,000.50`) keep the decimal; partials (`1.`, +/// `1.0`, `1.00`) stay as typed. +class FiatInputFormatter extends TextInputFormatter { + const FiatInputFormatter(); + + static final _allowedChars = FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]')); + + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + final allowed = _allowedChars.formatEditUpdate(oldValue, newValue); + final separator = strippedFiatGroupingSeparator(allowed.text); + final normalized = normalizeFiatInput(allowed.text); + if (normalized == allowed.text) return allowed; + final cursor = allowed.selection.baseOffset.clamp(0, allowed.text.length); + // Only separators actually dropped that sit before the caret. A global + // length delta also subtracts separators behind it (typing `1` into + // `.000,50` would move the caret to 0 instead of 1). + var removedBefore = 0; + if (separator != null) { + for (var i = 0; i < cursor; i++) { + if (allowed.text[i] == separator) removedBefore++; + } + } + return TextEditingValue( + text: normalized, + selection: TextSelection.collapsed( + offset: (cursor - removedBefore).clamp(0, normalized.length), + ), + ); + } +} diff --git a/test/goldens/screens/buy/buy_golden_test.dart b/test/goldens/screens/buy/buy_golden_test.dart index 25791c972..49b3185c5 100644 --- a/test/goldens/screens/buy/buy_golden_test.dart +++ b/test/goldens/screens/buy/buy_golden_test.dart @@ -270,6 +270,31 @@ void main() { }, ); + goldenTest( + 'invalid amount format failure', + fileName: 'buy_invalid_amount_format', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + when(() => paymentInfoCubit.state).thenReturn( + const BuyPaymentInfoFailure(PaymentInfoError.invalidAmountFormat), + ); + when(() => converterCubit.state).thenReturn( + const BuyConverterState( + // This golden stubs the failure panel and does not run the + // formatter. Same-separator thousands groups and mixed + // thousands + 1–2 decimal digits of the other separator are + // stripped before the parser; a 3-digit mixed tail is not a + // unique thousands-then-decimal, so `1.000,000` is left as + // typed, reaches chargedFiatAmount, and throws FormatException. + fiatText: '1.000,000', + sharesText: '', + currency: Currency.chf, + ), + ); + return wrapForGolden(buildSubject()); + }, + ); + goldenTest( 'price source unavailable failure shows API message', fileName: 'buy_price_source_unavailable', diff --git a/test/goldens/screens/buy/goldens/macos/buy_invalid_amount_format.png b/test/goldens/screens/buy/goldens/macos/buy_invalid_amount_format.png new file mode 100644 index 000000000..e100bd6e5 Binary files /dev/null and b/test/goldens/screens/buy/goldens/macos/buy_invalid_amount_format.png differ diff --git a/test/packages/service/dfx/models/payment/buy_sell_dtos_test.dart b/test/packages/service/dfx/models/payment/buy_sell_dtos_test.dart index 7d8889c71..7288cda44 100644 --- a/test/packages/service/dfx/models/payment/buy_sell_dtos_test.dart +++ b/test/packages/service/dfx/models/payment/buy_sell_dtos_test.dart @@ -182,9 +182,9 @@ void main() { }); group('$PaymentInfoError', () { - test('has the eight documented variants', () { + test('has the nine documented variants', () { // Pin the wire contract — any new variant has to be added intentionally. - expect(PaymentInfoError.values, hasLength(8)); + expect(PaymentInfoError.values, hasLength(9)); expect( PaymentInfoError.values.toSet(), { @@ -196,6 +196,7 @@ void main() { PaymentInfoError.bitboxDisconnected, PaymentInfoError.priceSourceUnavailable, PaymentInfoError.unknown, + PaymentInfoError.invalidAmountFormat, }, ); }); diff --git a/test/packages/utils/fiat_amount_test.dart b/test/packages/utils/fiat_amount_test.dart index dcc2215f8..150397a31 100644 --- a/test/packages/utils/fiat_amount_test.dart +++ b/test/packages/utils/fiat_amount_test.dart @@ -47,6 +47,10 @@ void main() { test('returns null on multi-separator input', () { expect(tryParseFiatAmount('1.300,75'), isNull); + // Consecutive thousands groups are stripped by [normalizeFiatInput] + // before the parser sees them. The parser itself still rejects the + // raw grouped form. + expect(tryParseFiatAmount('1.000.000'), isNull); }); test('returns null on grouping-ambiguous input (separator + 3 digits)', () { @@ -59,4 +63,47 @@ void main() { expect(tryParseFiatAmount('1,50'), 1.5); }); }); + + group('normalizeFiatInput', () { + test('strips a lone thousands group', () { + expect(normalizeFiatInput('1.000'), '1000'); + expect(normalizeFiatInput('1,000'), '1000'); + expect(normalizeFiatInput('10.000'), '10000'); + expect(normalizeFiatInput('10,000'), '10000'); + }); + + test('strips consecutive thousands groups with the same separator', () { + expect(normalizeFiatInput('1.000.000'), '1000000'); + expect(normalizeFiatInput('1,000,000'), '1000000'); + expect(normalizeFiatInput('10.000.000'), '10000000'); + expect(normalizeFiatInput('1000.000.000'), '1000000000'); + }); + + test('strips mixed thousands grouping plus a decimal of the other separator', () { + expect(normalizeFiatInput('1.000,50'), '1000,50'); + expect(normalizeFiatInput('1,000.50'), '1000.50'); + expect(normalizeFiatInput('1.000,5'), '1000,5'); + expect(normalizeFiatInput('1,000.5'), '1000.5'); + expect(normalizeFiatInput('1.300,75'), '1300,75'); + expect(normalizeFiatInput('1.000.000,50'), '1000000,50'); + expect(normalizeFiatInput('1,000,000.50'), '1000000.50'); + }); + + test('leaves every other input unchanged', () { + expect(normalizeFiatInput('300'), '300'); + expect(normalizeFiatInput('300,75'), '300,75'); + expect(normalizeFiatInput('0,5'), '0,5'); + expect(normalizeFiatInput('1.000,000'), '1.000,000'); + expect(normalizeFiatInput('1,000.000'), '1,000.000'); + expect(normalizeFiatInput('1.000.50'), '1.000.50'); + expect(normalizeFiatInput('1,000,50'), '1,000,50'); + expect(normalizeFiatInput('3,5,7'), '3,5,7'); + }); + + test('leaves partial thousands-group input unchanged', () { + expect(normalizeFiatInput('1.'), '1.'); + expect(normalizeFiatInput('1.0'), '1.0'); + expect(normalizeFiatInput('1.00'), '1.00'); + }); + }); } diff --git a/test/screens/buy/buy_amount_change_repro_test.dart b/test/screens/buy/buy_amount_change_repro_test.dart new file mode 100644 index 000000000..3c07d5b1f --- /dev/null +++ b/test/screens/buy/buy_amount_change_repro_test.dart @@ -0,0 +1,993 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/repository/cache_repository.dart'; +import 'package:realunit_wallet/packages/repository/supported_fiat_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_brokerbot_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_price_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/brokerbot/dfx_buy_price_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/brokerbot/dfx_buy_shares_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/buy/buy_payment_info.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_service.dart'; +import 'package:realunit_wallet/packages/service/session_cache.dart'; +import 'package:realunit_wallet/packages/utils/fiat_amount.dart'; +import 'package:realunit_wallet/screens/buy/buy_page.dart'; +import 'package:realunit_wallet/screens/buy/cubits/buy_converter/buy_converter_cubit.dart'; +import 'package:realunit_wallet/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart'; +import 'package:realunit_wallet/screens/buy/widgets/buy_confirm_button.dart'; +import 'package:realunit_wallet/screens/buy/widgets/payment_action_required.dart'; +import 'package:realunit_wallet/styles/currency.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../helper/helper.dart'; + +class MockDfxBrokerbotService extends Mock implements DfxBrokerbotService {} + +class MockRealUnitBuyPaymentInfoService extends Mock + implements RealUnitBuyPaymentInfoService {} + +class MockDfxPriceService extends Mock implements DFXPriceService {} + +class MockApiConfig extends Mock implements ApiConfig {} + +class MockCacheRepository extends Mock implements CacheRepository {} + +class MockSupportedFiatRepository extends Mock implements SupportedFiatRepository {} + +/// Matches [BuyConverterCubit]'s 100 ms debounce, plus a little slack so the +/// timer body (brokerbot call → loading:false → payment-info fetch) can finish. +const _afterDebounce = Duration(milliseconds: 150); + +/// Production-like floor: quotes below this return `AmountTooLow`, and the +/// error payload reports the same value as `minVolume`. +const _minVolume = 100.0; + +void main() { + late MockDfxBrokerbotService brokerbotService; + late MockRealUnitBuyPaymentInfoService paymentInfoService; + + void setupDependencyInjection() { + final getIt = GetIt.instance; + brokerbotService = MockDfxBrokerbotService(); + paymentInfoService = MockRealUnitBuyPaymentInfoService(); + + getIt.registerSingleton( + AppStore(() => MockApiConfig(), SessionCache(MockCacheRepository())), + ); + getIt.registerSingleton(brokerbotService); + getIt.registerSingleton(paymentInfoService); + getIt.registerSingleton(MockDfxPriceService()); + final fiatRepo = MockSupportedFiatRepository(); + when(() => fiatRepo.getBuyable()).thenAnswer((_) async => const [Currency.chf, Currency.eur]); + when(() => fiatRepo.getSellable()).thenAnswer((_) async => const [Currency.chf]); + when(() => fiatRepo.getAll()).thenAnswer((_) async => const [Currency.chf, Currency.eur]); + getIt.registerSingleton(fiatRepo); + } + + setUpAll(() { + registerFallbackValue(Currency.chf); + setupDependencyInjection(); + }); + + tearDownAll(() async => await GetIt.instance.reset()); + + setUp(() { + reset(brokerbotService); + reset(paymentInfoService); + _stubProductionLikeQuotes(brokerbotService, paymentInfoService); + }); + + group('BuyPage amount change (customer reproduction)', () { + testWidgets( + 'character-by-character replace of 300 with 100 does not show the support-contact error', + (tester) async { + await _pumpLoadedBuyPage(tester); + + for (final value in ['30', '3', '', '1', '10', '100']) { + await _enterAmount(tester, value); + } + + _expectHealthyQuote( + tester, + expectedAmount: '100', + reasonPrefix: + 'After deleting 300 digit-by-digit and typing 100, the screen must ' + 'keep a valid quote. Intermediate amounts below the production ' + 'floor of 100 are AmountTooLow; 100 is a valid quote.', + ); + }, + ); + + testWidgets( + 'replacing 300 with 100 in one step does not show the support-contact error', + (tester) async { + await _pumpLoadedBuyPage(tester); + + await _enterAmount(tester, ''); + await _enterAmount(tester, '100'); + + _expectHealthyQuote( + tester, + expectedAmount: '100', + reasonPrefix: + 'After clearing the default 300 and typing 100 in one step, the ' + 'screen must keep a valid quote. The quote API accepts 100.', + ); + }, + ); + + testWidgets( + 'typing 1.000 (thousands grouping) is shown as 1000 and does not show an error', + (tester) async { + await _pumpLoadedBuyPage(tester); + + await _enterAmount(tester, '1.000'); + + final snapshot = _snapshot(tester); + expect( + find.text(S.current.invalidAmountFormatTitle), + findsNothing, + reason: + 'Typing 1.000 must be rewritten to 1000; the ambiguous-amount ' + 'hint must not appear. $snapshot', + ); + expect( + find.text(S.current.invalidAmountFormatDescription), + findsNothing, + reason: + 'Typing 1.000 must be rewritten to 1000; the ambiguous-amount ' + 'description must not appear. $snapshot', + ); + _expectHealthyQuote( + tester, + expectedAmount: '1000', + reasonPrefix: + 'Typing 1.000 must appear in the field as 1000 and keep a valid ' + 'quote. The field normalises the thousands group before the ' + 'parser sees it.', + ); + }, + ); + + testWidgets( + 'typing 1.000 character by character leaves partials alone, then shows 1000', + (tester) async { + await _pumpLoadedBuyPage(tester); + await tester.enterText(_amountField, ''); + await tester.pump(); + + for (final value in ['1', '1.', '1.0', '1.00']) { + await tester.enterText(_amountField, value); + await tester.pump(); + expect( + tester.widget(_amountField).controller!.text, + value, + reason: + 'Intermediate "$value" must stay as typed; the formatter must ' + 'not rewrite partial thousands-group input. ${_snapshot(tester)}', + ); + } + + await tester.enterText(_amountField, '1.000'); + await tester.pump(); + + final afterGroup = tester.widget(_amountField).controller!; + expect( + afterGroup.text, + '1000', + reason: + 'The last character of 1.000 must rewrite the field to 1000. ' + '${_snapshot(tester)}', + ); + expect( + afterGroup.selection, + const TextSelection.collapsed(offset: 4), + reason: + 'After rewriting 1.000 to 1000 the caret must sit at the end ' + 'so further typing appends. ${_snapshot(tester)}', + ); + + for (final value in ['1000.', '1000.0', '1000.00']) { + await tester.enterText(_amountField, value); + await tester.pump(); + expect( + tester.widget(_amountField).controller!.text, + value, + reason: + 'Continuation "$value" must stay as typed. ${_snapshot(tester)}', + ); + } + + await tester.enterText(_amountField, '1000.000'); + await tester.pump(_afterDebounce); + await tester.pump(); + + _expectHealthyQuote( + tester, + expectedAmount: '1000000', + reasonPrefix: + 'Typing .000 after 1000 must appear in the field as 1000000 ' + 'and keep a valid quote.', + ); + expect( + tester.widget(_amountField).controller!.selection, + const TextSelection.collapsed(offset: 7), + reason: + 'After rewriting 1000.000 to 1000000 the caret must sit at ' + 'the end. ${_snapshot(tester)}', + ); + }, + ); + + testWidgets( + 'pasting 1.000.000 in one step is shown as 1000000 and does not show an error', + (tester) async { + await _pumpLoadedBuyPage(tester); + + await tester.enterText(_amountField, '1.000.000'); + await tester.pump(); + + expect( + tester.widget(_amountField).controller!.text, + '1000000', + reason: + 'Pasting 1.000.000 must be rewritten to 1000000 in one step, ' + 'matching character-by-character typing. ${_snapshot(tester)}', + ); + + await tester.pump(_afterDebounce); + await tester.pump(); + + expect( + find.text(S.current.invalidAmountFormatTitle), + findsNothing, + reason: + 'Pasting 1.000.000 must not surface the unreadable-amount hint. ' + '${_snapshot(tester)}', + ); + _expectHealthyQuote( + tester, + expectedAmount: '1000000', + reasonPrefix: + 'Pasting 1.000.000 must appear as 1000000 and keep a valid quote.', + ); + }, + ); + + testWidgets( + 'pasting 1.000,50 is shown as 1000,50 and matches typing the same characters', + (tester) async { + await _pumpLoadedBuyPage(tester); + + await tester.enterText(_amountField, '1.000,50'); + await tester.pump(); + + expect( + tester.widget(_amountField).controller!.text, + '1000,50', + reason: + 'Pasting 1.000,50 must be rewritten to 1000,50 in one step, ' + 'matching character-by-character typing. ${_snapshot(tester)}', + ); + + await tester.pump(_afterDebounce); + await tester.pump(); + + _expectHealthyQuote( + tester, + expectedAmount: '1000,50', + reasonPrefix: + 'Pasting 1.000,50 must appear as 1000,50 and keep a valid quote.', + ); + }, + ); + + testWidgets( + 'typing 1.000,50 character by character ends as 1000,50 with a valid quote', + (tester) async { + await _pumpLoadedBuyPage(tester); + await tester.enterText(_amountField, ''); + await tester.pump(); + + for (final value in ['1', '1.', '1.0', '1.00']) { + await tester.enterText(_amountField, value); + await tester.pump(); + expect( + tester.widget(_amountField).controller!.text, + value, + reason: + 'Intermediate "$value" must stay as typed. ${_snapshot(tester)}', + ); + } + + await tester.enterText(_amountField, '1.000'); + await tester.pump(); + expect( + tester.widget(_amountField).controller!.text, + '1000', + reason: + 'The last character of 1.000 must rewrite the field to 1000. ' + '${_snapshot(tester)}', + ); + + for (final value in ['1000,', '1000,5', '1000,50']) { + await tester.enterText(_amountField, value); + await tester.pump(); + expect( + tester.widget(_amountField).controller!.text, + value, + reason: + 'Continuation "$value" must stay as typed. ${_snapshot(tester)}', + ); + } + + await tester.pump(_afterDebounce); + await tester.pump(); + + _expectHealthyQuote( + tester, + expectedAmount: '1000,50', + reasonPrefix: + 'Typing 1.000,50 must appear as 1000,50 and keep a valid quote, ' + 'matching a one-step paste of the same characters.', + ); + }, + ); + + testWidgets( + 'pasting 1,000.50 is shown as 1000.50 and matches typing the same characters', + (tester) async { + await _pumpLoadedBuyPage(tester); + + await tester.enterText(_amountField, '1,000.50'); + await tester.pump(); + + expect( + tester.widget(_amountField).controller!.text, + '1000.50', + reason: + 'Pasting 1,000.50 must be rewritten to 1000.50 in one step, ' + 'matching character-by-character typing. ${_snapshot(tester)}', + ); + + await tester.pump(_afterDebounce); + await tester.pump(); + + _expectHealthyQuote( + tester, + expectedAmount: '1000.50', + reasonPrefix: + 'Pasting 1,000.50 must appear as 1000.50 and keep a valid quote.', + ); + }, + ); + + testWidgets( + 'typing 1,000.50 character by character ends as 1000.50 with a valid quote', + (tester) async { + await _pumpLoadedBuyPage(tester); + await tester.enterText(_amountField, ''); + await tester.pump(); + + for (final value in ['1', '1,', '1,0', '1,00']) { + await tester.enterText(_amountField, value); + await tester.pump(); + expect( + tester.widget(_amountField).controller!.text, + value, + reason: + 'Intermediate "$value" must stay as typed. ${_snapshot(tester)}', + ); + } + + await tester.enterText(_amountField, '1,000'); + await tester.pump(); + expect( + tester.widget(_amountField).controller!.text, + '1000', + reason: + 'The last character of 1,000 must rewrite the field to 1000. ' + '${_snapshot(tester)}', + ); + + for (final value in ['1000.', '1000.5', '1000.50']) { + await tester.enterText(_amountField, value); + await tester.pump(); + expect( + tester.widget(_amountField).controller!.text, + value, + reason: + 'Continuation "$value" must stay as typed. ${_snapshot(tester)}', + ); + } + + await tester.pump(_afterDebounce); + await tester.pump(); + + _expectHealthyQuote( + tester, + expectedAmount: '1000.50', + reasonPrefix: + 'Typing 1,000.50 must appear as 1000.50 and keep a valid quote, ' + 'matching a one-step paste of the same characters.', + ); + }, + ); + + testWidgets( + 'pasting grouping-ambiguous 1.000,000 is rejected', + (tester) async { + await _pumpLoadedBuyPage(tester); + + await tester.enterText(_amountField, '1.000,000'); + await tester.pump(); + + expect( + tester.widget(_amountField).controller!.text, + '1.000,000', + reason: + 'Pasting 1.000,000 must stay as typed; mixed 3-digit tails are ' + 'grouping-ambiguous. ${_snapshot(tester)}', + ); + + await tester.pump(_afterDebounce); + await tester.pump(); + + final snapshot = _snapshot(tester); + expect( + find.text(S.current.invalidAmountFormatTitle), + findsOneWidget, + reason: + 'Pasting 1.000,000 must surface the unreadable-amount hint. ' + '$snapshot', + ); + expect( + find.byType(BuyConfirmButton), + findsNothing, + reason: + 'A grouping-ambiguous paste must not leave a confirmable quote. ' + '$snapshot', + ); + }, + ); + + testWidgets( + 'pasting 1.000.50 (same separator as thousands and decimal) is rejected', + (tester) async { + await _pumpLoadedBuyPage(tester); + + await tester.enterText(_amountField, '1.000.50'); + await tester.pump(); + + expect( + tester.widget(_amountField).controller!.text, + '1.000.50', + reason: + 'Pasting 1.000.50 must stay as typed; the same separator cannot ' + 'be both thousands grouping and a decimal. ${_snapshot(tester)}', + ); + + await tester.pump(_afterDebounce); + await tester.pump(); + + final snapshot = _snapshot(tester); + expect( + find.text(S.current.invalidAmountFormatTitle), + findsOneWidget, + reason: + 'Pasting 1.000.50 must surface the unreadable-amount hint. ' + '$snapshot', + ); + expect( + find.byType(BuyConfirmButton), + findsNothing, + reason: + 'An ambiguous same-separator paste must not leave a confirmable ' + 'quote. $snapshot', + ); + }, + ); + + testWidgets( + 'a failed shares-to-fiat conversion drops the leftover quote so it cannot be confirmed', + (tester) async { + await _pumpLoadedBuyPage(tester); + + expect( + find.byType(BuyConfirmButton), + findsOneWidget, + reason: + 'Default 300 must already show the binding-buy button. ' + '${_snapshot(tester)}', + ); + + when(() => brokerbotService.getBuyPrice(any(), any())).thenAnswer( + (_) async => throw Exception('BuyPrice request failed: conversion failed'), + ); + + await tester.enterText(find.byType(TextField).last, '10'); + await tester.pump(_afterDebounce); + await tester.pump(); + + expect( + find.byType(BuyConfirmButton), + findsNothing, + reason: + 'After getBuyPrice fails the leftover 300 quote must not stay ' + 'confirmable. ${_snapshot(tester)}', + ); + expect( + tester.element(find.byType(BuyView)).read().state, + isA(), + reason: + 'The payment-info cubit must drop the landed quote when the ' + 'fiat counterpart is cleared. ${_snapshot(tester)}', + ); + verifyNever( + () => paymentInfoService.getPaymentInfo(0, currency: any(named: 'currency')), + ); + }, + ); + + testWidgets( + 'buying after replacing 300 with 100 confirms the 100 quote, not the leftover 300', + (tester) async { + await _pumpLoadedBuyPage(tester); + + _expectHealthyQuote( + tester, + expectedAmount: '300', + reasonPrefix: 'Default 300 must be a valid quote before the amount change.', + ); + expect( + find.byType(BuyConfirmButton), + findsOneWidget, + reason: + 'Default 300 must already show the binding-buy button. ' + '${_snapshot(tester)}', + ); + + await _enterAmount(tester, ''); + await _enterAmount(tester, '100'); + + _expectHealthyQuote( + tester, + expectedAmount: '100', + reasonPrefix: + 'After replacing 300 with 100 the screen must keep a valid quote.', + ); + + await _tapConfirmAndVerifyQuote( + tester, + paymentInfoService, + amount: 100, + currency: Currency.chf, + leftoverQuoteIds: [_quoteId(300, Currency.chf)], + reasonPrefix: + 'A buy after changing 300 to 100 must confirm the 100 quote, ' + 'never the leftover default 300.', + ); + }, + ); + + testWidgets( + 'buying after CHF to EUR then replacing 300 with 100 confirms the 100 EUR quote, ' + 'not a leftover 300', + (tester) async { + await _pumpLoadedBuyPage(tester); + + await _selectBuyCurrency(tester, Currency.eur); + + _expectHealthyQuote( + tester, + expectedAmount: '300', + expectedCurrency: Currency.eur, + reasonPrefix: 'After switching to EUR at 300 the quote must stay valid.', + ); + expect( + tester.element(find.byType(BuyView)).read().state.currency, + Currency.eur, + reason: + 'Currency picker must have flipped the converter to EUR. ' + '${_snapshot(tester)}', + ); + final eurQuote = tester + .element(find.byType(BuyView)) + .read() + .state; + expect( + eurQuote, + isA(), + reason: + '300 EUR must have landed as a valid quote before the amount ' + 'change. ${_snapshot(tester)}', + ); + final eurSuccess = eurQuote as BuyPaymentInfoSuccess; + expect( + eurSuccess.buyPaymentInfo.currency, + Currency.eur, + reason: + 'The landed quote must be EUR, not a leftover CHF quote. ' + '${_snapshot(tester)}', + ); + expect( + eurSuccess.buyPaymentInfo.amount, + 300.0, + reason: + 'The EUR quote before the amount change must still be 300. ' + '${_snapshot(tester)}', + ); + expect( + find.byType(BuyConfirmButton), + findsOneWidget, + reason: + '300 EUR must show the binding-buy button before the amount ' + 'change. ${_snapshot(tester)}', + ); + + await _enterAmount(tester, ''); + await _enterAmount(tester, '100'); + + _expectHealthyQuote( + tester, + expectedAmount: '100', + expectedCurrency: Currency.eur, + reasonPrefix: + 'After switching to EUR and replacing 300 with 100 the quote ' + 'must stay valid.', + ); + expect( + tester.element(find.byType(BuyView)).read().state.currency, + Currency.eur, + reason: + 'Currency must stay EUR after the amount change. ' + '${_snapshot(tester)}', + ); + + await _tapConfirmAndVerifyQuote( + tester, + paymentInfoService, + amount: 100, + currency: Currency.eur, + leftoverQuoteIds: [ + _quoteId(300, Currency.chf), + _quoteId(300, Currency.eur), + ], + reasonPrefix: + 'A buy after CHF to EUR and 300 to 100 must confirm the 100 EUR ' + 'quote, never a leftover 300 CHF or 300 EUR quote.', + ); + }, + ); + }); +} + +void _stubProductionLikeQuotes( + MockDfxBrokerbotService brokerbot, + MockRealUnitBuyPaymentInfoService paymentInfo, +) { + // Real DfxBrokerbotService rejects unparseable / non-positive input + // (empty, "1.000") and otherwise returns a conversion. Amounts 1–3 are + // convertible; the min-amount gate lives on the quote, not here. + when(() => brokerbot.getBuyShares(any(), any())).thenAnswer((invocation) async { + final raw = invocation.positionalArguments[0] as String; + final parsed = tryParseFiatAmount(raw); + if (parsed == null || parsed <= 0) { + throw Exception('Shares request failed: amountInput is not valid'); + } + return BrokerbotBuySharesDto( + shares: parsed < 1.43 ? 1 : parsed ~/ 1.43, + pricePerShare: 1.43, + availableShares: 100000, + ); + }); + + when(() => brokerbot.getBuyPrice(any(), any())).thenAnswer((invocation) async { + final raw = invocation.positionalArguments[0] as String; + final shares = int.tryParse(raw); + if (shares == null || shares <= 0) { + throw Exception('BuyPrice request failed: sharesInput is not valid'); + } + return BrokerbotBuyPriceDto( + totalCost: shares * 1.43, + pricePerShare: 1.43, + availableShares: 100000, + ); + }); + + when( + () => paymentInfo.getPaymentInfo(any(), currency: any(named: 'currency')), + ).thenAnswer((invocation) async { + final amount = invocation.positionalArguments[0] as int; + final currency = + invocation.namedArguments[#currency] as Currency? ?? Currency.chf; + if (amount >= _minVolume) { + return _quote(amount: amount, currency: currency, isValid: true); + } + return _quote( + amount: amount, + currency: currency, + isValid: false, + error: 'AmountTooLow', + minVolume: _minVolume, + ); + }); +} + +BuyPaymentInfo _quote({ + required int amount, + required Currency currency, + required bool isValid, + String? error, + double? minVolume, +}) { + return BuyPaymentInfo( + id: _quoteId(amount, currency), + iban: 'CH56 0483 5012 3456 78', + bic: 'CRESCHZZ80A', + name: 'DFX AG', + street: 'Bahnhofstrasse', + number: '1', + zip: '8000', + city: 'Zurich', + country: 'CH', + currency: currency, + amount: amount.toDouble(), + isValid: isValid, + error: error, + minVolume: minVolume, + ); +} + +/// Distinct per (amount, currency) so a leftover default quote cannot +/// masquerade as the edited one when [confirmPayment] is verified by id. +int _quoteId(int amount, Currency currency) { + return switch (currency) { + Currency.chf => amount, + Currency.eur => 1000000 + amount, + }; +} + +Finder get _amountField => find.byType(TextField).first; + +Future _pumpLoadedBuyPage(WidgetTester tester) async { + await tester.pumpApp(const BuyPage()); + // BuyPage constructs BuyConverterCubit(..)..onFiatChanged('300'). + await tester.pump(); + await tester.pump(_afterDebounce); + await tester.pump(); + + final amount = tester.widget(_amountField); + expect( + amount.controller!.text, + '300', + reason: 'Precondition failed: default 300 did not land. ${_snapshot(tester)}', + ); + expect( + find.text(S.current.paymentInformationFailed), + findsNothing, + reason: + 'Precondition failed: support-contact error already visible on the ' + 'default 300. ${_snapshot(tester)}', + ); +} + +Future _enterAmount(WidgetTester tester, String value) async { + await tester.enterText(_amountField, value); + await tester.pump(_afterDebounce); + await tester.pump(); +} + +Future _selectBuyCurrency(WidgetTester tester, Currency currency) async { + await tester.tap(find.byKey(const Key('buy-currency-picker'))); + await tester.pumpAndSettle(); + + await tester.tap( + find.byWidgetPredicate( + (widget) => widget is PopupMenuItem && widget.value == currency, + ), + ); + await tester.pumpAndSettle(); +} + +/// Taps the binding-buy CTA and asserts the confirm call carries the current +/// quote, not a leftover one. Confirm is stubbed to throw so the page does +/// not try to `pushNamed` (this file hosts [BuyPage] via [pumpApp], not a +/// GoRouter). The call itself is what the production path charges. +Future _tapConfirmAndVerifyQuote( + WidgetTester tester, + MockRealUnitBuyPaymentInfoService paymentInfo, { + required int amount, + required Currency currency, + required List leftoverQuoteIds, + required String reasonPrefix, +}) async { + final snapshot = _snapshot(tester); + final currentId = _quoteId(amount, currency); + + expect( + find.byType(BuyConfirmButton), + findsOneWidget, + reason: '$reasonPrefix Binding-buy button is missing. $snapshot', + ); + + final confirm = tester.widget(find.byType(BuyConfirmButton)); + expect( + confirm.buyPaymentInfo.amount, + amount.toDouble(), + reason: + '$reasonPrefix Confirm button still holds amount ' + '${confirm.buyPaymentInfo.amount}, not $amount. $snapshot', + ); + expect( + confirm.buyPaymentInfo.currency, + currency, + reason: + '$reasonPrefix Confirm button still holds currency ' + '${confirm.buyPaymentInfo.currency.code}, not ${currency.code}. $snapshot', + ); + expect( + confirm.buyPaymentInfo.id, + currentId, + reason: + '$reasonPrefix Confirm button still holds quote id ' + '${confirm.buyPaymentInfo.id}, not the current quote $currentId. $snapshot', + ); + expect( + leftoverQuoteIds.contains(confirm.buyPaymentInfo.id), + isFalse, + reason: + '$reasonPrefix Confirm button still holds a leftover quote id ' + '${confirm.buyPaymentInfo.id}. $snapshot', + ); + + final filled = tester.widget( + find.descendant( + of: find.byType(BuyConfirmButton), + matching: find.byType(AppFilledButton), + ), + ); + expect( + filled.onPressed, + isNotNull, + reason: '$reasonPrefix Binding-buy button is not pressable. $snapshot', + ); + + when(() => paymentInfo.confirmPayment(any())).thenAnswer( + (_) async => throw Exception('confirm outcome is not under test'), + ); + + await tester.tap(find.text(S.current.buyPaymentConfirm)); + await tester.pump(); + await tester.pump(); + + final confirmedIds = + verify(() => paymentInfo.confirmPayment(captureAny())).captured.cast(); + expect( + confirmedIds, + [currentId], + reason: + '$reasonPrefix Confirm must be called with the current quote id ' + '$currentId (amount $amount ${currency.code}), not a leftover ' + 'quote. Got $confirmedIds. ${_snapshot(tester)}', + ); + for (final leftoverId in leftoverQuoteIds) { + expect( + confirmedIds, + isNot(contains(leftoverId)), + reason: + '$reasonPrefix Confirm carried leftover quote id $leftoverId. ' + 'Got $confirmedIds. ${_snapshot(tester)}', + ); + } +} + +void _expectHealthyQuote( + WidgetTester tester, { + required String expectedAmount, + required String reasonPrefix, + Currency expectedCurrency = Currency.chf, +}) { + final snapshot = _snapshot(tester); + final amount = tester.widget(_amountField); + + expect( + find.text(S.current.paymentInformationFailed), + findsNothing, + reason: '$reasonPrefix Support title is visible. $snapshot', + ); + expect( + find.text(S.current.paymentInformationFailedDescription), + findsNothing, + reason: '$reasonPrefix Support description is visible. $snapshot', + ); + expect( + find.byType(CupertinoActivityIndicator), + findsNothing, + reason: '$reasonPrefix Payment info is still spinning. $snapshot', + ); + expect( + amount.controller!.text, + expectedAmount, + reason: '$reasonPrefix Amount field is not "$expectedAmount". $snapshot', + ); + + final paymentState = + tester.element(find.byType(BuyView)).read().state; + expect( + paymentState, + isA(), + reason: '$reasonPrefix Payment info is not a landed quote. $snapshot', + ); + final success = paymentState as BuyPaymentInfoSuccess; + expect( + success.buyPaymentInfo.amount, + chargedFiatAmount(expectedAmount).toDouble(), + reason: + '$reasonPrefix Quote amount is ${success.buyPaymentInfo.amount}, not ' + '${chargedFiatAmount(expectedAmount)}. $snapshot', + ); + expect( + success.buyPaymentInfo.currency, + expectedCurrency, + reason: + '$reasonPrefix Quote currency is ${success.buyPaymentInfo.currency.code}, ' + 'not ${expectedCurrency.code}. $snapshot', + ); + expect( + find.byType(BuyConfirmButton), + findsOneWidget, + reason: '$reasonPrefix Binding-buy button is missing. $snapshot', + ); + final filled = tester.widget( + find.descendant( + of: find.byType(BuyConfirmButton), + matching: find.byType(AppFilledButton), + ), + ); + expect( + filled.onPressed, + isNotNull, + reason: '$reasonPrefix Binding-buy button is not pressable. $snapshot', + ); +} + +String _snapshot(WidgetTester tester) { + final amountText = tester.widget(_amountField).controller?.text; + final viewContext = tester.element(find.byType(BuyView)); + final paymentState = viewContext.read().state; + final converterState = viewContext.read().state; + final supportVisible = find.text(S.current.paymentInformationFailed).evaluate().isNotEmpty; + final spinnerVisible = find.byType(CupertinoActivityIndicator).evaluate().isNotEmpty; + final actionRequired = find.byType(PaymentActionRequired).evaluate().isNotEmpty; + return 'amountField="$amountText" ' + 'converter(fiat=${converterState.fiatText}, shares=${converterState.sharesText}, ' + 'loading=${converterState.loading}, currency=${converterState.currency.code}) ' + 'paymentInfo=${_describePayment(paymentState)} ' + 'supportText=$supportVisible spinner=$spinnerVisible ' + 'PaymentActionRequired=$actionRequired'; +} + +String _describePayment(BuyPaymentInfoState state) { + if (state is BuyPaymentInfoSuccess) { + return 'Success(id=${state.buyPaymentInfo.id}, ' + 'amount=${state.buyPaymentInfo.amount}, ' + 'currency=${state.buyPaymentInfo.currency.code}, ' + 'isValid=${state.buyPaymentInfo.isValid})'; + } + if (state is BuyPaymentInfoMinAmountNotMetFailure) { + return 'MinAmountNotMet(minAmount=${state.minAmount})'; + } + if (state is BuyPaymentInfoFailure) { + return 'Failure(${state.error})'; + } + if (state is BuyPaymentInfoLoading) return 'Loading'; + if (state is BuyPaymentInfoInitial) return 'Initial'; + return state.runtimeType.toString(); +} diff --git a/test/screens/buy/cubits/buy_converter_cubit_test.dart b/test/screens/buy/cubits/buy_converter_cubit_test.dart index 53ecc7186..dd2fe5ab5 100644 --- a/test/screens/buy/cubits/buy_converter_cubit_test.dart +++ b/test/screens/buy/cubits/buy_converter_cubit_test.dart @@ -86,6 +86,31 @@ void main() { expect(cubit.state.loading, isFalse); }); + test('onFiatChanged clears stale sharesText when a later conversion fails', () async { + when(() => service.getBuyShares('300', any())).thenAnswer( + (_) async => BrokerbotBuySharesDto( + shares: 209, + pricePerShare: 1.43, + availableShares: 100000, + ), + ); + when(() => service.getBuyShares('1.000', any())).thenAnswer( + (_) async => throw Exception('Shares request failed: amountInput is not valid'), + ); + + final cubit = BuyConverterCubit(service); + await cubit.onFiatChanged('300'); + await Future.delayed(const Duration(milliseconds: 250)); + expect(cubit.state.sharesText, '209'); + + await cubit.onFiatChanged('1.000'); + await Future.delayed(const Duration(milliseconds: 250)); + + expect(cubit.state.fiatText, '1.000'); + expect(cubit.state.sharesText, ''); + expect(cubit.state.loading, isFalse); + }); + test( 'onSharesChanged debounces, then writes the converted fiat with matching fractional digits', () async { @@ -123,6 +148,31 @@ void main() { expect(cubit.state.loading, isFalse); }); + test('onSharesChanged clears stale fiatText when a later conversion fails', () async { + when(() => service.getBuyPrice('5', any())).thenAnswer( + (_) async => BrokerbotBuyPriceDto( + totalCost: 7.15, + pricePerShare: 1.43, + availableShares: 100, + ), + ); + when(() => service.getBuyPrice('x', any())).thenAnswer( + (_) async => throw Exception('BuyPrice request failed: sharesInput is not valid'), + ); + + final cubit = BuyConverterCubit(service); + await cubit.onSharesChanged('5'); + await Future.delayed(const Duration(milliseconds: 250)); + expect(cubit.state.fiatText, '7.15'); + + await cubit.onSharesChanged('x'); + await Future.delayed(const Duration(milliseconds: 250)); + + expect(cubit.state.sharesText, 'x'); + expect(cubit.state.fiatText, ''); + expect(cubit.state.loading, isFalse); + }); + test('onSharesChanged uses 2 fractional digits when input has no dot', () async { when(() => service.getBuyPrice(any(), any())).thenAnswer( (_) async => BrokerbotBuyPriceDto( @@ -172,6 +222,30 @@ void main() { expect(cubit.state.loading, isFalse); }); + test('onCurrencyChanged clears stale sharesText when conversion fails', () async { + when(() => service.getBuyShares(any(), Currency.chf)).thenAnswer( + (_) async => BrokerbotBuySharesDto( + shares: 209, + pricePerShare: 1.43, + availableShares: 100000, + ), + ); + when(() => service.getBuyShares(any(), Currency.eur)).thenAnswer( + (_) async => throw Exception('throttle'), + ); + + final cubit = BuyConverterCubit(service); + await cubit.onFiatChanged('300'); + await Future.delayed(const Duration(milliseconds: 250)); + expect(cubit.state.sharesText, '209'); + + await cubit.onCurrencyChanged(Currency.eur); + + expect(cubit.state.currency, Currency.eur); + expect(cubit.state.sharesText, ''); + expect(cubit.state.loading, isFalse); + }); + test( 'fiat race: stale in-flight response is dropped when user typed further', () async { diff --git a/test/screens/buy/cubits/buy_payment_info_cubit_test.dart b/test/screens/buy/cubits/buy_payment_info_cubit_test.dart index 486ba9242..fb1dab47a 100644 --- a/test/screens/buy/cubits/buy_payment_info_cubit_test.dart +++ b/test/screens/buy/cubits/buy_payment_info_cubit_test.dart @@ -179,6 +179,33 @@ void main() { verify(() => service.getPaymentInfo(301, currency: Currency.chf)).called(1); }); + test('grouping-ambiguous amount (1.000) → Failure(invalidAmountFormat), not unknown', () async { + final cubit = build(); + await cubit.getPaymentInfo(amount: '1.000'); + + expect(cubit.state, isA()); + expect( + (cubit.state as BuyPaymentInfoFailure).error, + PaymentInfoError.invalidAmountFormat, + ); + verifyNever(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))); + }); + + test('FormatException from the service is unknown, not invalidAmountFormat', () async { + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) + .thenAnswer((_) async => throw const FormatException('Unexpected character')); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '300'); + + expect(cubit.state, isA()); + expect( + (cubit.state as BuyPaymentInfoFailure).error, + PaymentInfoError.unknown, + ); + verify(() => service.getPaymentInfo(300, currency: Currency.chf)).called(1); + }); + test('KycLevelRequiredException → Failure(kycRequired, requiredLevel)', () async { when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) .thenAnswer( @@ -329,6 +356,18 @@ void main() { expect((cubit.state as BuyPaymentInfoFailure).message, 'bad'); }); + test('clearQuote drops a landed Success back to Initial', () async { + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) + .thenAnswer((_) async => _info()); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '300'); + expect(cubit.state, isA()); + + cubit.clearQuote(); + expect(cubit.state, isA()); + }); + test('does not emit after close', () async { final completer = Completer(); when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) diff --git a/test/screens/sell/cubits/sell_converter_cubit_test.dart b/test/screens/sell/cubits/sell_converter_cubit_test.dart index 68c64b3a7..689c469f7 100644 --- a/test/screens/sell/cubits/sell_converter_cubit_test.dart +++ b/test/screens/sell/cubits/sell_converter_cubit_test.dart @@ -104,6 +104,32 @@ void main() { expect(cubit.state.loading, isFalse); }); + test('onFiatChanged clears stale sharesText when a later conversion fails', () async { + when(() => service.getSellShares('300', any())).thenAnswer( + (_) async => BrokerbotSellSharesDto( + targetAmount: 300, + shares: 209, + pricePerShare: 1.43, + currency: 'CHF', + ), + ); + when(() => service.getSellShares('1.000', any())).thenAnswer( + (_) async => throw Exception('Shares request failed: amountInput is not valid'), + ); + + final cubit = SellConverterCubit(service); + await cubit.onFiatChanged('300'); + await Future.delayed(const Duration(milliseconds: 250)); + expect(cubit.state.sharesText, '209'); + + await cubit.onFiatChanged('1.000'); + await Future.delayed(const Duration(milliseconds: 250)); + + expect(cubit.state.fiatText, '1.000'); + expect(cubit.state.sharesText, ''); + expect(cubit.state.loading, isFalse); + }); + test('onSharesChanged writes estimatedAmount with matching fractional digits', () async { when(() => service.getSellPrice(any(), any())).thenAnswer( (_) async => BrokerbotSellPriceDto( @@ -140,6 +166,32 @@ void main() { expect(cubit.state.loading, isFalse); }); + test('onSharesChanged clears stale fiatText when a later conversion fails', () async { + when(() => service.getSellPrice('5', any())).thenAnswer( + (_) async => BrokerbotSellPriceDto( + shares: 5, + estimatedAmount: 7.15, + pricePerShare: 1.43, + currency: 'CHF', + ), + ); + when(() => service.getSellPrice('x', any())).thenAnswer( + (_) async => throw Exception('SellPrice request failed: sharesInput is not valid'), + ); + + final cubit = SellConverterCubit(service); + await cubit.onSharesChanged('5'); + await Future.delayed(const Duration(milliseconds: 250)); + expect(cubit.state.fiatText, '7.15'); + + await cubit.onSharesChanged('x'); + await Future.delayed(const Duration(milliseconds: 250)); + + expect(cubit.state.sharesText, 'x'); + expect(cubit.state.fiatText, ''); + expect(cubit.state.loading, isFalse); + }); + test('onSharesChanged defaults to 2 fractional digits when input has no dot', () async { when(() => service.getSellPrice(any(), any())).thenAnswer( (_) async => BrokerbotSellPriceDto( @@ -213,6 +265,31 @@ void main() { expect(cubit.state.loading, isFalse); }); + test('onCurrencyChanged clears stale fiatText when conversion fails', () async { + when(() => service.getSellPrice(any(), Currency.chf)).thenAnswer( + (_) async => BrokerbotSellPriceDto( + shares: 10, + estimatedAmount: 14.30, + pricePerShare: 1.43, + currency: 'CHF', + ), + ); + when(() => service.getSellPrice(any(), Currency.eur)).thenAnswer( + (_) async => throw Exception('throttle'), + ); + + final cubit = SellConverterCubit(service); + await cubit.onSharesChanged('10'); + await Future.delayed(const Duration(milliseconds: 250)); + expect(cubit.state.fiatText, '14.30'); + + await cubit.onCurrencyChanged(Currency.eur); + + expect(cubit.state.currency, Currency.eur); + expect(cubit.state.fiatText, ''); + expect(cubit.state.loading, isFalse); + }); + test( 'fiat race: stale in-flight response is dropped when user typed further', () async { diff --git a/test/screens/sell/cubits/sell_payment_info_cubit_test.dart b/test/screens/sell/cubits/sell_payment_info_cubit_test.dart index 94be1457f..f41ed39a6 100644 --- a/test/screens/sell/cubits/sell_payment_info_cubit_test.dart +++ b/test/screens/sell/cubits/sell_payment_info_cubit_test.dart @@ -349,6 +349,22 @@ void main() { }, ); + test('FormatException from the service is unknown, not invalidAmountFormat', () async { + when( + () => service.getPaymentInfo(any(), any(), currency: any(named: 'currency')), + ).thenAnswer((_) async => throw const FormatException('Unexpected character')); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '100', iban: 'CH56'); + + expect(cubit.state, isA()); + expect( + (cubit.state as SellPaymentInfoFailure).error, + PaymentInfoError.unknown, + ); + verify(() => service.getPaymentInfo(100, 'CH56', currency: Currency.chf)).called(1); + }); + test('does not emit after close', () async { final completer = Completer(); when( diff --git a/test/screens/sell/widgets/sell_converter_error_test.dart b/test/screens/sell/widgets/sell_converter_error_test.dart index 270666e6d..1623e0827 100644 --- a/test/screens/sell/widgets/sell_converter_error_test.dart +++ b/test/screens/sell/widgets/sell_converter_error_test.dart @@ -95,4 +95,60 @@ void main() { expect(find.byType(SnackBar), findsNothing); }, ); + + testWidgets( + 'typing 1.000 in the you-receive field is shown as 1000', + (tester) async { + when(() => fiatRepo.getSellable()).thenAnswer((_) async => const [Currency.chf]); + when(() => converterCubit.onFiatChanged(any())).thenAnswer((_) async {}); + + final sharesController = TextEditingController(); + final fiatController = TextEditingController(); + addTearDown(sharesController.dispose); + addTearDown(fiatController.dispose); + + await tester.pumpApp( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: converterCubit), + BlocProvider.value(value: balanceCubit), + ], + child: Scaffold( + body: SellConverter( + amountController: sharesController, + resultController: fiatController, + ), + ), + ), + ); + await tester.pump(); + + final fiatField = find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller == fiatController, + ); + + for (final value in ['1', '1.', '1.0', '1.00']) { + await tester.enterText(fiatField, value); + await tester.pump(); + expect( + fiatController.text, + value, + reason: 'Intermediate "$value" must stay as typed in the sell fiat field.', + ); + } + + await tester.enterText(fiatField, '1.000'); + await tester.pump(); + + expect(fiatController.text, '1000'); + expect(fiatController.selection, const TextSelection.collapsed(offset: 4)); + expect(sharesController.text, isEmpty); + + await tester.enterText(fiatField, '1000.000'); + await tester.pump(); + + expect(fiatController.text, '1000000'); + expect(fiatController.selection, const TextSelection.collapsed(offset: 7)); + }, + ); } diff --git a/test/widgets/fiat_input_formatter_test.dart b/test/widgets/fiat_input_formatter_test.dart new file mode 100644 index 000000000..a588ee82e --- /dev/null +++ b/test/widgets/fiat_input_formatter_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/widgets/fiat_input_formatter.dart'; + +void main() { + const formatter = FiatInputFormatter(); + + TextEditingValue update(TextEditingValue oldValue, TextEditingValue newValue) => + formatter.formatEditUpdate(oldValue, newValue); + + group('$FiatInputFormatter.formatEditUpdate', () { + test('rewrites a one-step paste of 1.000.000 to 1000000', () { + final out = update( + TextEditingValue.empty, + const TextEditingValue( + text: '1.000.000', + selection: TextSelection.collapsed(offset: 9), + ), + ); + expect(out.text, '1000000'); + expect(out.selection, const TextSelection.collapsed(offset: 7)); + }); + + test('rewrites a one-step paste of 1,000,000 to 1000000', () { + final out = update( + TextEditingValue.empty, + const TextEditingValue( + text: '1,000,000', + selection: TextSelection.collapsed(offset: 9), + ), + ); + expect(out.text, '1000000'); + expect(out.selection, const TextSelection.collapsed(offset: 7)); + }); + + test('rewrites a one-step paste of 1.000,50 to 1000,50', () { + final out = update( + TextEditingValue.empty, + const TextEditingValue( + text: '1.000,50', + selection: TextSelection.collapsed(offset: 8), + ), + ); + expect(out.text, '1000,50'); + expect(out.selection, const TextSelection.collapsed(offset: 7)); + }); + + test('rewrites a one-step paste of 1,000.50 to 1000.50', () { + final out = update( + TextEditingValue.empty, + const TextEditingValue( + text: '1,000.50', + selection: TextSelection.collapsed(offset: 8), + ), + ); + expect(out.text, '1000.50'); + expect(out.selection, const TextSelection.collapsed(offset: 7)); + }); + + test('a digit typed after a mixed thousands-and-decimal paste lands at the end', () { + final pasted = update( + TextEditingValue.empty, + const TextEditingValue( + text: '1.000,5', + selection: TextSelection.collapsed(offset: 7), + ), + ); + expect(pasted.text, '1000,5'); + expect(pasted.selection, const TextSelection.collapsed(offset: 6)); + + final typed = update( + pasted, + TextEditingValue( + text: '${pasted.text}9', + selection: TextSelection.collapsed(offset: pasted.selection.baseOffset + 1), + ), + ); + expect(typed.text, '1000,59'); + expect(typed.selection, const TextSelection.collapsed(offset: 7)); + }); + + test('leaves a one-step paste of grouping-ambiguous 1.000,000 unchanged', () { + final out = update( + TextEditingValue.empty, + const TextEditingValue( + text: '1.000,000', + selection: TextSelection.collapsed(offset: 9), + ), + ); + expect(out.text, '1.000,000'); + expect(out.selection, const TextSelection.collapsed(offset: 9)); + }); + + test('keeps the caret at the end when a thousands group is completed at the end', () { + final out = update( + const TextEditingValue( + text: '1.00', + selection: TextSelection.collapsed(offset: 4), + ), + const TextEditingValue( + text: '1.000', + selection: TextSelection.collapsed(offset: 5), + ), + ); + expect(out.text, '1000'); + expect(out.selection, const TextSelection.collapsed(offset: 4)); + }); + + test('keeps the caret relative to the edit when a thousands group is stripped mid-text', () { + // Insert '.' after '1' in '1000' → '1.000' → '1000', caret stays after '1'. + final out = update( + const TextEditingValue( + text: '1000', + selection: TextSelection.collapsed(offset: 1), + ), + const TextEditingValue( + text: '1.000', + selection: TextSelection.collapsed(offset: 2), + ), + ); + expect(out.text, '1000'); + expect(out.selection, const TextSelection.collapsed(offset: 1)); + }); + + test('places the caret after a 0 typed into 1.00,50 before the decimal', () { + // `1.00|`,50 plus `0` → `1.000|,50`. One `.` and zero `,` sit before + // the caret; counting the decimal comma instead would leave it at 5. + final out = update( + const TextEditingValue( + text: '1.00,50', + selection: TextSelection.collapsed(offset: 4), + ), + const TextEditingValue( + text: '1.000,50', + selection: TextSelection.collapsed(offset: 5), + ), + ); + expect(out.text, '1000,50'); + expect(out.selection, const TextSelection.collapsed(offset: 4)); + }); + + test('places the caret after a 0 typed into 1,00.50 before the decimal', () { + final out = update( + const TextEditingValue( + text: '1,00.50', + selection: TextSelection.collapsed(offset: 4), + ), + const TextEditingValue( + text: '1,000.50', + selection: TextSelection.collapsed(offset: 5), + ), + ); + expect(out.text, '1000.50'); + expect(out.selection, const TextSelection.collapsed(offset: 4)); + }); + + test('places the caret after a digit typed at the start of .000,50', () { + // `.000,50` is a fixpoint (both regexes require a leading `\d+`). Typing + // `1` at offset 0 yields `1.000,50` → `1000,50`. The stripped `.` sits + // behind the caret, so a global length delta would move it to 0. + final out = update( + const TextEditingValue( + text: '.000,50', + selection: TextSelection.collapsed(offset: 0), + ), + const TextEditingValue( + text: '1.000,50', + selection: TextSelection.collapsed(offset: 1), + ), + ); + expect(out.text, '1000,50'); + expect(out.selection, const TextSelection.collapsed(offset: 1)); + }); + + test('places the caret after a digit typed at the start of .000.000', () { + final out = update( + const TextEditingValue( + text: '.000.000', + selection: TextSelection.collapsed(offset: 0), + ), + const TextEditingValue( + text: '1.000.000', + selection: TextSelection.collapsed(offset: 1), + ), + ); + expect(out.text, '1000000'); + expect(out.selection, const TextSelection.collapsed(offset: 1)); + }); + }); +}