From b55a7203b09fc1fbfeaad5ee782ccc9f44eafc8b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 20:39:21 +0300 Subject: [PATCH 1/5] test(ui): theme-switch suite for the UI Kit components (#1043) --- .../querya_ui_kit_theme_switch_test.dart | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 test/shared/widgets/querya_ui_kit_theme_switch_test.dart diff --git a/test/shared/widgets/querya_ui_kit_theme_switch_test.dart b/test/shared/widgets/querya_ui_kit_theme_switch_test.dart new file mode 100644 index 00000000..55af621b --- /dev/null +++ b/test/shared/widgets/querya_ui_kit_theme_switch_test.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/security/connection_environment.dart'; +import 'package:querya_desktop/core/theme/querya_semantic_palette.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/theme/querya_workbench_theme.dart'; +import 'package:querya_desktop/shared/widgets/connection_environment_badge.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +/// The theme values the components should be using at the moment. +class _Captured { + _Captured(this.workbench, this.palette, this.foreground); + final QueryaWorkbenchTheme workbench; + final QueryaSemanticPalette palette; + final material.Color foreground; +} + +void main() { + _Captured? captured; + + /// One tree holding a sample of every themed UI Kit component. The same + /// widget instances are rebuilt when the theme changes, as in the app. + const kit = _UiKitSample(); + + Future pumpKit(WidgetTester tester, QueryaTheme theme) async { + await tester.pumpWidget( + queryaThemeTestShell( + data: theme, + child: material.Scaffold( + body: material.Builder( + builder: (context) { + captured = _Captured( + context.workbench, + context.semanticPalette, + Theme.of(context).colorScheme.foreground, + ); + return kit; + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + material.Color? textColor(WidgetTester tester, String text) => + tester.widget(find.text(text)).style?.color; + + material.Color? iconColor(WidgetTester tester, material.IconData icon) => + material.IconTheme.of(tester.element(find.byIcon(icon))).color; + + material.Color spinnerColor(WidgetTester tester) => tester + .widget( + find.byType(material.CircularProgressIndicator), + ) + .valueColor! + .value!; + + /// Asserts every sampled component uses the colors of the active theme. + void expectThemed(WidgetTester tester) { + final wb = captured!.workbench; + final palette = captured!.palette; + + expect(spinnerColor(tester), wb.accent, reason: 'spinner'); + + expect(textColor(tester, 'Connected'), wb.success, reason: 'success badge'); + expect(textColor(tester, 'Failed'), wb.destructive, reason: 'error badge'); + expect(textColor(tester, 'Beta'), wb.warning, reason: 'warning badge'); + expect(textColor(tester, 'PK'), palette.type1, reason: 'primary key'); + expect(textColor(tester, 'FK'), palette.type2, reason: 'foreign key'); + + expect(textColor(tester, 'PROD'), wb.destructive, reason: 'environment'); + expect(textColor(tester, 'STAGING'), wb.warning, reason: 'environment'); + expect(textColor(tester, 'DEV'), wb.success, reason: 'environment'); + + expect(iconColor(tester, material.Icons.star_rounded), wb.accent, + reason: 'active icon button'); + expect(iconColor(tester, material.Icons.delete_rounded), wb.destructive, + reason: 'destructive active icon button'); + expect(iconColor(tester, material.Icons.add_rounded), captured!.foreground, + reason: 'plain icon button'); + + expect(textColor(tester, 'Nothing here yet'), wb.mutedForeground, + reason: 'empty state description'); + } + + group('themes used by the test', () { + test('dark and light differ, so a switch is observable', () { + expect( + QueryaTheme.darkDefault.workbench.canvas, + isNot(QueryaTheme.lightDefault.workbench.canvas), + ); + expect( + QueryaTheme.darkDefault.workbench.mutedForeground, + isNot(QueryaTheme.lightDefault.workbench.mutedForeground), + ); + }); + }); + + group('UI Kit follows the active theme', () { + testWidgets('dark theme', (tester) async { + await pumpKit(tester, QueryaTheme.darkDefault); + + expectThemed(tester); + }); + + testWidgets('light theme', (tester) async { + await pumpKit(tester, QueryaTheme.lightDefault); + + expectThemed(tester); + }); + + testWidgets('switching dark -> light updates every component in place', + (tester) async { + await pumpKit(tester, QueryaTheme.darkDefault); + final darkMuted = textColor(tester, 'Nothing here yet'); + expectThemed(tester); + + await pumpKit(tester, QueryaTheme.lightDefault); + + expectThemed(tester); + expect(textColor(tester, 'Nothing here yet'), isNot(darkMuted), + reason: 'muted text changed with the theme'); + expect(tester.takeException(), isNull); + }); + + testWidgets('switching light -> dark and back is stable', (tester) async { + await pumpKit(tester, QueryaTheme.lightDefault); + await pumpKit(tester, QueryaTheme.darkDefault); + expectThemed(tester); + + await pumpKit(tester, QueryaTheme.lightDefault); + expectThemed(tester); + expect(tester.takeException(), isNull); + }); + + testWidgets('component state survives a theme switch', (tester) async { + await pumpKit(tester, QueryaTheme.darkDefault); + await tester.enterText(find.byType(material.TextField), 'orders'); + await tester.pump(); + + await pumpKit(tester, QueryaTheme.lightDefault); + + expect(find.text('orders'), findsOneWidget); + }); + }); +} + +class _UiKitSample extends material.StatelessWidget { + const _UiKitSample(); + + @override + material.Widget build(material.BuildContext context) { + return const material.SingleChildScrollView( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + QueryaSpinner(size: QueryaSpinnerSize.md), + QueryaBadge.status('Connected', status: QueryaBadgeStatus.success), + QueryaBadge.status('Failed', status: QueryaBadgeStatus.error), + QueryaBadge.status('Beta', status: QueryaBadgeStatus.warning), + QueryaBadge.primaryKey(), + QueryaBadge.foreignKey(), + ConnectionEnvironmentBadge( + environment: ConnectionEnvironment.production, + ), + ConnectionEnvironmentBadge( + environment: ConnectionEnvironment.staging, + ), + ConnectionEnvironmentBadge( + environment: ConnectionEnvironment.development, + ), + material.Row( + children: [ + QueryaIconButton( + icon: material.Icon(material.Icons.star_rounded), + isActive: true, + onPressed: _noop, + ), + QueryaIconButton( + icon: material.Icon(material.Icons.delete_rounded), + isActive: true, + isDestructive: true, + onPressed: _noop, + ), + QueryaIconButton( + icon: material.Icon(material.Icons.add_rounded), + onPressed: _noop, + ), + ], + ), + material.SizedBox( + width: 300, + height: 220, + child: QueryaEmptyState( + title: 'Empty', + description: 'Nothing here yet', + icon: material.Icon(material.Icons.inbox_rounded), + ), + ), + material.SizedBox(width: 300, child: QueryaSearchField()), + ], + ), + ); + } +} + +void _noop() {} From 6602c54807c998a1c5ccb7f9814c95e132d2f99d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 20:42:28 +0300 Subject: [PATCH 2/5] test(ui): behavior tests for search field, icon button, spinner and confirm dialog (#1043) --- .../widgets/querya_ui_kit_behavior_test.dart | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 test/shared/widgets/querya_ui_kit_behavior_test.dart diff --git a/test/shared/widgets/querya_ui_kit_behavior_test.dart b/test/shared/widgets/querya_ui_kit_behavior_test.dart new file mode 100644 index 00000000..3c394682 --- /dev/null +++ b/test/shared/widgets/querya_ui_kit_behavior_test.dart @@ -0,0 +1,252 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('QueryaSearchField', () { + testWidgets('Escape clears the text and reports an empty query', + (tester) async { + final changes = []; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: QueryaSearchField( + autofocus: true, + debounceDuration: Duration.zero, + onChanged: changes.add, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(material.TextField), 'orders'); + await tester.pump(); + expect(changes, ['orders']); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + + expect( + tester.widget(find.byType(material.TextField)) + .controller!.text, + isEmpty, + ); + expect(changes.last, ''); + }); + + testWidgets('only the last keystroke inside the debounce window is reported', + (tester) async { + final changes = []; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: QueryaSearchField( + debounceDuration: const Duration(milliseconds: 300), + onChanged: changes.add, + ), + ), + ), + ); + + await tester.enterText(find.byType(material.TextField), 'o'); + await tester.pump(const Duration(milliseconds: 100)); + await tester.enterText(find.byType(material.TextField), 'or'); + await tester.pump(const Duration(milliseconds: 100)); + await tester.enterText(find.byType(material.TextField), 'ord'); + expect(changes, isEmpty); + + await tester.pump(const Duration(milliseconds: 350)); + + expect(changes, ['ord']); + }); + + testWidgets('the shortcut hint is replaced by the clear button once typing', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: QueryaSearchField(shortcutHint: 'Ctrl+K'), + ), + ), + ); + expect(find.text('Ctrl+K'), findsOneWidget); + expect(find.byIcon(material.Icons.close_rounded), findsNothing); + + await tester.enterText(find.byType(material.TextField), 'x'); + await tester.pump(); + + expect(find.text('Ctrl+K'), findsNothing); + expect(find.byIcon(material.Icons.close_rounded), findsOneWidget); + }); + + testWidgets('an external controller is not disposed with the field', + (tester) async { + final controller = material.TextEditingController(text: 'kept'); + addTearDown(controller.dispose); + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: QueryaSearchField(controller: controller), + ), + ), + ); + expect(find.text('kept'), findsOneWidget); + + await tester.pumpWidget( + queryaThemeTestShell(child: const material.SizedBox.shrink()), + ); + + expect(controller.text, 'kept'); + controller.text = 'still usable'; + }); + }); + + group('QueryaIconButton', () { + testWidgets('without onPressed the button is disabled', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: QueryaIconButton( + icon: material.Icon(material.Icons.add_rounded), + ), + ), + ), + ); + + final ink = tester.widget(find.byType(material.InkWell)); + expect(ink.onTap, isNull); + }); + + testWidgets('a tooltip message is attached when given', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: QueryaIconButton( + icon: const material.Icon(material.Icons.add_rounded), + tooltip: 'Add row', + onPressed: () {}, + ), + ), + ), + ); + + expect(find.byTooltip('Add row'), findsOneWidget); + }); + }); + + group('QueryaSpinner', () { + Future pump( + WidgetTester tester, + QueryaSpinner spinner, + ) async { + await tester.pumpWidget( + queryaThemeTestShell(child: material.Scaffold(body: spinner)), + ); + return tester.widget( + find.byType(material.CircularProgressIndicator), + ); + } + + testWidgets('stroke width grows with the preset', (tester) async { + expect((await pump(tester, const QueryaSpinner(size: QueryaSpinnerSize.sm))) + .strokeWidth, + 2.0); + expect((await pump(tester, const QueryaSpinner(size: QueryaSpinnerSize.md))) + .strokeWidth, + 2.5); + expect((await pump(tester, const QueryaSpinner(size: QueryaSpinnerSize.lg))) + .strokeWidth, + 3.0); + }); + + testWidgets('medium is the default size', (tester) async { + await pump(tester, const QueryaSpinner()); + + final box = tester.getSize(find.byType(material.SizedBox).first); + expect(box, const material.Size(20, 20)); + }); + + testWidgets('custom dimension, stroke and color override the preset', + (tester) async { + final indicator = await pump( + tester, + const QueryaSpinner( + size: QueryaSpinnerSize.sm, + customDimension: 40, + strokeWidth: 5, + color: material.Color(0xFF123456), + ), + ); + + expect(indicator.strokeWidth, 5); + expect(indicator.valueColor!.value, const material.Color(0xFF123456)); + expect(tester.getSize(find.byType(material.SizedBox).first), + const material.Size(40, 40)); + }); + }); + + group('QueryaConfirmDialog.show', () { + Future open( + WidgetTester tester, + void Function(bool?) onResult, { + bool isDestructive = false, + }) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.Scaffold( + body: material.Center( + child: material.TextButton( + onPressed: () async => onResult( + await QueryaConfirmDialog.show( + context: context, + title: 'Delete row?', + message: 'This cannot be undone.', + confirmLabel: 'Delete', + isDestructive: isDestructive, + ), + ), + child: const material.Text('open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + testWidgets('confirming resolves to true', (tester) async { + bool? result; + await open(tester, (r) => result = r); + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + + expect(result, isTrue); + expect(find.text('Delete row?'), findsNothing); + }); + + testWidgets('cancelling resolves to false', (tester) async { + bool? result; + await open(tester, (r) => result = r); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(result, isFalse); + }); + + testWidgets('a destructive confirmation uses the destructive button', + (tester) async { + await open(tester, (_) {}, isDestructive: true); + + expect(find.byType(DestructiveButton), findsOneWidget); + }); + }); +} From afda999cc860f65c97db9034b81cb0fe042f58cd Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 20:42:52 +0300 Subject: [PATCH 3/5] test(ui): locate the spinner box by ancestry (#1043) --- .../widgets/querya_ui_kit_behavior_test.dart | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/test/shared/widgets/querya_ui_kit_behavior_test.dart b/test/shared/widgets/querya_ui_kit_behavior_test.dart index 3c394682..c0661266 100644 --- a/test/shared/widgets/querya_ui_kit_behavior_test.dart +++ b/test/shared/widgets/querya_ui_kit_behavior_test.dart @@ -5,6 +5,14 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import '../../support/querya_theme_test_shell.dart'; +/// The sized box that wraps the progress indicator. +Finder _spinnerBox() => find + .ancestor( + of: find.byType(material.CircularProgressIndicator), + matching: find.byType(material.SizedBox), + ) + .first; + void main() { group('QueryaSearchField', () { testWidgets('Escape clears the text and reports an empty query', @@ -166,8 +174,7 @@ void main() { testWidgets('medium is the default size', (tester) async { await pump(tester, const QueryaSpinner()); - final box = tester.getSize(find.byType(material.SizedBox).first); - expect(box, const material.Size(20, 20)); + expect(tester.getSize(_spinnerBox()), const material.Size(20, 20)); }); testWidgets('custom dimension, stroke and color override the preset', @@ -184,8 +191,7 @@ void main() { expect(indicator.strokeWidth, 5); expect(indicator.valueColor!.value, const material.Color(0xFF123456)); - expect(tester.getSize(find.byType(material.SizedBox).first), - const material.Size(40, 40)); + expect(tester.getSize(_spinnerBox()), const material.Size(40, 40)); }); }); From 820d208265a88e8e61fe16550757c136eab74aff Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 20:48:03 +0300 Subject: [PATCH 4/5] test(ui): drop unnecessary import in theme-switch test --- test/shared/widgets/querya_ui_kit_theme_switch_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/test/shared/widgets/querya_ui_kit_theme_switch_test.dart b/test/shared/widgets/querya_ui_kit_theme_switch_test.dart index 55af621b..1b716c24 100644 --- a/test/shared/widgets/querya_ui_kit_theme_switch_test.dart +++ b/test/shared/widgets/querya_ui_kit_theme_switch_test.dart @@ -3,7 +3,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/security/connection_environment.dart'; import 'package:querya_desktop/core/theme/querya_semantic_palette.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/theme/querya_workbench_theme.dart'; import 'package:querya_desktop/shared/widgets/connection_environment_badge.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; From 1692d2a4f24291e135834fa9a09309c74289c0dd Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 22:31:06 +0300 Subject: [PATCH 5/5] test(ui): do not pumpAndSettle with an animating spinner in the theme-switch test --- test/shared/widgets/querya_ui_kit_theme_switch_test.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/shared/widgets/querya_ui_kit_theme_switch_test.dart b/test/shared/widgets/querya_ui_kit_theme_switch_test.dart index 1b716c24..7bfa07d4 100644 --- a/test/shared/widgets/querya_ui_kit_theme_switch_test.dart +++ b/test/shared/widgets/querya_ui_kit_theme_switch_test.dart @@ -42,7 +42,10 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + // `pumpAndSettle` never returns here: the spinner animates forever. A + // second of frames is enough for the theme transition. + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); } material.Color? textColor(WidgetTester tester, String text) =>