From c7cfdc471cfc91a3c365642791fdb63d00aedc33 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Tue, 10 Feb 2026 18:56:18 -0700 Subject: [PATCH 1/3] Add tests demonstrating RawAutocomplete onSubmitted bug RawAutocomplete.onFieldSubmitted auto-selects the first option when called, which means pressing "Done" on an empty tag value field re-populates it with the first NSI suggestion. These tests prove the bug exists (unguarded path) and verify the fix (guarded path). Co-Authored-By: Claude Opus 4.6 --- test/widgets/nsi_tag_value_field_test.dart | 177 +++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 test/widgets/nsi_tag_value_field_test.dart diff --git a/test/widgets/nsi_tag_value_field_test.dart b/test/widgets/nsi_tag_value_field_test.dart new file mode 100644 index 0000000..cf87847 --- /dev/null +++ b/test/widgets/nsi_tag_value_field_test.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Tests for the tag value field clearing bug and fix. +/// +/// Root cause: RawAutocomplete.onFieldSubmitted auto-selects the first option +/// when called, and NSITagValueField's optionsBuilder returns ALL suggestions +/// when the text is empty. So pressing "Done" on the keyboard with an empty +/// field auto-selects the first NSI suggestion, making the value "pop back in". +void main() { + // Helper to build a RawAutocomplete widget tree that mirrors NSITagValueField + Widget buildAutocompleteField({ + required TextEditingController controller, + required FocusNode focusNode, + required List suggestions, + required ValueChanged onChanged, + required ValueChanged onSelected, + required bool guardOnSubmitted, + }) { + return MaterialApp( + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(16), + child: RawAutocomplete( + textEditingController: controller, + focusNode: focusNode, + optionsBuilder: (TextEditingValue textEditingValue) { + if (suggestions.isEmpty) { + return const Iterable.empty(); + } + if (textEditingValue.text.isEmpty) return suggestions; + return suggestions + .where((s) => s.contains(textEditingValue.text)); + }, + onSelected: (String selection) => onSelected(selection), + fieldViewBuilder: + (context, controller, focusNode, onFieldSubmitted) { + return TextField( + controller: controller, + focusNode: focusNode, + onChanged: onChanged, + onSubmitted: guardOnSubmitted + ? (_) { + if (controller.text.isNotEmpty) { + onFieldSubmitted(); + } + } + : (_) => onFieldSubmitted(), + ); + }, + optionsViewBuilder: (context, onSelected, options) { + return Align( + alignment: Alignment.topLeft, + child: Material( + child: SizedBox( + height: 200, + child: ListView( + children: options + .map((o) => ListTile( + title: Text(o), + onTap: () => onSelected(o), + )) + .toList(), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } + + group('NSITagValueField onSubmitted behavior', () { + testWidgets( + 'unguarded onFieldSubmitted auto-selects first suggestion on empty submit', + (tester) async { + String reportedValue = 'Hikvision'; + final controller = TextEditingController(text: 'Hikvision'); + final focusNode = FocusNode(); + + await tester.pumpWidget(buildAutocompleteField( + controller: controller, + focusNode: focusNode, + suggestions: ['Axis', 'Dahua', 'Hikvision'], + onChanged: (v) => reportedValue = v, + onSelected: (v) => reportedValue = v, + guardOnSubmitted: false, // old behavior + )); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + + // Clear the field + await tester.enterText(find.byType(TextField), ''); + await tester.pump(); + expect(controller.text, equals('')); + + // Press "Done" on the keyboard + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + // Demonstrates the bug: first suggestion ('Axis') is auto-selected + expect(controller.text, equals('Axis'), + reason: 'Unguarded onFieldSubmitted auto-selects first suggestion'); + }); + + testWidgets( + 'guarded onFieldSubmitted keeps field empty on submit', + (tester) async { + String reportedValue = 'Hikvision'; + final controller = TextEditingController(text: 'Hikvision'); + final focusNode = FocusNode(); + + await tester.pumpWidget(buildAutocompleteField( + controller: controller, + focusNode: focusNode, + suggestions: ['Axis', 'Dahua', 'Hikvision'], + onChanged: (v) => reportedValue = v, + onSelected: (v) => reportedValue = v, + guardOnSubmitted: true, // fixed behavior + )); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + + // Clear the field + await tester.enterText(find.byType(TextField), ''); + await tester.pump(); + expect(controller.text, equals('')); + expect(reportedValue, equals('')); + + // Press "Done" on the keyboard + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + // Field stays empty + expect(controller.text, equals(''), + reason: 'Field should stay empty after pressing Done'); + expect(reportedValue, equals(''), + reason: 'Parent should not receive a new value'); + }); + + testWidgets( + 'guarded onFieldSubmitted still auto-completes when text is present', + (tester) async { + String reportedValue = ''; + final controller = TextEditingController(); + final focusNode = FocusNode(); + + await tester.pumpWidget(buildAutocompleteField( + controller: controller, + focusNode: focusNode, + suggestions: ['Axis', 'Dahua', 'Hikvision'], + onChanged: (v) => reportedValue = v, + onSelected: (v) => reportedValue = v, + guardOnSubmitted: true, // fixed behavior + )); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + + // Type a partial match + await tester.enterText(find.byType(TextField), 'Axi'); + await tester.pump(); + + // Press "Done" → should auto-complete to 'Axis' + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(controller.text, equals('Axis'), + reason: 'Should auto-complete partial text to first matching suggestion'); + expect(reportedValue, equals('Axis')); + }); + }); +} From af42e18f6e0619ced390dd26b77603e857f33a30 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Tue, 10 Feb 2026 18:56:28 -0700 Subject: [PATCH 2/3] Guard onFieldSubmitted on non-empty text to prevent cleared values reappearing When a user clears a tag value and presses Done, RawAutocomplete's onFieldSubmitted auto-selects the first option from the suggestions list. Since optionsBuilder returns all suggestions for empty text, this causes the cleared value to reappear. Guarding the call on non-empty text prevents the auto-selection while preserving autocomplete behavior when the user has typed a partial match. Co-Authored-By: Claude Opus 4.6 --- lib/widgets/nsi_tag_value_field.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/widgets/nsi_tag_value_field.dart b/lib/widgets/nsi_tag_value_field.dart index 51685f1..d05f752 100644 --- a/lib/widgets/nsi_tag_value_field.dart +++ b/lib/widgets/nsi_tag_value_field.dart @@ -132,7 +132,14 @@ class _NSITagValueFieldState extends State { onChanged: (value) { widget.onChanged(value); }, - onSubmitted: (_) => onFieldSubmitted(), + onSubmitted: (_) { + // Only auto-complete when there's text to match against. + // Otherwise, pressing Done on an empty field would auto-select + // the first suggestion, preventing users from clearing values. + if (controller.text.isNotEmpty) { + onFieldSubmitted(); + } + }, ); }, optionsViewBuilder: ( From 75014be485669f959b3d967b85ada12ceccd0370 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Tue, 10 Feb 2026 19:01:09 -0700 Subject: [PATCH 3/3] Remove simulation tests that don't exercise production code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RawAutocomplete tests proved the pattern and fix but never instantiated NSITagValueField itself — no actual coverage gained. PR #36 replaces the widget entirely, so investing in testability here isn't worthwhile. Co-Authored-By: Claude Opus 4.6 --- test/widgets/nsi_tag_value_field_test.dart | 177 --------------------- 1 file changed, 177 deletions(-) delete mode 100644 test/widgets/nsi_tag_value_field_test.dart diff --git a/test/widgets/nsi_tag_value_field_test.dart b/test/widgets/nsi_tag_value_field_test.dart deleted file mode 100644 index cf87847..0000000 --- a/test/widgets/nsi_tag_value_field_test.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// Tests for the tag value field clearing bug and fix. -/// -/// Root cause: RawAutocomplete.onFieldSubmitted auto-selects the first option -/// when called, and NSITagValueField's optionsBuilder returns ALL suggestions -/// when the text is empty. So pressing "Done" on the keyboard with an empty -/// field auto-selects the first NSI suggestion, making the value "pop back in". -void main() { - // Helper to build a RawAutocomplete widget tree that mirrors NSITagValueField - Widget buildAutocompleteField({ - required TextEditingController controller, - required FocusNode focusNode, - required List suggestions, - required ValueChanged onChanged, - required ValueChanged onSelected, - required bool guardOnSubmitted, - }) { - return MaterialApp( - home: Scaffold( - body: Padding( - padding: const EdgeInsets.all(16), - child: RawAutocomplete( - textEditingController: controller, - focusNode: focusNode, - optionsBuilder: (TextEditingValue textEditingValue) { - if (suggestions.isEmpty) { - return const Iterable.empty(); - } - if (textEditingValue.text.isEmpty) return suggestions; - return suggestions - .where((s) => s.contains(textEditingValue.text)); - }, - onSelected: (String selection) => onSelected(selection), - fieldViewBuilder: - (context, controller, focusNode, onFieldSubmitted) { - return TextField( - controller: controller, - focusNode: focusNode, - onChanged: onChanged, - onSubmitted: guardOnSubmitted - ? (_) { - if (controller.text.isNotEmpty) { - onFieldSubmitted(); - } - } - : (_) => onFieldSubmitted(), - ); - }, - optionsViewBuilder: (context, onSelected, options) { - return Align( - alignment: Alignment.topLeft, - child: Material( - child: SizedBox( - height: 200, - child: ListView( - children: options - .map((o) => ListTile( - title: Text(o), - onTap: () => onSelected(o), - )) - .toList(), - ), - ), - ), - ); - }, - ), - ), - ), - ); - } - - group('NSITagValueField onSubmitted behavior', () { - testWidgets( - 'unguarded onFieldSubmitted auto-selects first suggestion on empty submit', - (tester) async { - String reportedValue = 'Hikvision'; - final controller = TextEditingController(text: 'Hikvision'); - final focusNode = FocusNode(); - - await tester.pumpWidget(buildAutocompleteField( - controller: controller, - focusNode: focusNode, - suggestions: ['Axis', 'Dahua', 'Hikvision'], - onChanged: (v) => reportedValue = v, - onSelected: (v) => reportedValue = v, - guardOnSubmitted: false, // old behavior - )); - - await tester.tap(find.byType(TextField)); - await tester.pump(); - - // Clear the field - await tester.enterText(find.byType(TextField), ''); - await tester.pump(); - expect(controller.text, equals('')); - - // Press "Done" on the keyboard - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pump(); - - // Demonstrates the bug: first suggestion ('Axis') is auto-selected - expect(controller.text, equals('Axis'), - reason: 'Unguarded onFieldSubmitted auto-selects first suggestion'); - }); - - testWidgets( - 'guarded onFieldSubmitted keeps field empty on submit', - (tester) async { - String reportedValue = 'Hikvision'; - final controller = TextEditingController(text: 'Hikvision'); - final focusNode = FocusNode(); - - await tester.pumpWidget(buildAutocompleteField( - controller: controller, - focusNode: focusNode, - suggestions: ['Axis', 'Dahua', 'Hikvision'], - onChanged: (v) => reportedValue = v, - onSelected: (v) => reportedValue = v, - guardOnSubmitted: true, // fixed behavior - )); - - await tester.tap(find.byType(TextField)); - await tester.pump(); - - // Clear the field - await tester.enterText(find.byType(TextField), ''); - await tester.pump(); - expect(controller.text, equals('')); - expect(reportedValue, equals('')); - - // Press "Done" on the keyboard - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pump(); - - // Field stays empty - expect(controller.text, equals(''), - reason: 'Field should stay empty after pressing Done'); - expect(reportedValue, equals(''), - reason: 'Parent should not receive a new value'); - }); - - testWidgets( - 'guarded onFieldSubmitted still auto-completes when text is present', - (tester) async { - String reportedValue = ''; - final controller = TextEditingController(); - final focusNode = FocusNode(); - - await tester.pumpWidget(buildAutocompleteField( - controller: controller, - focusNode: focusNode, - suggestions: ['Axis', 'Dahua', 'Hikvision'], - onChanged: (v) => reportedValue = v, - onSelected: (v) => reportedValue = v, - guardOnSubmitted: true, // fixed behavior - )); - - await tester.tap(find.byType(TextField)); - await tester.pump(); - - // Type a partial match - await tester.enterText(find.byType(TextField), 'Axi'); - await tester.pump(); - - // Press "Done" → should auto-complete to 'Axis' - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pump(); - - expect(controller.text, equals('Axis'), - reason: 'Should auto-complete partial text to first matching suggestion'); - expect(reportedValue, equals('Axis')); - }); - }); -}