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..c0661266 --- /dev/null +++ b/test/shared/widgets/querya_ui_kit_behavior_test.dart @@ -0,0 +1,258 @@ +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'; + +/// 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', + (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()); + + expect(tester.getSize(_spinnerBox()), 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(_spinnerBox()), 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); + }); + }); +} 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..7bfa07d4 --- /dev/null +++ b/test/shared/widgets/querya_ui_kit_theme_switch_test.dart @@ -0,0 +1,213 @@ +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_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; + }, + ), + ), + ), + ); + // `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) => + 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() {}