From 8d2fa68a3ba9349adbcbe1cb204e481fb1ee88ed Mon Sep 17 00:00:00 2001 From: Ilya Nixan Date: Tue, 17 Mar 2026 02:01:26 +0300 Subject: [PATCH 1/4] Refactor and expand number editing tests --- test/number_editing_controller_test.dart | 801 ++++++++++++++++++----- 1 file changed, 650 insertions(+), 151 deletions(-) diff --git a/test/number_editing_controller_test.dart b/test/number_editing_controller_test.dart index 0cc1f4d..eaa4952 100644 --- a/test/number_editing_controller_test.dart +++ b/test/number_editing_controller_test.dart @@ -2,196 +2,695 @@ import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:number_editing_controller/number_editing_controller.dart'; +/// Non-breaking space used by many locales as a group separator. +const _nbsp = '\u00A0'; + void main() { - test('empty state', () { - final controller = NumberEditingTextController.currency( - currencyName: 'EUR', - ); - expect(controller.value, const TextEditingValue(text: '')); - expect(controller.number, null); - }); - test('some amount', () { - final controller = NumberEditingTextController.currency( - currencyName: 'EUR', - ); - controller.number = 10.5; - expect(controller.value, const TextEditingValue(text: '€10.5')); - expect(controller.number, 10.5); - }); - test('some amount in DE locale', () { - final controller = NumberEditingTextController.currency( - currencyName: 'GBP', - locale: 'de', - ); - controller.number = -5000.5; - expect(controller.value, const TextEditingValue(text: '-5.000,5 £')); - expect(controller.number, -5000.5); - }); - test('some decimal', () { - final controller = NumberEditingTextController.decimal( - locale: 'de', - maximumFractionDigits: 6, - ); - controller.number = -100.51241; - expect(controller.value, const TextEditingValue(text: '-100,51241')); - expect(controller.number, -100.51241); - }); - test('some decimal but provided integer', () { - final controller = NumberEditingTextController.decimal( - locale: 'de', - maximumFractionDigits: 6, - ); - controller.number = -1100; - expect(controller.value, const TextEditingValue(text: '-1.100')); - expect(controller.number, -1100); - }); - test('some integer', () { - final controller = NumberEditingTextController.integer( - locale: 'de', - ); - controller.number = -100; - expect(controller.value, const TextEditingValue(text: '-100')); - expect(controller.number, -100); - }); - test('zero-based currency editing', () { - final controller = NumberEditingTextController.currency( - currencyName: 'USD', - locale: 'en', - ); - controller.number = 1; - expect( - controller.value, - const TextEditingValue( - text: '\$1', - ), - ); - - controller.value = controller.value.copyWith( - text: '\$', - selection: const TextSelection.collapsed(offset: 1), - ); - expect( - controller.value, - const TextEditingValue( + group('NumberEditingTextController.currency', () { + test('empty state', () { + final controller = NumberEditingTextController.currency( + currencyName: 'EUR', + ); + expect(controller.value.text, ''); + expect(controller.number, null); + }); + + test('set number programmatically', () { + final controller = NumberEditingTextController.currency( + currencyName: 'EUR', + ); + controller.number = 10.5; + expect(controller.value.text, '€10.5'); + expect(controller.number, 10.5); + }); + + test('set number to null clears text', () { + final controller = NumberEditingTextController.currency( + currencyName: 'EUR', + ); + controller.number = 42; + expect(controller.number, 42); + controller.number = null; + expect(controller.value.text, ''); + expect(controller.number, null); + }); + + test('initial value via constructor', () { + final controller = NumberEditingTextController.currency( + currencyName: 'USD', + locale: 'en', + value: 99.99, + ); + expect(controller.value.text, '\$99.99'); + expect(controller.number, 99.99); + }); + + test('negative currency in DE locale', () { + final controller = NumberEditingTextController.currency( + currencyName: 'GBP', + locale: 'de', + ); + controller.number = -5000.5; + // DE uses non-breaking space before currency symbol + expect(controller.value.text, '-5.000,5${_nbsp}£'); + expect(controller.number, -5000.5); + }); + + test('overridden currency symbol', () { + final controller = NumberEditingTextController.currency( + currencyName: 'TRY', + locale: 'es_ES', + currencySymbol: '₺', + allowNegative: false, + ); + controller.number = 6612.54; + expect(controller.value.text, '6.612,54${_nbsp}₺'); + expect(controller.number, 6612.54); + }); + + test('custom decimal separator', () { + final controller = NumberEditingTextController.currency( + currencyName: 'USD', + locale: 'en', + decimalSeparator: '·', + ); + controller.number = 10.5; + expect(controller.value.text, '\$10·5'); + }); + + test('custom group separator', () { + final controller = NumberEditingTextController.currency( + currencyName: 'USD', + locale: 'en', + groupSeparator: ' ', + ); + controller.number = 1000000; + expect(controller.value.text, '\$1 000 000'); + }); + + test('zero value', () { + final controller = NumberEditingTextController.currency( + currencyName: 'USD', + locale: 'en', + ); + controller.number = 0; + expect(controller.value.text, '\$0'); + expect(controller.number, 0); + }); + + test('very large number', () { + final controller = NumberEditingTextController.currency( + currencyName: 'USD', + locale: 'en', + ); + controller.number = 999999999; + expect(controller.value.text, '\$999,999,999'); + expect(controller.number, 999999999); + }); + + test('typing digits adds formatting', () { + final controller = NumberEditingTextController.currency( + currencyName: 'USD', + locale: 'en', + ); + controller.value = controller.value.copyWith( + text: '1234', + selection: const TextSelection.collapsed(offset: 4), + ); + expect(controller.value.text, '\$1,234'); + expect(controller.number, 1234); + }); + + test('typing decimal amount', () { + final controller = NumberEditingTextController.currency( + currencyName: 'USD', + locale: 'en', + ); + controller.value = controller.value.copyWith( + text: '10.50', + selection: const TextSelection.collapsed(offset: 5), + ); + expect(controller.value.text, '\$10.50'); + expect(controller.number, 10.5); + }); + + test('deleting all digits leaves currency symbol', () { + final controller = NumberEditingTextController.currency( + currencyName: 'USD', + locale: 'en', + ); + controller.number = 1; + controller.value = controller.value.copyWith( text: '\$', - selection: TextSelection.collapsed( - offset: 1, - ), - ), - ); - expect(controller.number, null); + selection: const TextSelection.collapsed(offset: 1), + ); + expect(controller.value.text, '\$'); + expect(controller.number, null); + }); + + test('deleting all digits with trailing symbol', () { + final controller = NumberEditingTextController.currency( + currencyName: 'USD', + locale: 'ru', + ); + controller.number = 1; + // Russian uses non-breaking space + expect(controller.value.text, '1${_nbsp}\$'); + controller.value = controller.value.copyWith( + text: '${_nbsp}\$', + selection: const TextSelection.collapsed(offset: 0), + ); + expect(controller.value.text, '${_nbsp}\$'); + expect(controller.number, null); + }); + + test('JPY currency has no decimals', () { + final controller = NumberEditingTextController.currency( + currencyName: 'JPY', + locale: 'ja', + ); + controller.number = 1500; + expect(controller.value.text, '¥1,500'); + expect(controller.number, 1500); + }); + + test('default locale is used when none specified', () { + final controller = NumberEditingTextController.currency( + currencyName: 'USD', + ); + controller.number = 100; + expect(controller.number, 100); + expect(controller.value.text.isNotEmpty, true); + }); + }); + + group('NumberEditingTextController.integer', () { + test('empty state', () { + final controller = NumberEditingTextController.integer(); + expect(controller.value.text, ''); + expect(controller.number, null); + }); + + test('set number programmatically', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.number = 42; + expect(controller.value.text, '42'); + expect(controller.number, 42); + }); + + test('negative integer', () { + final controller = NumberEditingTextController.integer(locale: 'de'); + controller.number = -100; + expect(controller.value.text, '-100'); + expect(controller.number, -100); + }); + + test('large integer with grouping', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.number = 1234567; + expect(controller.value.text, '1,234,567'); + expect(controller.number, 1234567); + }); + + test('typing digits formats with grouping', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = controller.value.copyWith( + text: '1000000', + selection: const TextSelection.collapsed(offset: 7), + ); + expect(controller.value.text, '1,000,000'); + expect(controller.number, 1000000); + }); + + test('decimal input stops at separator for integer', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = controller.value.copyWith( + text: '123.45', + selection: const TextSelection.collapsed(offset: 6), + ); + // integer controller ignores everything from decimal separator + expect(controller.number, 123); + }); + + test('zero value', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.number = 0; + expect(controller.value.text, '0'); + expect(controller.number, 0); + }); + + test('initial value via constructor', () { + final controller = NumberEditingTextController.integer( + locale: 'en', + value: 500, + ); + expect(controller.value.text, '500'); + expect(controller.number, 500); + }); + + test('custom group separator', () { + final controller = NumberEditingTextController.integer( + locale: 'en', + groupSeparator: '.', + ); + controller.number = 1000000; + expect(controller.value.text, '1.000.000'); + }); + + test('German locale grouping', () { + final controller = NumberEditingTextController.integer(locale: 'de'); + controller.number = 1234567; + expect(controller.value.text, '1.234.567'); + expect(controller.number, 1234567); + }); + + test('single digit', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = controller.value.copyWith( + text: '5', + selection: const TextSelection.collapsed(offset: 1), + ); + expect(controller.value.text, '5'); + expect(controller.number, 5); + }); }); - test('zero-based currency editing with trailing symbol', () { - final controller = NumberEditingTextController.currency( - currencyName: 'USD', - locale: 'ru', - ); - controller.number = 1; - expect( - controller.value, - const TextEditingValue( - text: '1 \$', - ), - ); - - controller.value = controller.value.copyWith( - text: ' \$', - selection: const TextSelection.collapsed(offset: 0), - ); - expect( - controller.value, - const TextEditingValue( - text: ' \$', - selection: TextSelection.collapsed( - offset: 0, - ), - ), - ); - expect(controller.number, null); + + group('NumberEditingTextController.decimal', () { + test('empty state', () { + final controller = NumberEditingTextController.decimal(); + expect(controller.value.text, ''); + expect(controller.number, null); + }); + + test('set decimal number programmatically', () { + final controller = NumberEditingTextController.decimal( + locale: 'de', + maximumFractionDigits: 6, + ); + controller.number = -100.51241; + expect(controller.value.text, '-100,51241'); + expect(controller.number, -100.51241); + }); + + test('integer provided to decimal controller', () { + final controller = NumberEditingTextController.decimal( + locale: 'de', + maximumFractionDigits: 6, + ); + controller.number = -1100; + expect(controller.value.text, '-1.100'); + expect(controller.number, -1100); + }); + + test('minimum fraction digits enforced', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + minimalFractionDigits: 2, + maximumFractionDigits: 4, + ); + controller.number = 10; + expect(controller.value.text, '10.00'); + expect(controller.number, 10); + }); + + test('trailing zeros removed up to minimum', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + minimalFractionDigits: 1, + maximumFractionDigits: 4, + ); + controller.number = 5.10; + expect(controller.value.text, '5.1'); + }); + + test('zero decimal value', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + maximumFractionDigits: 2, + ); + controller.number = 0; + expect(controller.value.text, '0'); + expect(controller.number, 0); + }); + + test('large decimal with grouping', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + maximumFractionDigits: 2, + ); + controller.number = 1234567.89; + expect(controller.value.text, '1,234,567.89'); + expect(controller.number, 1234567.89); + }); + + test('initial value via constructor', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + maximumFractionDigits: 2, + value: 3.14, + ); + expect(controller.value.text, '3.14'); + expect(controller.number, 3.14); + }); + + test('custom decimal separator', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + decimalSeparator: ',', + maximumFractionDigits: 2, + ); + controller.number = 3.14; + expect(controller.value.text, '3,14'); + }); + + test('custom group separator', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + groupSeparator: ' ', + maximumFractionDigits: 2, + ); + controller.number = 1234567.89; + expect(controller.value.text, '1 234 567.89'); + }); + + test('typing decimal value', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + maximumFractionDigits: 3, + ); + controller.value = controller.value.copyWith( + text: '42.123', + selection: const TextSelection.collapsed(offset: 6), + ); + expect(controller.value.text, '42.123'); + expect(controller.number, 42.123); + }); + + test('excess decimal digits truncated on input', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + maximumFractionDigits: 2, + ); + controller.value = controller.value.copyWith( + text: '1.23456', + selection: const TextSelection.collapsed(offset: 7), + ); + expect(controller.value.text, '1.23'); + expect(controller.number, 1.23); + }); + + test('German locale decimal formatting', () { + final controller = NumberEditingTextController.decimal( + locale: 'de', + maximumFractionDigits: 2, + ); + controller.number = 1234.56; + expect(controller.value.text, '1.234,56'); + expect(controller.number, 1234.56); + }); }); - test( - 'disallow negative input with currency symbol before input', - () { + group('allowNegative', () { + test('negative allowed by default for currency', () { + final controller = NumberEditingTextController.currency( + currencyName: 'USD', + locale: 'en', + ); + controller.value = controller.value.copyWith( + text: '-100', + selection: const TextSelection.collapsed(offset: 4), + ); + expect(controller.number, -100); + }); + + test('negative allowed by default for integer', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = controller.value.copyWith( + text: '-50', + selection: const TextSelection.collapsed(offset: 3), + ); + expect(controller.number, -50); + }); + + test('disallow negative with leading currency symbol', () { final controller = NumberEditingTextController.currency( currencyName: 'USD', locale: 'ja', allowNegative: false, ); - controller.value = controller.value.copyWith( text: '-', selection: const TextSelection.collapsed(offset: 1), ); - expect( - controller.value, - const TextEditingValue( - text: '\$', - selection: TextSelection.collapsed(offset: 1), - ), - ); + expect(controller.value.text, '\$'); controller.value = controller.value.copyWith( text: '\$-', selection: const TextSelection.collapsed(offset: 2), ); - expect( - controller.value, - const TextEditingValue( - text: '\$', - selection: TextSelection.collapsed(offset: 1), - ), - ); - }, - ); + expect(controller.value.text, '\$'); + }); - test( - 'disallow negative input with currency symbol after input', - () { + test('disallow negative with trailing currency symbol', () { final controller = NumberEditingTextController.currency( currencyName: 'UAH', locale: 'uk', allowNegative: false, ); - controller.value = controller.value.copyWith( text: '-', selection: const TextSelection.collapsed(offset: 1), ); - expect( - controller.value, - const TextEditingValue( - text: '', - selection: TextSelection.collapsed(offset: 0), - ), - ); + expect(controller.value.text, ''); controller.value = controller.value.copyWith( text: '0', selection: const TextSelection.collapsed(offset: 1), ); - expect( - controller.value, - const TextEditingValue( - text: '0 ₴', - selection: TextSelection.collapsed(offset: 3), - ), + // Ukrainian locale uses non-breaking space + expect(controller.value.text, '0${_nbsp}₴'); + }); + + test('disallow negative for integer', () { + final controller = NumberEditingTextController.integer( + locale: 'en', + allowNegative: false, ); - }, - ); + controller.value = controller.value.copyWith( + text: '-5', + selection: const TextSelection.collapsed(offset: 2), + ); + expect(controller.number, 5); + }); - test( - 'overriden currency symbol', - () { + test('disallow negative for decimal', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + allowNegative: false, + maximumFractionDigits: 2, + ); + controller.value = controller.value.copyWith( + text: '-1.5', + selection: const TextSelection.collapsed(offset: 4), + ); + expect(controller.number, 1.5); + }); + + test('minus sign only yields zero', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = controller.value.copyWith( + text: '-', + selection: const TextSelection.collapsed(offset: 1), + ); + expect(controller.number, 0); + }); + }); + + group('non-numeric input filtering', () { + test('alphabetic characters stop parsing for integer', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = controller.value.copyWith( + text: '12abc34', + selection: const TextSelection.collapsed(offset: 7), + ); + // parsing stops at 'a', so only '12' is parsed + expect(controller.number, 12); + }); + + test('non-digit input with leading currency symbol', () { + final controller = NumberEditingTextController.currency( + currencyName: 'USD', + locale: 'en', + ); + controller.value = controller.value.copyWith( + text: 'abc', + selection: const TextSelection.collapsed(offset: 3), + ); + // currency symbol is prepended, non-digits remain after the real part + expect(controller.number, null); + }); + + test('only first contiguous digits are parsed', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = controller.value.copyWith( + text: '1@2', + selection: const TextSelection.collapsed(offset: 3), + ); + // stops parsing at '@' + expect(controller.number, 1); + }); + }); + + group('locale handling', () { + test('French locale formatting', () { + final controller = NumberEditingTextController.currency( + currencyName: 'EUR', + locale: 'fr', + ); + controller.number = 1234.56; + expect(controller.number, 1234.56); + expect(controller.value.text.contains('€'), true); + }); + + test('Japanese locale with JPY', () { + final controller = NumberEditingTextController.currency( + currencyName: 'JPY', + locale: 'ja', + allowNegative: false, + ); + controller.number = 1500; + expect(controller.value.text, '¥1,500'); + expect(controller.number, 1500); + }); + + test('Spanish locale with custom currency', () { final controller = NumberEditingTextController.currency( currencyName: 'TRY', locale: 'es_ES', currencySymbol: '₺', - allowNegative: false, ); - controller.number = 6612.54; - expect(controller.value, const TextEditingValue(text: '6.612,54 ₺')); - expect(controller.number, 6612.54); - }, - ); + controller.number = 1000; + expect(controller.value.text.contains('₺'), true); + expect(controller.number, 1000); + }); + + test('Russian locale trailing currency symbol', () { + final controller = NumberEditingTextController.currency( + currencyName: 'RUB', + locale: 'ru', + ); + controller.number = 500; + final text = controller.value.text; + expect(text.contains('500'), true); + expect(controller.number, 500); + }); + }); + + group('number setter edge cases', () { + test('set number updates text and number', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.number = 100; + expect(controller.value.text, '100'); + expect(controller.number, 100); + controller.number = 200; + expect(controller.value.text, '200'); + expect(controller.number, 200); + }); + + test('set number to zero', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.number = 0; + expect(controller.value.text, '0'); + expect(controller.number, 0); + }); + + test('set number then type replaces properly', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.number = 100; + controller.value = controller.value.copyWith( + text: '200', + selection: const TextSelection.collapsed(offset: 3), + ); + expect(controller.value.text, '200'); + expect(controller.number, 200); + }); + + test('repeated set same number keeps same text', () { + final controller = NumberEditingTextController.currency( + currencyName: 'USD', + locale: 'en', + ); + controller.number = 1000; + final formatted = controller.value.text; + controller.number = 1000; + expect(controller.value.text, formatted); + }); + }); + + group('grouping behavior', () { + test('no grouping for small numbers', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.number = 999; + expect(controller.value.text, '999'); + }); + + test('grouping starts at 4 digits', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.number = 1000; + expect(controller.value.text, '1,000'); + }); + + test('multiple groups', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.number = 1000000000; + expect(controller.value.text, '1,000,000,000'); + }); + + test('typing preserves grouping separators', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = controller.value.copyWith( + text: '12345', + selection: const TextSelection.collapsed(offset: 5), + ); + expect(controller.value.text, '12,345'); + expect(controller.number, 12345); + }); + + test('existing group separators are re-formatted', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = controller.value.copyWith( + text: '1,23,456', + selection: const TextSelection.collapsed(offset: 8), + ); + expect(controller.value.text, '123,456'); + expect(controller.number, 123456); + }); + }); + + group('value setter (simulating user input)', () { + test('empty input stays empty', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = const TextEditingValue(text: ''); + expect(controller.number, null); + }); + + test('typing single zero', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = controller.value.copyWith( + text: '0', + selection: const TextSelection.collapsed(offset: 1), + ); + expect(controller.value.text, '0'); + expect(controller.number, 0); + }); + + test('typing leading zeros', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = controller.value.copyWith( + text: '007', + selection: const TextSelection.collapsed(offset: 3), + ); + expect(controller.number, 7); + }); + }); } From 7feda6d2215020d2ca942b6a356e4534b3246588 Mon Sep 17 00:00:00 2001 From: Ilya Nixan Date: Tue, 17 Mar 2026 02:19:22 +0300 Subject: [PATCH 2/4] Add tests for negative numbers and separators --- test/number_editing_controller_test.dart | 127 +++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/test/number_editing_controller_test.dart b/test/number_editing_controller_test.dart index eaa4952..b65f784 100644 --- a/test/number_editing_controller_test.dart +++ b/test/number_editing_controller_test.dart @@ -693,4 +693,131 @@ void main() { expect(controller.number, 7); }); }); + + group('bug fixes', () { + test('negative decimal via typing produces correct number', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + maximumFractionDigits: 2, + ); + controller.value = controller.value.copyWith( + text: '-5.14', + selection: const TextSelection.collapsed(offset: 5), + ); + expect(controller.number, -5.14); + }); + + test('negative decimal via typing with various values', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + maximumFractionDigits: 4, + ); + controller.value = controller.value.copyWith( + text: '-0.5', + selection: const TextSelection.collapsed(offset: 4), + ); + expect(controller.number, -0.5); + + controller.value = controller.value.copyWith( + text: '-100.99', + selection: const TextSelection.collapsed(offset: 7), + ); + expect(controller.number, -100.99); + + controller.value = controller.value.copyWith( + text: '-1.0001', + selection: const TextSelection.collapsed(offset: 7), + ); + expect(controller.number, -1.0001); + }); + + test('negative currency via typing produces correct number', () { + final controller = NumberEditingTextController.currency( + currencyName: 'USD', + locale: 'en', + ); + controller.value = controller.value.copyWith( + text: '-25.75', + selection: const TextSelection.collapsed(offset: 6), + ); + expect(controller.number, -25.75); + }); + + test('group separator at start of input does not crash', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + // Should not throw RangeError + controller.value = controller.value.copyWith( + text: ',5', + selection: const TextSelection.collapsed(offset: 2), + ); + expect(controller.number, isNotNull); + }); + + test('group separator alone does not crash', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = controller.value.copyWith( + text: ',', + selection: const TextSelection.collapsed(offset: 1), + ); + // Should not crash; number may be null or 0 + expect(controller.number, anyOf(isNull, equals(0))); + }); + + test('NaN does not crash', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + expect( + () => controller.number = double.nan, + returnsNormally, + ); + }); + + test('infinity does not crash', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + expect( + () => controller.number = double.infinity, + returnsNormally, + ); + }); + + test('negative infinity does not crash', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + maximumFractionDigits: 2, + ); + expect( + () => controller.number = double.negativeInfinity, + returnsNormally, + ); + }); + + test('typing negative 6+ digit number has correct grouping', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = controller.value.copyWith( + text: '-100000', + selection: const TextSelection.collapsed(offset: 7), + ); + expect(controller.value.text, '-100,000'); + expect(controller.number, -100000); + }); + + test('typing negative 7+ digit number has correct grouping', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = controller.value.copyWith( + text: '-1000000', + selection: const TextSelection.collapsed(offset: 8), + ); + expect(controller.value.text, '-1,000,000'); + expect(controller.number, -1000000); + }); + + test('typing negative 10+ digit number has correct grouping', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = controller.value.copyWith( + text: '-1234567890', + selection: const TextSelection.collapsed(offset: 11), + ); + expect(controller.value.text, '-1,234,567,890'); + expect(controller.number, -1234567890); + }); + }); } From 589f6bc86734f8e0f50d2e80c8017a6eaf8414a1 Mon Sep 17 00:00:00 2001 From: Ilya Nixan Date: Tue, 17 Mar 2026 02:35:39 +0300 Subject: [PATCH 3/4] Fix negative number parsing and grouping --- lib/src/parsed_number_format.dart | 20 +++++++++++++++++++- lib/src/parts.dart | 11 +++++++---- test/number_editing_controller_test.dart | 15 ++++++++------- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/lib/src/parsed_number_format.dart b/lib/src/parsed_number_format.dart index 18246e2..13a95a1 100644 --- a/lib/src/parsed_number_format.dart +++ b/lib/src/parsed_number_format.dart @@ -152,6 +152,7 @@ class ParsedNumberFormat { } num number = 0; + var isNegative = false; for (final part in parts) { final partResult = part.format(result, charPosition); @@ -159,7 +160,21 @@ class ParsedNumberFormat { result = partResult.value; final resultNumber = partResult.number; if (resultNumber != null) { - number += resultNumber; + if (part is RealPart && resultNumber <= 0) { + // Check if the formatted text contains a minus sign + final partText = result.text.substring( + charPosition - partResult.offset, + charPosition, + ); + if (partText.startsWith('-')) { + isNegative = true; + } + } + if (isNegative && part is DecimalPart) { + number -= resultNumber; + } else { + number += resultNumber; + } } else if (part is RealPart) { return FormatResult(result, null); } else if (part is DecimalPart) { @@ -177,6 +192,9 @@ class ParsedNumberFormat { } String formatString(num value) { + if (value is double && (value.isNaN || value.isInfinite)) { + return ''; + } final result = StringBuffer(); for (final part in parts) { diff --git a/lib/src/parts.dart b/lib/src/parts.dart index 0f29ade..dd1bd26 100644 --- a/lib/src/parts.dart +++ b/lib/src/parts.dart @@ -103,7 +103,7 @@ class RealPart extends NumberFormatPart { final nextChar = v.text.length == position + i + 1 ? null : v.text[position + i + 1]; if (g is WithGrouping && char == g.groupingSymbol) { - if (nextChar == null || !nextChar.isDigit) { + if (i == 0 || nextChar == null || !nextChar.isDigit) { finished = true; continue; } @@ -139,11 +139,14 @@ class RealPart extends NumberFormatPart { number = numberText == '-' ? 0 : num.parse(numberText); } if (i != 0 && g is WithGrouping) { - final realPartLength = i; - for (var j = 0; j < realPartLength; j++) { + final hasMinusSign = + v.text[position] == '-' && allowNegative; + final digitLength = hasMinusSign ? i - 1 : i; + final digitStart = hasMinusSign ? 1 : 0; + for (var j = 0; j < digitLength; j++) { if (j != 0 && j % g.groupSize == 0) { v = v.replaced( - TextRange.collapsed(position + realPartLength - j), + TextRange.collapsed(position + digitStart + digitLength - j), g.groupingSymbol, ); i++; diff --git a/test/number_editing_controller_test.dart b/test/number_editing_controller_test.dart index b65f784..8de1f9a 100644 --- a/test/number_editing_controller_test.dart +++ b/test/number_editing_controller_test.dart @@ -52,7 +52,7 @@ void main() { ); controller.number = -5000.5; // DE uses non-breaking space before currency symbol - expect(controller.value.text, '-5.000,5${_nbsp}£'); + expect(controller.value.text, '-5.000,5$_nbsp£'); expect(controller.number, -5000.5); }); @@ -64,7 +64,7 @@ void main() { allowNegative: false, ); controller.number = 6612.54; - expect(controller.value.text, '6.612,54${_nbsp}₺'); + expect(controller.value.text, '6.612,54$_nbsp₺'); expect(controller.number, 6612.54); }); @@ -155,12 +155,12 @@ void main() { ); controller.number = 1; // Russian uses non-breaking space - expect(controller.value.text, '1${_nbsp}\$'); + expect(controller.value.text, '1$_nbsp\$'); controller.value = controller.value.copyWith( - text: '${_nbsp}\$', + text: '$_nbsp\$', selection: const TextSelection.collapsed(offset: 0), ); - expect(controller.value.text, '${_nbsp}\$'); + expect(controller.value.text, '$_nbsp\$'); expect(controller.number, null); }); @@ -468,7 +468,7 @@ void main() { selection: const TextSelection.collapsed(offset: 1), ); // Ukrainian locale uses non-breaking space - expect(controller.value.text, '0${_nbsp}₴'); + expect(controller.value.text, '0$_nbsp₴'); }); test('disallow negative for integer', () { @@ -750,7 +750,8 @@ void main() { text: ',5', selection: const TextSelection.collapsed(offset: 2), ); - expect(controller.number, isNotNull); + // separator at start stops parsing, no digits found + expect(controller.number, anyOf(isNull, equals(0))); }); test('group separator alone does not crash', () { From 4b8efd1856af1afdae817491545ea761cfe3023b Mon Sep 17 00:00:00 2001 From: Ilya Nixan Date: Tue, 17 Mar 2026 03:02:23 +0300 Subject: [PATCH 4/4] Fix static part insertion and update iOS configs --- example/ios/Flutter/AppFrameworkInfo.plist | 2 - example/ios/Runner.xcodeproj/project.pbxproj | 6 +- .../xcshareddata/xcschemes/Runner.xcscheme | 3 + example/ios/Runner/AppDelegate.swift | 9 +- example/ios/Runner/Info.plist | 29 +- example/lib/variants/currency_input.dart | 2 +- lib/src/parts.dart | 13 +- test/number_editing_controller_test.dart | 497 +++++++++++++++++- 8 files changed, 543 insertions(+), 18 deletions(-) diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 7c56964..391a902 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 12.0 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 2e4ec11..99f75ee 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -344,7 +344,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -472,7 +472,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -521,7 +521,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 8e3ca5d..e3773d4 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index b636303..c30b367 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -1,13 +1,16 @@ -import UIKit import Flutter +import UIKit @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index 95022dc..8e68f29 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,29 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -43,9 +68,5 @@ UIViewControllerBasedStatusBarAppearance - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/example/lib/variants/currency_input.dart b/example/lib/variants/currency_input.dart index 2f20034..5e7a37b 100644 --- a/example/lib/variants/currency_input.dart +++ b/example/lib/variants/currency_input.dart @@ -11,7 +11,7 @@ class CurrencyInput extends StatefulWidget { class _CurrencyInputState extends State { final _controller = NumberEditingTextController.currency( currencyName: 'JPY', - allowNegative: false, + allowNegative: true, ); @override diff --git a/lib/src/parts.dart b/lib/src/parts.dart index dd1bd26..8ea4b14 100644 --- a/lib/src/parts.dart +++ b/lib/src/parts.dart @@ -53,7 +53,18 @@ class StaticPart extends NumberFormatPart { while (i < content.length && (position + i) <= v.text.length) { if ((position + i) == v.text.length || content[i] != v.text[position + i]) { - v = v.replaced(TextRange.collapsed(position + i), content[i]); + // Check if the expected character exists right after (user inserted + // a character before the static content). If so, remove the inserted + // character instead of inserting the expected one. + if ((position + i + 1) < v.text.length && + content[i] == v.text[position + i + 1]) { + v = v.replaced( + TextRange(start: position + i, end: position + i + 1), + '', + ); + } else { + v = v.replaced(TextRange.collapsed(position + i), content[i]); + } } i++; } diff --git a/test/number_editing_controller_test.dart b/test/number_editing_controller_test.dart index 8de1f9a..a7e8a1a 100644 --- a/test/number_editing_controller_test.dart +++ b/test/number_editing_controller_test.dart @@ -5,6 +5,9 @@ import 'package:number_editing_controller/number_editing_controller.dart'; /// Non-breaking space used by many locales as a group separator. const _nbsp = '\u00A0'; +/// Narrow no-break space used by some locales (e.g. French) as a group separator. +const _nnbsp = '\u202F'; + void main() { group('NumberEditingTextController.currency', () { test('empty state', () { @@ -548,8 +551,8 @@ void main() { locale: 'fr', ); controller.number = 1234.56; + expect(controller.value.text, '1${_nnbsp}234,56$_nbsp€'); expect(controller.number, 1234.56); - expect(controller.value.text.contains('€'), true); }); test('Japanese locale with JPY', () { @@ -570,7 +573,7 @@ void main() { currencySymbol: '₺', ); controller.number = 1000; - expect(controller.value.text.contains('₺'), true); + expect(controller.value.text, '1.000$_nbsp₺'); expect(controller.number, 1000); }); @@ -580,8 +583,7 @@ void main() { locale: 'ru', ); controller.number = 500; - final text = controller.value.text; - expect(text.contains('500'), true); + expect(controller.value.text, '500$_nbsp₽'); expect(controller.number, 500); }); }); @@ -820,5 +822,492 @@ void main() { expect(controller.value.text, '-1,234,567,890'); expect(controller.number, -1234567890); }); + + test( + 'typing minus before leading currency symbol preserves digits', + () { + final controller = NumberEditingTextController.currency( + currencyName: 'JPY', + locale: 'en', + ); + controller.value = controller.value.copyWith( + text: '123456', + selection: const TextSelection.collapsed(offset: 6), + ); + expect(controller.value.text, '¥123,456'); + expect(controller.number, 123456); + + // Simulate caret at position 0 and typing '-' + // The minus is removed by the static part, but digits must be preserved + controller.value = controller.value.copyWith( + text: '-¥123,456', + selection: const TextSelection.collapsed(offset: 1), + ); + expect(controller.value.text, '¥123,456'); + expect(controller.number, 123456); + }, + ); + + test( + 'typing minus before leading currency symbol with allowNegative false', + () { + final controller = NumberEditingTextController.currency( + currencyName: 'JPY', + locale: 'en', + allowNegative: false, + ); + controller.value = controller.value.copyWith( + text: '123456', + selection: const TextSelection.collapsed(offset: 6), + ); + expect(controller.value.text, '¥123,456'); + expect(controller.number, 123456); + + // Simulate caret at position 0 and typing '-' — should be rejected + controller.value = controller.value.copyWith( + text: '-¥123,456', + selection: const TextSelection.collapsed(offset: 1), + ); + // Should not duplicate currency symbol or lose digits + expect(controller.value.text, '¥123,456'); + expect(controller.number, 123456); + }, + ); + }); + + group('caret position', () { + group('integer controller', () { + test('caret at end after typing single digit', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = controller.value.copyWith( + text: '5', + selection: const TextSelection.collapsed(offset: 1), + ); + expect(controller.value.text, '5'); + expect(controller.value.selection.baseOffset, 1); + }); + + test('caret at end after typing 4 digits adds grouping', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = controller.value.copyWith( + text: '1234', + selection: const TextSelection.collapsed(offset: 4), + ); + // '1234' -> '1,234', caret moves to end (5) + expect(controller.value.text, '1,234'); + expect(controller.value.selection.baseOffset, 5); + }); + + test('caret at end after typing 5 digits', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = controller.value.copyWith( + text: '1,2345', + selection: const TextSelection.collapsed(offset: 6), + ); + // '1,2345' -> '12,345', caret at end (6) + expect(controller.value.text, '12,345'); + expect(controller.value.selection.baseOffset, 6); + }); + + test('caret at end after typing 6 digits', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = controller.value.copyWith( + text: '12,3456', + selection: const TextSelection.collapsed(offset: 7), + ); + // '12,3456' -> '123,456', caret at end (7) + expect(controller.value.text, '123,456'); + expect(controller.value.selection.baseOffset, 7); + }); + + test('caret preserved in middle after deleting digit', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + // Start with '12,345', delete '2' -> '1,345' with cursor at 1 + controller.value = controller.value.copyWith( + text: '12345', + selection: const TextSelection.collapsed(offset: 5), + ); + controller.value = controller.value.copyWith( + text: '1,345', + selection: const TextSelection.collapsed(offset: 1), + ); + expect(controller.value.text, '1,345'); + expect(controller.value.selection.baseOffset, 2); + }); + + test('caret at end after deleting last digit', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.value = controller.value.copyWith( + text: '12345', + selection: const TextSelection.collapsed(offset: 5), + ); + // Delete last digit: '12,345' -> '12,34' cursor at 5 + controller.value = controller.value.copyWith( + text: '12,34', + selection: const TextSelection.collapsed(offset: 5), + ); + // Re-grouped as '1,234', cursor at end (5) + expect(controller.value.text, '1,234'); + expect(controller.value.selection.baseOffset, 5); + }); + }); + + group('currency controller', () { + test('caret after currency symbol when typing first digit', () { + final controller = NumberEditingTextController.currency( + currencyName: 'USD', + locale: 'en', + ); + controller.value = controller.value.copyWith( + text: '1', + selection: const TextSelection.collapsed(offset: 1), + ); + // '$1', caret at end (2) + expect(controller.value.text, '\$1'); + expect(controller.value.selection.baseOffset, 2); + }); + + test('caret at end after typing multiple digits', () { + final controller = NumberEditingTextController.currency( + currencyName: 'USD', + locale: 'en', + ); + controller.value = controller.value.copyWith( + text: '\$12', + selection: const TextSelection.collapsed(offset: 3), + ); + expect(controller.value.text, '\$12'); + expect(controller.value.selection.baseOffset, 3); + }); + + test('caret at end after grouping added', () { + final controller = NumberEditingTextController.currency( + currencyName: 'USD', + locale: 'en', + ); + controller.value = controller.value.copyWith( + text: '\$1234', + selection: const TextSelection.collapsed(offset: 5), + ); + // '$1234' -> '$1,234', caret at end (6) + expect(controller.value.text, '\$1,234'); + expect(controller.value.selection.baseOffset, 6); + }); + + test('caret at end after typing 5 digits', () { + final controller = NumberEditingTextController.currency( + currencyName: 'USD', + locale: 'en', + ); + controller.value = controller.value.copyWith( + text: '\$1,2345', + selection: const TextSelection.collapsed(offset: 7), + ); + // '$1,2345' -> '$12,345', caret at end (7) + expect(controller.value.text, '\$12,345'); + expect(controller.value.selection.baseOffset, 7); + }); + }); + + group('decimal controller', () { + test('caret at end after typing decimal', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + maximumFractionDigits: 2, + ); + controller.value = controller.value.copyWith( + text: '1.5', + selection: const TextSelection.collapsed(offset: 3), + ); + expect(controller.value.text, '1.5'); + expect(controller.value.selection.baseOffset, 3); + }); + + test('caret at end after typing two decimal digits', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + maximumFractionDigits: 2, + ); + controller.value = controller.value.copyWith( + text: '1.50', + selection: const TextSelection.collapsed(offset: 4), + ); + expect(controller.value.text, '1.50'); + expect(controller.value.selection.baseOffset, 4); + }); + + test('caret at end after typing integer part with grouping', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + maximumFractionDigits: 2, + ); + controller.value = controller.value.copyWith( + text: '12345.67', + selection: const TextSelection.collapsed(offset: 8), + ); + // '12345.67' -> '12,345.67', caret at end (9) + expect(controller.value.text, '12,345.67'); + expect(controller.value.selection.baseOffset, 9); + }); + }); + }); + + group('non-common formatting patterns', () { + group('Korean Won (no decimal currency)', () { + test('set number programmatically', () { + final controller = NumberEditingTextController.currency( + currencyName: 'KRW', + locale: 'ko', + ); + controller.number = 50000; + expect(controller.value.text, '₩50,000'); + expect(controller.number, 50000); + }); + + test('type large amount', () { + final controller = NumberEditingTextController.currency( + currencyName: 'KRW', + locale: 'ko', + ); + controller.value = controller.value.copyWith( + text: '1000000', + selection: const TextSelection.collapsed(offset: 7), + ); + expect(controller.value.text, '₩1,000,000'); + expect(controller.number, 1000000); + }); + }); + + group('Brazilian Real (R\$ multi-char symbol)', () { + test('set number programmatically', () { + final controller = NumberEditingTextController.currency( + currencyName: 'BRL', + locale: 'pt_BR', + ); + controller.number = 1234.56; + expect(controller.value.text, 'R\$$_nbsp${'1.234,56'}'); + expect(controller.number, 1234.56); + }); + + test('type digits', () { + final controller = NumberEditingTextController.currency( + currencyName: 'BRL', + locale: 'pt_BR', + ); + controller.value = controller.value.copyWith( + text: '5000', + selection: const TextSelection.collapsed(offset: 4), + ); + expect(controller.value.text, 'R\$$_nbsp${'5.000'}'); + expect(controller.number, 5000); + }); + }); + + group('Turkish Lira (multi-char symbol TL)', () { + test('set number programmatically', () { + final controller = NumberEditingTextController.currency( + currencyName: 'TRY', + locale: 'tr', + ); + controller.number = 1234.56; + expect(controller.value.text, 'TL1.234,56'); + expect(controller.number, 1234.56); + }); + }); + + group('Danish Krone (trailing multi-char symbol)', () { + test('set number programmatically', () { + final controller = NumberEditingTextController.currency( + currencyName: 'DKK', + locale: 'da', + ); + controller.number = 1234.56; + expect(controller.value.text, '1.234,56${_nbsp}kr'); + expect(controller.number, 1234.56); + }); + + test('negative value', () { + final controller = NumberEditingTextController.currency( + currencyName: 'DKK', + locale: 'da', + ); + controller.number = -500; + expect(controller.value.text, '-500${_nbsp}kr'); + expect(controller.number, -500); + }); + }); + + group('custom separator configurations', () { + test('no grouping with empty separator', () { + final controller = NumberEditingTextController.integer( + locale: 'en', + groupSeparator: '', + ); + controller.number = 1234567; + expect(controller.value.text, '1234567'); + expect(controller.number, 1234567); + }); + + test('same character for group and decimal separator', () { + final controller = NumberEditingTextController.currency( + currencyName: 'USD', + locale: 'en', + groupSeparator: '.', + decimalSeparator: '.', + ); + controller.number = 1234.56; + expect(controller.value.text, '\$1.234.56'); + expect(controller.number, 1234.56); + }); + + test('space as group separator', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + groupSeparator: ' ', + maximumFractionDigits: 2, + ); + controller.number = 1234567.89; + expect(controller.value.text, '1 234 567.89'); + expect(controller.number, 1234567.89); + }); + + test('custom decimal separator with comma', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + decimalSeparator: ',', + maximumFractionDigits: 2, + ); + controller.number = 42.99; + expect(controller.value.text, '42,99'); + expect(controller.number, 42.99); + }); + }); + + group('decimal precision edge cases', () { + test('high minimum fraction digits', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + minimalFractionDigits: 3, + maximumFractionDigits: 8, + ); + controller.number = 1.5; + expect(controller.value.text, '1.500'); + expect(controller.number, 1.5); + }); + + test('high minimum fraction digits with integer value', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + minimalFractionDigits: 3, + maximumFractionDigits: 8, + ); + controller.number = 1.0; + expect(controller.value.text, '1.000'); + }); + + test('maximum fraction digits truncates with rounding', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + minimalFractionDigits: 3, + maximumFractionDigits: 8, + ); + controller.number = 1.123456789; + // toStringAsFixed(8) rounds the 9th digit + expect(controller.value.text, '1.12345679'); + }); + + test('very small decimal', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + maximumFractionDigits: 10, + ); + controller.number = 0.0000001; + expect(controller.value.text, '0.0000001'); + expect(controller.number, 0.0000001); + }); + + test('typing more decimals than max is truncated', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + minimalFractionDigits: 0, + maximumFractionDigits: 3, + ); + controller.value = controller.value.copyWith( + text: '1.12345', + selection: const TextSelection.collapsed(offset: 7), + ); + expect(controller.value.text, '1.123'); + expect(controller.number, 1.123); + }); + }); + + group('German locale (dot grouping, comma decimal)', () { + test('integer typing with dot grouping', () { + final controller = NumberEditingTextController.integer(locale: 'de'); + controller.value = controller.value.copyWith( + text: '1234567', + selection: const TextSelection.collapsed(offset: 7), + ); + expect(controller.value.text, '1.234.567'); + expect(controller.number, 1234567); + }); + + test('decimal with comma separator', () { + final controller = NumberEditingTextController.decimal( + locale: 'de', + maximumFractionDigits: 2, + ); + controller.value = controller.value.copyWith( + text: '1234,56', + selection: const TextSelection.collapsed(offset: 7), + ); + expect(controller.value.text, '1.234,56'); + expect(controller.number, 1234.56); + }); + + test('negative decimal with German formatting', () { + final controller = NumberEditingTextController.decimal( + locale: 'de', + maximumFractionDigits: 2, + ); + controller.value = controller.value.copyWith( + text: '-1234,56', + selection: const TextSelection.collapsed(offset: 8), + ); + expect(controller.value.text, '-1.234,56'); + expect(controller.number, -1234.56); + }); + }); + + group('Hindi locale with INR', () { + test('set number programmatically', () { + final controller = NumberEditingTextController.currency( + currencyName: 'INR', + locale: 'hi', + ); + controller.number = 50000; + expect(controller.value.text, '₹50,000'); + expect(controller.number, 50000); + }); + }); + + group('negative zero', () { + test('negative zero decimal', () { + final controller = NumberEditingTextController.decimal( + locale: 'en', + maximumFractionDigits: 2, + ); + controller.number = -0.0; + // -0.0 has no significant digits, should format as '0' + expect(controller.value.text, '0'); + }); + + test('negative zero integer', () { + final controller = NumberEditingTextController.integer(locale: 'en'); + controller.number = -0; + expect(controller.value.text, '0'); + expect(controller.number, 0); + }); + }); }); }