diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index a81d5a15dc..7cb4ad9571 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -235,7 +235,7 @@ jobs: ulimit -c unlimited && \ env CTEST_OUTPUT_ON_FAILURE=1 \ LD_LIBRARY_PATH=/root/stage/usr/lib/x86_64-linux-gnu/:/root/stage/lib/:/root/parts/multipass/build/lib/ \ - /root/parts/multipass/build/bin/multipass_tests" + ctest -V" - name: Measure coverage id: measure-coverage diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 2122b180de..592450c66e 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -254,7 +254,7 @@ jobs: trap 'echo "MULTIPASS_TESTS_EXIT_CODE=$?" >> $GITHUB_ENV' EXIT # Set soft limit for the core file size (512MiB) ulimit -c 1048576 - bin/multipass_tests + ctest -V - name: Upload test coredump uses: actions/upload-artifact@v7 diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 77287fc10a..00e30ccf14 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -176,7 +176,7 @@ jobs: - name: Test working-directory: ${{ env.BUILD_DIR }} run: | - bin/multipass_tests + ctest -V - name: Package id: cmake-package diff --git a/CMakeLists.txt b/CMakeLists.txt index a3bf438293..1873704216 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -351,7 +351,22 @@ if(cmake_build_type_lower MATCHES "coverage") ${CMAKE_BINARY_DIR}'/*' --output-file coverage.cleaned COMMAND ${CMAKE_COMMAND} -E remove coverage.info - COMMAND ${GENHTML} -o coverage coverage.cleaned + + # Merge Flutter test coverage with C++ coverage + COMMAND ${CMAKE_COMMAND} -E chdir ${CMAKE_SOURCE_DIR}/src/client/gui + ${FLUTTER_EXECUTABLE} test --coverage + COMMAND ${LCOV} + --remove ${CMAKE_SOURCE_DIR}/src/client/gui/coverage/lcov.info + --ignore-errors unused + '*/lib/generated/*' + '*/lib/l10n/*' + --output-file ${CMAKE_SOURCE_DIR}/src/client/gui/coverage/lcov.info + COMMAND ${LCOV} + --add-tracefile coverage.cleaned + --add-tracefile ${CMAKE_SOURCE_DIR}/src/client/gui/coverage/lcov.info + --output-file coverage.cleaned + + COMMAND ${GENHTML} --source-directory ${CMAKE_SOURCE_DIR}/src/client/gui -o coverage coverage.cleaned ) endif() endif() @@ -369,6 +384,14 @@ add_subdirectory(src) if(MULTIPASS_ENABLE_TESTS) enable_testing() add_subdirectory(tests/unit) + + if(MULTIPASS_ENABLE_FLUTTER_GUI) + add_test( + NAME multipass_gui_tests + COMMAND ${FLUTTER_EXECUTABLE} test + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/src/client/gui + ) + endif() endif() include(packaging/cpack.cmake OPTIONAL) diff --git a/src/client/gui/lib/vm_details/memory_slider.dart b/src/client/gui/lib/vm_details/memory_slider.dart index 57948c1a7b..a6d953c2b0 100644 --- a/src/client/gui/lib/vm_details/memory_slider.dart +++ b/src/client/gui/lib/vm_details/memory_slider.dart @@ -15,6 +15,7 @@ class MemorySlider extends StatefulWidget { final int max; final bool enabled; final int sysMax; + final String Function(int) memoryFormatter; const MemorySlider({ super.key, @@ -25,6 +26,7 @@ class MemorySlider extends StatefulWidget { required this.max, this.enabled = true, required this.sysMax, + this.memoryFormatter = humanReadableMemory, }); @override @@ -130,9 +132,9 @@ class _MemorySliderState extends State { const SizedBox(height: 5), Row( children: [ - Text(humanReadableMemory(widget.min)), + Text(widget.memoryFormatter(widget.min)), Spacer(), - Text(humanReadableMemory(widget.max)), + Text(widget.memoryFormatter(widget.max)), ], ), if ((field.value ?? widget.min) > widget.sysMax) ...[ diff --git a/src/client/gui/test/catalogue/catalogue_screen_test.dart b/src/client/gui/test/catalogue/catalogue_screen_test.dart new file mode 100644 index 0000000000..3fab7314ff --- /dev/null +++ b/src/client/gui/test/catalogue/catalogue_screen_test.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:flutter/material.dart' hide ImageInfo; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/catalogue/catalogue.dart'; +import 'package:multipass_gui/grpc_client.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; + +import '../helpers.dart'; + +Widget _scope(Widget child) { + return withFakeSvgAssetBundle( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: child, + ), + ); +} + +void main() { + group('CatalogueScreen', () { + testWidgets('shows a loading indicator while images are fetching', + (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + imagesProvider.overrideWith( + (ref) => Completer>().future, + ), + ], + child: _scope(const CatalogueScreen()), + ), + ); + // One pump to trigger the first build before the future resolves. + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('shows an error message when images fail to load', + (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + imagesProvider.overrideWith((ref) async { + throw Exception('Connection failed'); + }), + ], + child: _scope(const CatalogueScreen()), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Connection failed'), findsOneWidget); + }); + + testWidgets('shows a refresh button when images fail to load', + (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + imagesProvider.overrideWith((ref) async { + throw Exception('Load error'); + }), + ], + child: _scope(const CatalogueScreen()), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Refresh'), findsOneWidget); + }); + + testWidgets('shows the welcome title when images are successfully loaded', + (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + imagesProvider.overrideWith((ref) async => []), + ], + child: _scope(const CatalogueScreen()), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Welcome to Multipass'), findsOneWidget); + }); + }); +} diff --git a/src/client/gui/test/catalogue/catalogue_test.dart b/src/client/gui/test/catalogue/catalogue_test.dart new file mode 100644 index 0000000000..09d6428ea9 --- /dev/null +++ b/src/client/gui/test/catalogue/catalogue_test.dart @@ -0,0 +1,357 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/catalogue/catalogue.dart'; +import 'package:multipass_gui/catalogue/launch_form.dart'; +import 'package:multipass_gui/grpc_client.dart'; + +void main() { + group('sortImages', () { + test('returns empty list for empty input', () { + expect(sortImages([]), isEmpty); + }); + + test('returns single item list unchanged', () { + final images = [ + ImageInfo( + os: 'Ubuntu', + release: '22.04', + codename: 'jammy', + aliases: ['22.04']), + ]; + final result = sortImages(images); + expect(result.length, equals(1)); + expect(result.first.release, equals('22.04')); + }); + + test('LTS image comes first when present', () { + final images = [ + ImageInfo( + os: 'Ubuntu', + release: '22.04', + codename: 'jammy', + aliases: ['22.04']), + ImageInfo( + os: 'Ubuntu', + release: '24.04', + codename: 'noble', + aliases: ['24.04', 'lts']), + ImageInfo( + os: 'Ubuntu', + release: '20.04', + codename: 'focal', + aliases: ['20.04']), + ]; + final result = sortImages(images); + expect(result.first.aliases, contains('lts')); + }); + + test('Ubuntu releases are sorted newest-first after LTS', () { + final images = [ + ImageInfo( + os: 'Ubuntu', + release: '20.04', + codename: 'focal', + aliases: ['20.04']), + ImageInfo( + os: 'Ubuntu', + release: '24.04', + codename: 'noble', + aliases: ['24.04', 'lts']), + ImageInfo( + os: 'Ubuntu', + release: '22.04', + codename: 'jammy', + aliases: ['22.04']), + ImageInfo( + os: 'Ubuntu', + release: '23.10', + codename: 'mantic', + aliases: ['23.10']), + ]; + final result = sortImages(images); + // lts first + expect(result[0].aliases, contains('lts')); + // then newest non-lts Ubuntu releases + expect(result[1].release, equals('23.10')); + expect(result[2].release, equals('22.04')); + expect(result[3].release, equals('20.04')); + }); + + test('devel image comes after Ubuntu releases', () { + final images = [ + ImageInfo( + os: 'Ubuntu', + release: '24.04', + codename: 'noble', + aliases: ['24.04', 'lts']), + ImageInfo( + os: 'Ubuntu', + release: '22.04', + codename: 'jammy', + aliases: ['22.04']), + ImageInfo( + os: 'Ubuntu', + release: '25.04', + codename: 'plucky', + aliases: ['25.04', 'devel']), + ]; + final result = sortImages(images); + expect(result[0].aliases, contains('lts')); + expect(result[1].release, equals('22.04')); + expect(result[2].aliases, contains('devel')); + }); + + test('core images come after devel', () { + final images = [ + ImageInfo( + os: 'Ubuntu', + release: '24.04', + codename: 'noble', + aliases: ['24.04', 'lts']), + ImageInfo( + os: 'Ubuntu', + release: '25.04', + codename: 'plucky', + aliases: ['25.04', 'devel']), + ImageInfo( + os: 'Ubuntu', release: '22', codename: '', aliases: ['core22']), + ImageInfo( + os: 'Ubuntu', release: '18', codename: '', aliases: ['core18']), + ]; + final result = sortImages(images); + expect(result[0].aliases, contains('lts')); + expect(result[1].aliases, contains('devel')); + expect(result[2].aliases, contains('core22')); + expect(result[3].aliases, contains('core18')); + }); + + test('third-party (non-Ubuntu) images come last', () { + final images = [ + ImageInfo( + os: 'Ubuntu', + release: '24.04', + codename: 'noble', + aliases: ['24.04', 'lts']), + ImageInfo( + os: 'CentOS', release: '8', codename: '', aliases: ['centos']), + ImageInfo( + os: 'Ubuntu', + release: '22.04', + codename: 'jammy', + aliases: ['22.04']), + ]; + final result = sortImages(images); + expect(result[0].aliases, contains('lts')); + expect(result[1].release, equals('22.04')); + expect(result[2].os, equals('CentOS')); + }); + + test('no LTS at front when LTS is absent', () { + final images = [ + ImageInfo( + os: 'Ubuntu', + release: '22.04', + codename: 'jammy', + aliases: ['22.04']), + ImageInfo( + os: 'Ubuntu', + release: '23.10', + codename: 'mantic', + aliases: ['23.10']), + ]; + final result = sortImages(images); + expect(result.first.aliases, isNot(contains('lts'))); + expect(result[0].release, equals('23.10')); + expect(result[1].release, equals('22.04')); + }); + + test('no devel after Ubuntu images when devel is absent', () { + final images = [ + ImageInfo( + os: 'Ubuntu', + release: '24.04', + codename: 'noble', + aliases: ['24.04', 'lts']), + ImageInfo( + os: 'Ubuntu', + release: '22.04', + codename: 'jammy', + aliases: ['22.04']), + ImageInfo( + os: 'Ubuntu', release: '22', codename: '', aliases: ['core22']), + ]; + final result = sortImages(images); + expect(result[0].aliases, contains('lts')); + expect(result[1].release, equals('22.04')); + expect(result[2].aliases, contains('core22')); + expect(result.any((i) => i.aliases.contains('devel')), isFalse); + }); + + test('third-party images are sorted newest-first among themselves', () { + final images = [ + ImageInfo( + os: 'CentOS', release: '7', codename: '', aliases: ['centos7']), + ImageInfo( + os: 'Alpine', release: '3.20', codename: '', aliases: ['alpine']), + ImageInfo( + os: 'CentOS', release: '8', codename: '', aliases: ['centos8']), + ]; + final result = sortImages(images); + final releases = result.map((i) => i.release).toList(); + // Sorting is lexicographic (string compareTo), so '8' > '7' > '3.20' + expect(releases, equals(['8', '7', '3.20'])); + }); + + test('full ordering: LTS, Ubuntu releases, devel, core, third-party', () { + final images = [ + ImageInfo( + os: 'Ubuntu', release: '16', codename: '', aliases: ['core16']), + ImageInfo( + os: 'Alpine', release: '3.20', codename: '', aliases: ['alpine']), + ImageInfo( + os: 'Ubuntu', + release: '25.04', + codename: 'plucky', + aliases: ['25.04', 'devel']), + ImageInfo( + os: 'Ubuntu', + release: '22.04', + codename: 'jammy', + aliases: ['22.04']), + ImageInfo( + os: 'Ubuntu', + release: '24.04', + codename: 'noble', + aliases: ['24.04', 'lts']), + ImageInfo( + os: 'Ubuntu', release: '22', codename: '', aliases: ['core22']), + ]; + final result = sortImages(images); + expect(result[0].aliases, contains('lts')); + expect(result[1].release, equals('22.04')); + expect(result[2].aliases, contains('devel')); + expect(result[3].aliases, contains('core22')); + expect(result[4].aliases, contains('core16')); + expect(result[5].os, equals('Alpine')); + }); + }); + + group('imageName', () { + test('combines os, release, and codename for non-core image', () { + final image = ImageInfo( + os: 'Ubuntu', + release: '24.04', + codename: 'noble', + aliases: ['24.04', 'lts'], + ); + expect(imageName(image), equals('Ubuntu 24.04 noble')); + }); + + test('omits codename for image with alias "core"', () { + final image = ImageInfo( + os: 'Ubuntu', + release: '20.04', + codename: 'focal', + aliases: ['20.04', 'core'], + ); + expect(imageName(image), equals('Ubuntu 20.04')); + }); + + test('omits codename for image with alias "core18"', () { + final image = ImageInfo( + os: 'Ubuntu', + release: '18', + codename: '', + aliases: ['core18'], + ); + expect(imageName(image), equals('Ubuntu 18')); + }); + + test('omits codename for image with alias "core22"', () { + final image = ImageInfo( + os: 'Ubuntu', + release: '22', + codename: '', + aliases: ['core22'], + ); + expect(imageName(image), equals('Ubuntu 22')); + }); + + test('includes codename for non-core devel image', () { + final image = ImageInfo( + os: 'Ubuntu', + release: '25.04', + codename: 'plucky', + aliases: ['25.04', 'devel'], + ); + expect(imageName(image), equals('Ubuntu 25.04 plucky')); + }); + }); + + group('SelectedImageNotifier', () { + test('initial state is null', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + container.listen(selectedImageProvider('key1'), (_, __) {}); + + expect(container.read(selectedImageProvider('key1')), isNull); + }); + + test('set(image) updates state', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + container.listen(selectedImageProvider('key1'), (_, __) {}); + + final image = ImageInfo( + os: 'Ubuntu', + release: '24.04', + codename: 'noble', + aliases: ['24.04', 'lts'], + ); + container.read(selectedImageProvider('key1').notifier).set(image); + + expect(container.read(selectedImageProvider('key1')), equals(image)); + }); + + test('set(image) replaces previous state', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + container.listen(selectedImageProvider('key1'), (_, __) {}); + + final first = ImageInfo( + os: 'Ubuntu', + release: '22.04', + codename: 'jammy', + aliases: ['22.04'], + ); + final second = ImageInfo( + os: 'Ubuntu', + release: '24.04', + codename: 'noble', + aliases: ['24.04', 'lts'], + ); + container.read(selectedImageProvider('key1').notifier).set(first); + container.read(selectedImageProvider('key1').notifier).set(second); + + expect(container.read(selectedImageProvider('key1')), equals(second)); + }); + + test('different keys have independent state', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + container.listen(selectedImageProvider('key1'), (_, __) {}); + container.listen(selectedImageProvider('key2'), (_, __) {}); + + final image = ImageInfo( + os: 'Ubuntu', + release: '24.04', + codename: 'noble', + aliases: ['24.04', 'lts'], + ); + container.read(selectedImageProvider('key1').notifier).set(image); + + expect(container.read(selectedImageProvider('key1')), equals(image)); + expect(container.read(selectedImageProvider('key2')), isNull); + }); + }); +} diff --git a/src/client/gui/test/catalogue/image_card_test.dart b/src/client/gui/test/catalogue/image_card_test.dart new file mode 100644 index 0000000000..424d4eddb9 --- /dev/null +++ b/src/client/gui/test/catalogue/image_card_test.dart @@ -0,0 +1,253 @@ +import 'package:flutter/material.dart' hide ImageInfo; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/catalogue/image_card.dart'; +import 'package:multipass_gui/catalogue/launch_form.dart'; +import 'package:multipass_gui/grpc_client.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; + +import '../helpers.dart'; + +ImageInfo makeImage({ + String os = 'Ubuntu', + String release = '24.04', + String codename = 'noble', + List aliases = const ['24.04', 'lts'], +}) => + ImageInfo(os: os, release: release, codename: codename, aliases: aliases); + +Widget buildApp(ImageCard card) => withFakeSvgAssetBundle( + ProviderScope( + overrides: [ + randomNameProvider.overrideWith((ref) => 'test-vm'), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 500, + height: 600, + child: card, + ), + ), + ), + ), + ); + +void main() { + group('ImageCard', () { + group('title', () { + testWidgets('shows "Ubuntu Server" for Ubuntu images', (tester) async { + final image = makeImage(os: 'Ubuntu'); + await tester.pumpWidget(buildApp(ImageCard( + parentImage: image, + versions: [image], + width: 500, + imageKey: 'k', + ))); + await tester.pumpAndSettle(); + + expect(find.text('Ubuntu Server'), findsOneWidget); + }); + + testWidgets('shows "Ubuntu Core" when alias contains "core"', + (tester) async { + final image = makeImage( + os: 'Ubuntu', + release: '22', + codename: '', + aliases: ['core22'], + ); + await tester.pumpWidget(buildApp(ImageCard( + parentImage: image, + versions: [image], + width: 500, + imageKey: 'k', + ))); + await tester.pumpAndSettle(); + + expect(find.text('Ubuntu Core'), findsOneWidget); + }); + + testWidgets('shows "Debian" for Debian images', (tester) async { + final image = makeImage( + os: 'Debian', + release: '12', + codename: 'bookworm', + aliases: ['debian'], + ); + await tester.pumpWidget(buildApp(ImageCard( + parentImage: image, + versions: [image], + width: 500, + imageKey: 'k', + ))); + await tester.pumpAndSettle(); + + expect(find.text('Debian'), findsOneWidget); + }); + + testWidgets('shows "Fedora" for Fedora images', (tester) async { + final image = makeImage( + os: 'Fedora', + release: '39', + codename: 'fedora', + aliases: ['fedora'], + ); + await tester.pumpWidget(buildApp(ImageCard( + parentImage: image, + versions: [image], + width: 500, + imageKey: 'k', + ))); + await tester.pumpAndSettle(); + + expect(find.text('Fedora'), findsOneWidget); + }); + + testWidgets('shows OS name for unrecognised OS', (tester) async { + final image = makeImage( + os: 'Alpine', + release: '3.20', + codename: '', + aliases: ['alpine'], + ); + await tester.pumpWidget(buildApp(ImageCard( + parentImage: image, + versions: [image], + width: 500, + imageKey: 'k', + ))); + await tester.pumpAndSettle(); + + expect(find.text('Alpine'), findsOneWidget); + }); + }); + + group('version label', () { + testWidgets('shows "release (codename)" when they differ', + (tester) async { + final image = makeImage(release: '24.04', codename: 'noble'); + await tester.pumpWidget(buildApp(ImageCard( + parentImage: image, + versions: [image], + width: 500, + imageKey: 'k', + ))); + await tester.pumpAndSettle(); + + expect(find.text('24.04 (noble)'), findsOneWidget); + }); + + testWidgets('shows release only when release equals codename', + (tester) async { + final image = makeImage( + release: 'core20', + codename: 'core20', + aliases: ['core20'], + ); + await tester.pumpWidget(buildApp(ImageCard( + parentImage: image, + versions: [image], + width: 500, + imageKey: 'k', + ))); + await tester.pumpAndSettle(); + + expect(find.text('core20'), findsOneWidget); + }); + }); + + group('version dropdown', () { + testWidgets('is disabled when only one version is provided', + (tester) async { + final image = makeImage(); + await tester.pumpWidget(buildApp(ImageCard( + parentImage: image, + versions: [image], + width: 500, + imageKey: 'k', + ))); + await tester.pumpAndSettle(); + + final dropdown = tester.widget>( + find.byType(DropdownButton), + ); + expect(dropdown.onChanged, isNull); + }); + + testWidgets('is enabled when multiple versions are provided', + (tester) async { + final v1 = + makeImage(release: '22.04', codename: 'jammy', aliases: ['22.04']); + final v2 = makeImage( + release: '24.04', codename: 'noble', aliases: ['24.04', 'lts']); + await tester.pumpWidget(buildApp(ImageCard( + parentImage: v2, + versions: [v1, v2], + width: 500, + imageKey: 'k', + ))); + await tester.pumpAndSettle(); + + final dropdown = tester.widget>( + find.byType(DropdownButton), + ); + expect(dropdown.onChanged, isNotNull); + }); + + testWidgets('selecting a version updates the displayed label', + (tester) async { + final v1 = + makeImage(release: '22.04', codename: 'jammy', aliases: ['22.04']); + final v2 = makeImage( + release: '24.04', codename: 'noble', aliases: ['24.04', 'lts']); + await tester.pumpWidget(buildApp(ImageCard( + parentImage: v2, + versions: [v1, v2], + width: 500, + imageKey: 'k', + ))); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(DropdownButton)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('22.04 (jammy)').last); + await tester.pumpAndSettle(); + + expect(find.text('22.04 (jammy)'), findsOneWidget); + }); + }); + + group('buttons', () { + testWidgets('Launch button is present', (tester) async { + final image = makeImage(); + await tester.pumpWidget(buildApp(ImageCard( + parentImage: image, + versions: [image], + width: 500, + imageKey: 'k', + ))); + await tester.pumpAndSettle(); + + expect(find.text('Launch'), findsOneWidget); + }); + + testWidgets('Configure button is present', (tester) async { + final image = makeImage(); + await tester.pumpWidget(buildApp(ImageCard( + parentImage: image, + versions: [image], + width: 500, + imageKey: 'k', + ))); + await tester.pumpAndSettle(); + + expect(find.text('Configure'), findsOneWidget); + }); + }); + }); +} diff --git a/src/client/gui/test/catalogue/launch_form_providers_test.dart b/src/client/gui/test/catalogue/launch_form_providers_test.dart new file mode 100644 index 0000000000..049740a55e --- /dev/null +++ b/src/client/gui/test/catalogue/launch_form_providers_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/catalogue/launch_form.dart'; +import 'package:multipass_gui/grpc_client.dart'; + +void main() { + group('LaunchingImageNotifier', () { + late ProviderContainer container; + + setUp(() { + container = ProviderContainer(); + }); + + tearDown(() { + container.dispose(); + }); + + ImageInfo state() => container.read(launchingImageProvider); + + test('initial state is an empty ImageInfo', () { + expect(state(), equals(ImageInfo())); + }); + + test('set() updates state to the given ImageInfo', () { + final image = ImageInfo( + os: 'Ubuntu', + release: '24.04', + codename: 'noble', + aliases: ['24.04', 'lts'], + ); + + container.read(launchingImageProvider.notifier).set(image); + + expect(state(), equals(image)); + }); + + test('set() called twice replaces state with the latest value', () { + final first = + ImageInfo(os: 'Ubuntu', release: '22.04', aliases: ['22.04']); + final second = + ImageInfo(os: 'Ubuntu', release: '24.04', aliases: ['24.04']); + + container.read(launchingImageProvider.notifier).set(first); + container.read(launchingImageProvider.notifier).set(second); + + expect(state(), equals(second)); + }); + }); +} diff --git a/src/client/gui/test/catalogue/launching_vms_test.dart b/src/client/gui/test/catalogue/launching_vms_test.dart new file mode 100644 index 0000000000..9fc6372a00 --- /dev/null +++ b/src/client/gui/test/catalogue/launching_vms_test.dart @@ -0,0 +1,212 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/grpc_client.dart'; +import 'package:multipass_gui/providers.dart'; + +void main() { + group('LaunchingVmsNotifier', () { + late ProviderContainer container; + + setUp(() { + container = ProviderContainer(); + }); + + tearDown(() { + container.dispose(); + }); + + BuiltList state() => container.read(launchingVmsProvider); + + LaunchingVmsNotifier notifier() => + container.read(launchingVmsProvider.notifier); + + test('initial state is an empty BuiltList', () { + expect(state(), isEmpty); + expect(state(), isA>()); + }); + + test('add() appends a DetailedInfoItem with correct fields', () { + final request = LaunchRequest( + instanceName: 'my-vm', + numCores: 2, + diskSpace: '10G', + memSize: '4G', + image: 'ubuntu', + ); + + notifier().add(request); + + expect(state(), hasLength(1)); + final item = state().first; + expect(item.name, equals('my-vm')); + expect(item.cpuCount, equals('2')); + expect(item.diskTotal, equals('10G')); + expect(item.memoryTotal, equals('4G')); + expect(item.instanceInfo.currentRelease, equals('ubuntu')); + }); + + test('add() called twice appends both items', () { + final request1 = LaunchRequest( + instanceName: 'vm-one', + numCores: 1, + diskSpace: '5G', + memSize: '1G', + image: 'ubuntu', + ); + final request2 = LaunchRequest( + instanceName: 'vm-two', + numCores: 4, + diskSpace: '20G', + memSize: '8G', + image: 'noble', + ); + + notifier().add(request1); + notifier().add(request2); + + expect(state(), hasLength(2)); + expect(state()[0].name, equals('vm-one')); + expect(state()[1].name, equals('vm-two')); + }); + + test('remove() removes the item with the given name', () { + notifier().add( + LaunchRequest( + instanceName: 'target-vm', + numCores: 2, + diskSpace: '10G', + memSize: '4G', + image: 'ubuntu', + ), + ); + expect(state(), hasLength(1)); + + notifier().remove('target-vm'); + + expect(state(), isEmpty); + }); + + test('remove() with nonexistent name leaves list unchanged', () { + notifier().add( + LaunchRequest( + instanceName: 'existing-vm', + numCores: 2, + diskSpace: '10G', + memSize: '4G', + image: 'ubuntu', + ), + ); + expect(state(), hasLength(1)); + + notifier().remove('nonexistent'); + + expect(state(), hasLength(1)); + expect(state().first.name, equals('existing-vm')); + }); + + test('add() then remove() leaves an empty list', () { + notifier().add( + LaunchRequest( + instanceName: 'temp-vm', + numCores: 2, + diskSpace: '10G', + memSize: '4G', + image: 'ubuntu', + ), + ); + notifier().remove('temp-vm'); + + expect(state(), isEmpty); + }); + + test('updateShouldNotify returns true when lists differ', () { + final list1 = BuiltList(); + final list2 = BuiltList([ + DetailedInfoItem(name: 'vm1'), + ]); + + expect(notifier().updateShouldNotify(list1, list2), isTrue); + }); + + test('updateShouldNotify returns false for the same list object', () { + final list1 = BuiltList(); + + expect(notifier().updateShouldNotify(list1, list1), isFalse); + }); + }); + + group('isLaunchingProvider', () { + late ProviderContainer container; + + setUp(() { + container = ProviderContainer(); + }); + + tearDown(() { + container.dispose(); + }); + + LaunchingVmsNotifier notifier() => + container.read(launchingVmsProvider.notifier); + + test('returns false when launchingVmsProvider is empty', () { + container.listen(isLaunchingProvider('vm1'), (_, __) {}); + + expect(container.read(isLaunchingProvider('vm1')), isFalse); + }); + + test('returns true after adding a vm with that name', () { + container.listen(isLaunchingProvider('vm1'), (_, __) {}); + + notifier().add( + LaunchRequest( + instanceName: 'vm1', + numCores: 2, + diskSpace: '10G', + memSize: '4G', + image: 'ubuntu', + ), + ); + + expect(container.read(isLaunchingProvider('vm1')), isTrue); + }); + + test('returns false for a different vm name', () { + container.listen(isLaunchingProvider('vm1'), (_, __) {}); + container.listen(isLaunchingProvider('vm2'), (_, __) {}); + + notifier().add( + LaunchRequest( + instanceName: 'vm1', + numCores: 2, + diskSpace: '10G', + memSize: '4G', + image: 'ubuntu', + ), + ); + + expect(container.read(isLaunchingProvider('vm1')), isTrue); + expect(container.read(isLaunchingProvider('vm2')), isFalse); + }); + + test('returns false again after removing the vm', () { + container.listen(isLaunchingProvider('vm1'), (_, __) {}); + + notifier().add( + LaunchRequest( + instanceName: 'vm1', + numCores: 2, + diskSpace: '10G', + memSize: '4G', + image: 'ubuntu', + ), + ); + expect(container.read(isLaunchingProvider('vm1')), isTrue); + + notifier().remove('vm1'); + + expect(container.read(isLaunchingProvider('vm1')), isFalse); + }); + }); +} diff --git a/src/client/gui/test/confirmation_dialog_test.dart b/src/client/gui/test/confirmation_dialog_test.dart new file mode 100644 index 0000000000..c0ddf2e2cf --- /dev/null +++ b/src/client/gui/test/confirmation_dialog_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/confirmation_dialog.dart'; + +void main() { + Widget buildDialog({ + required String title, + required Widget body, + required String actionText, + required VoidCallback onAction, + required String inactionText, + required VoidCallback onInaction, + }) { + return MaterialApp( + home: Scaffold( + body: Dialog( + child: ConfirmationDialog( + title: title, + body: body, + actionText: actionText, + onAction: onAction, + inactionText: inactionText, + onInaction: onInaction, + ), + ), + ), + ); + } + + group('ConfirmationDialog', () { + testWidgets('tapping action button invokes onAction', (tester) async { + var actionCalled = false; + + await tester.pumpWidget(buildDialog( + title: 'Delete instance', + body: const Text('Are you sure?'), + actionText: 'Delete', + onAction: () => actionCalled = true, + inactionText: 'Cancel', + onInaction: () {}, + )); + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + + expect(actionCalled, isTrue); + }); + + testWidgets('tapping inaction button invokes onInaction', (tester) async { + var inactionCalled = false; + + await tester.pumpWidget(buildDialog( + title: 'Delete instance', + body: const Text('Are you sure?'), + actionText: 'Delete', + onAction: () {}, + inactionText: 'Cancel', + onInaction: () => inactionCalled = true, + )); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(inactionCalled, isTrue); + }); + + testWidgets('tapping close icon button dismisses the dialog', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showDialog( + context: context, + builder: (_) => ConfirmationDialog( + title: 'Delete instance', + body: const Text('Are you sure?'), + actionText: 'Delete', + onAction: () {}, + inactionText: 'Cancel', + onInaction: () {}, + ), + ), + child: const Text('Open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Delete instance'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + expect(find.text('Delete instance'), findsNothing); + }); + + testWidgets('tapping action button does not invoke onInaction', + (tester) async { + var inactionCalled = false; + + await tester.pumpWidget(buildDialog( + title: 'Delete instance', + body: const Text('Are you sure?'), + actionText: 'Delete', + onAction: () {}, + inactionText: 'Cancel', + onInaction: () => inactionCalled = true, + )); + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + + expect(inactionCalled, isFalse); + }); + + testWidgets('tapping inaction button does not invoke onAction', + (tester) async { + var actionCalled = false; + + await tester.pumpWidget(buildDialog( + title: 'Delete instance', + body: const Text('Are you sure?'), + actionText: 'Delete', + onAction: () => actionCalled = true, + inactionText: 'Cancel', + onInaction: () {}, + )); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(actionCalled, isFalse); + }); + }); +} diff --git a/src/client/gui/test/copyable_text_and_display_field_test.dart b/src/client/gui/test/copyable_text_and_display_field_test.dart new file mode 100644 index 0000000000..b2247391c3 --- /dev/null +++ b/src/client/gui/test/copyable_text_and_display_field_test.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart' hide Tooltip; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/copyable_text.dart'; +import 'package:multipass_gui/display_field.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; + +Widget buildApp(Widget child) => MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), + ); + +void main() { + group('CopyableText', () { + testWidgets('renders plain Text when text is "-"', (tester) async { + await tester.pumpWidget(buildApp(const CopyableText('-'))); + await tester.pumpAndSettle(); + + expect(find.text('-'), findsOneWidget); + expect(find.byType(GestureDetector), findsNothing); + }); + + testWidgets('renders GestureDetector when text is not "-"', (tester) async { + await tester.pumpWidget(buildApp(const CopyableText('192.168.1.1'))); + await tester.pumpAndSettle(); + + expect(find.byType(GestureDetector), findsOneWidget); + }); + + testWidgets('renders MouseRegion with click cursor when text is not "-"', + (tester) async { + await tester.pumpWidget(buildApp(const CopyableText('192.168.1.1'))); + await tester.pumpAndSettle(); + + final mouseRegions = + tester.widgetList(find.byType(MouseRegion)); + expect( + mouseRegions.any((r) => r.cursor == SystemMouseCursors.click), + isTrue, + ); + }); + + testWidgets('displays the provided text value', (tester) async { + const value = 'hello-world'; + await tester.pumpWidget(buildApp(const CopyableText(value))); + await tester.pumpAndSettle(); + + expect(find.text(value), findsOneWidget); + }); + + testWidgets('applies the provided TextStyle', (tester) async { + const style = TextStyle(fontSize: 20); + await tester.pumpWidget( + buildApp(const CopyableText('some text', style: style)), + ); + await tester.pumpAndSettle(); + + final text = tester.widget(find.text('some text')); + expect(text.style, equals(style)); + }); + + testWidgets('GestureDetector remains after tap (state rebuilds correctly)', + (tester) async { + await tester.pumpWidget(buildApp(const CopyableText('10.0.0.1'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(GestureDetector)); + await tester.pumpAndSettle(); + + expect(find.byType(GestureDetector), findsOneWidget); + }); + + testWidgets('no MouseRegion with click cursor when text is "-"', + (tester) async { + await tester.pumpWidget(buildApp(const CopyableText('-'))); + await tester.pumpAndSettle(); + + final mouseRegions = + tester.widgetList(find.byType(MouseRegion)); + expect( + mouseRegions.any((r) => r.cursor == SystemMouseCursors.click), + isFalse, + ); + }); + }); + + group('DisplayField', () { + testWidgets('renders label Text and value Text when both are provided', + (tester) async { + await tester.pumpWidget( + buildApp( + const DisplayField(label: 'IP Address', text: '192.168.1.1'), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('IP Address'), findsOneWidget); + expect(find.text('192.168.1.1'), findsOneWidget); + }); + + testWidgets('renders no label Text when label is null', (tester) async { + await tester.pumpWidget( + buildApp(const DisplayField(text: '192.168.1.1')), + ); + await tester.pumpAndSettle(); + + expect(find.text('192.168.1.1'), findsOneWidget); + // Only the value text should be present; no separate label widget. + expect(find.byType(Text), findsOneWidget); + }); + + testWidgets('renders no value widget when text is null', (tester) async { + await tester.pumpWidget( + buildApp(const DisplayField(label: 'CPU')), + ); + await tester.pumpAndSettle(); + + expect(find.text('CPU'), findsOneWidget); + // Only the label text; no SizedBox/value widget. + expect(find.byType(SizedBox), findsNothing); + }); + + testWidgets('renders an empty Row when both label and text are null', + (tester) async { + await tester.pumpWidget(buildApp(const DisplayField())); + await tester.pumpAndSettle(); + + final row = tester.widget(find.byType(Row)); + expect(row.children, isEmpty); + }); + + testWidgets( + 'renders plain Text (not CopyableText) for value when copyable is false', + (tester) async { + await tester.pumpWidget( + buildApp( + const DisplayField(label: 'Name', text: 'primary'), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(CopyableText), findsNothing); + expect(find.text('primary'), findsOneWidget); + }); + + testWidgets('renders CopyableText for value when copyable is true', + (tester) async { + await tester.pumpWidget( + buildApp( + const DisplayField( + label: 'IP Address', + text: '10.0.0.1', + copyable: true, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(CopyableText), findsOneWidget); + }); + + testWidgets('label is rendered with the provided labelStyle', + (tester) async { + const style = TextStyle(fontSize: 24, color: Colors.red); + await tester.pumpWidget( + buildApp( + const DisplayField( + label: 'Memory', + text: '4GB', + labelStyle: style, + ), + ), + ); + await tester.pumpAndSettle(); + + final labelText = tester.widget(find.text('Memory')); + expect(labelText.style, equals(style)); + }); + + testWidgets('value SizedBox uses the provided width', (tester) async { + await tester.pumpWidget( + buildApp( + const DisplayField(label: 'Disk', text: '50GB', width: 200), + ), + ); + await tester.pumpAndSettle(); + + final sized = tester.widget(find.byType(SizedBox)); + expect(sized.width, equals(200)); + }); + }); +} diff --git a/src/client/gui/test/cpu_sparkline_test.dart b/src/client/gui/test/cpu_sparkline_test.dart new file mode 100644 index 0000000000..a59bb429b3 --- /dev/null +++ b/src/client/gui/test/cpu_sparkline_test.dart @@ -0,0 +1,225 @@ +import 'dart:collection'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/grpc_client.dart'; +import 'package:multipass_gui/providers.dart'; +import 'package:multipass_gui/vm_details/cpu_sparkline.dart'; + +// A mutable provider to drive vmInfoProvider's cpuTimes field in tests. +class _CpuTimesNotifier extends Notifier { + @override + String build() => ''; + + void set(String value) => state = value; +} + +final _cpuTimesProvider = + NotifierProvider<_CpuTimesNotifier, String>(_CpuTimesNotifier.new); + +ProviderContainer makeContainer() { + final container = ProviderContainer( + overrides: [ + vmInfoProvider('test-vm').overrideWithBuild( + (ref, notifier) => DetailedInfoItem( + instanceInfo: InstanceDetails( + cpuTimes: ref.watch(_cpuTimesProvider), + ), + ), + ), + ], + ); + addTearDown(container.dispose); + // Keep the autoDispose provider alive for the duration of the test. + container.listen(cpuUsagesProvider('test-vm'), (_, __) {}); + return container; +} + +void main() { + group('CpuUsagesNotifier', () { + group('initial state with empty cpuTimes', () { + test('queue has 50 elements', () { + final container = makeContainer(); + + final queue = container.read(cpuUsagesProvider('test-vm')); + + expect(queue.length, equals(50)); + }); + + test('all 50 elements are 0.0', () { + final container = makeContainer(); + + final queue = container.read(cpuUsagesProvider('test-vm')); + + expect(queue.every((v) => v == 0.0), isTrue); + }); + }); + + group('usage calculation', () { + // split/skip(2)/take(8) on 'h s 500 0 0 500 0 0 0 0': + // values = [500, 0, 0, 500, 0, 0, 0, 0] + // total=1000, idle=values[3]=500 + // diffTotal=1000, diffIdle=500 → round(100*500/1000) = 50.0 + test('computes 50% usage when half of total time is idle', () { + final container = makeContainer(); + + container + .read(_cpuTimesProvider.notifier) + .set('h s 500 0 0 500 0 0 0 0'); + + expect( + container.read(cpuUsagesProvider('test-vm')).last, + equals(50.0), + ); + }); + + // split/skip(2)/take(8) on 'a b 1000 0 0 0 0 0 0 0': + // values = [1000, 0, 0, 0, 0, 0, 0, 0] + // total=1000, idle=values[3]=0 + // diffTotal=1000, diffIdle=0 → round(100*1000/1000) = 100.0 + test('computes 100% usage when no time is idle', () { + final container = makeContainer(); + + container + .read(_cpuTimesProvider.notifier) + .set('a b 1000 0 0 0 0 0 0 0'); + + expect( + container.read(cpuUsagesProvider('test-vm')).last, + equals(100.0), + ); + }); + + // split/skip(2)/take(8) on 'h s 0 0 0 1000 0 0 0 0': + // values = [0, 0, 0, 1000, 0, 0, 0, 0] + // total=1000, idle=values[3]=1000 + // diffTotal=1000, diffIdle=1000 → round(100*0/1000) = 0.0 + test('computes 0% usage when all time is idle', () { + final container = makeContainer(); + + container + .read(_cpuTimesProvider.notifier) + .set('h s 0 0 0 1000 0 0 0 0'); + + expect( + container.read(cpuUsagesProvider('test-vm')).last, + equals(0.0), + ); + }); + + // 'cpu 100 200 300 400 500 600 700 800' + // split: ['cpu','100','200','300','400','500','600','700','800'] + // skip(2)+take(8): [200, 300, 400, 500, 600, 700, 800] + // total=3500, idle=values[3]=500 + // diffTotal=3500, diffIdle=500 → round(100*3000/3500) = round(85.71) = 86.0 + test('rounds fractional percentage to nearest integer', () { + final container = makeContainer(); + + container + .read(_cpuTimesProvider.notifier) + .set('cpu 100 200 300 400 500 600 700 800'); + + expect( + container.read(cpuUsagesProvider('test-vm')).last, + equals(86.0), + ); + }); + }); + + group('accumulated state across builds', () { + // First build: 'h s 500 0 0 500 0 0 0 0' → total=1000, idle=500 → lastTotal=1000, lastIdle=500 + // Second build: 'h s 1500 0 0 500 0 0 0 0' → total=2000, idle=500 + // diffTotal=2000-1000=1000, diffIdle=500-500=0 → 100*1000/1000 = 100.0 + test('second build computes delta from previous accumulated totals', () { + final container = makeContainer(); + + container + .read(_cpuTimesProvider.notifier) + .set('h s 500 0 0 500 0 0 0 0'); + container.read(cpuUsagesProvider('test-vm')); // first build + + container + .read(_cpuTimesProvider.notifier) + .set('h s 1500 0 0 500 0 0 0 0'); + + expect( + container.read(cpuUsagesProvider('test-vm')).last, + equals(100.0), + ); + }); + + // Empty cpuTimes skips the update branch, so lastTotal and lastIdle + // remain 0 after the initial build. The next real build thus starts + // its delta from 0. + test('empty cpuTimes does not update accumulated totals', () { + final container = makeContainer(); + // Initial build already used '' → lastTotal=0, lastIdle=0 unchanged. + + // Build with real data: delta is from 0. + // [1000,0,0,0,0,0,0,0] total=1000 idle=0 + // diffTotal=1000-0=1000, diffIdle=0-0=0 → 100.0 + container + .read(_cpuTimesProvider.notifier) + .set('a b 1000 0 0 0 0 0 0 0'); + + expect( + container.read(cpuUsagesProvider('test-vm')).last, + equals(100.0), + ); + }); + + test('rebuilding with empty cpuTimes appends 0.0', () { + final container = makeContainer(); + + container + .read(_cpuTimesProvider.notifier) + .set('h s 500 0 0 500 0 0 0 0'); + container.read(cpuUsagesProvider('test-vm')); + + container.read(_cpuTimesProvider.notifier).set(''); + + expect( + container.read(cpuUsagesProvider('test-vm')).last, + equals(0.0), + ); + }); + }); + + group('provider family isolation', () { + test('different vm args have independent notifier instances', () { + final container = ProviderContainer( + overrides: [ + vmInfoProvider('vm-a').overrideWithBuild( + (ref, notifier) => DetailedInfoItem( + instanceInfo: InstanceDetails( + // [1000,0,0,0,0,0,0,0] total=1000, idle=0 → 100% + cpuTimes: 'h s 1000 0 0 0 0 0 0 0', + ), + ), + ), + vmInfoProvider('vm-b').overrideWithBuild( + (ref, notifier) => DetailedInfoItem( + instanceInfo: InstanceDetails( + // [500,0,0,500,0,0,0,0] total=1000, idle=500 → 50% + cpuTimes: 'h s 500 0 0 500 0 0 0 0', + ), + ), + ), + ], + ); + addTearDown(container.dispose); + container.listen(cpuUsagesProvider('vm-a'), (_, __) {}); + container.listen(cpuUsagesProvider('vm-b'), (_, __) {}); + + expect( + container.read(cpuUsagesProvider('vm-a')).last, + equals(100.0), + ); + expect( + container.read(cpuUsagesProvider('vm-b')).last, + equals(50.0), + ); + }); + }); + }); +} diff --git a/src/client/gui/test/delete_instance_dialog_test.dart b/src/client/gui/test/delete_instance_dialog_test.dart new file mode 100644 index 0000000000..4034692e93 --- /dev/null +++ b/src/client/gui/test/delete_instance_dialog_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/confirmation_dialog.dart'; +import 'package:multipass_gui/delete_instance_dialog.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; + +Widget buildWidget({required VoidCallback onDelete, int count = 1}) { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showDialog( + context: context, + builder: (_) => DeleteInstanceDialog( + onDelete: onDelete, + count: count, + ), + ), + child: const Text('Open'), + ), + ), + ), + ); +} + +void main() { + group('DeleteInstanceDialog', () { + testWidgets('renders a ConfirmationDialog', (tester) async { + await tester.pumpWidget(buildWidget(onDelete: () {})); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.byType(ConfirmationDialog), findsOneWidget); + }); + + testWidgets('invoking delete calls onDelete and closes the dialog', + (tester) async { + var deleted = false; + await tester.pumpWidget(buildWidget(onDelete: () => deleted = true)); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + // The delete button has an explicit red backgroundColor style. + await tester.tap(find.byWidgetPredicate( + (w) => w is TextButton && w.style?.backgroundColor != null, + )); + await tester.pumpAndSettle(); + + expect(deleted, isTrue); + expect(find.byType(ConfirmationDialog), findsNothing); + }); + + testWidgets('cancel button closes the dialog without invoking onDelete', + (tester) async { + var deleted = false; + await tester.pumpWidget(buildWidget(onDelete: () => deleted = true)); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(OutlinedButton)); + await tester.pumpAndSettle(); + + expect(deleted, isFalse); + expect(find.byType(ConfirmationDialog), findsNothing); + }); + }); +} diff --git a/src/client/gui/test/disable_section_test.dart b/src/client/gui/test/disable_section_test.dart new file mode 100644 index 0000000000..c9663f3efa --- /dev/null +++ b/src/client/gui/test/disable_section_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/vm_details/vm_details.dart'; + +Widget buildWidget({ + required ActiveEditPage? active, + required List letEnabledFor, +}) { + return MaterialApp( + home: Scaffold( + body: DisableSection( + active: active, + letEnabledFor: letEnabledFor, + child: const Text('content'), + ), + ), + ); +} + +void main() { + group('DisableSection', () { + testWidgets('is not disabled when active is null', (tester) async { + await tester.pumpWidget( + buildWidget(active: null, letEnabledFor: []), + ); + final section = find.byType(DisableSection); + final opacity = tester.widget( + find.descendant(of: section, matching: find.byType(Opacity)).first, + ); + expect(opacity.opacity, equals(1.0)); + final pointer = tester.widget( + find + .descendant(of: section, matching: find.byType(IgnorePointer)) + .first, + ); + expect(pointer.ignoring, isFalse); + }); + + testWidgets('is not disabled when active is in letEnabledFor', + (tester) async { + await tester.pumpWidget( + buildWidget( + active: ActiveEditPage.resources, + letEnabledFor: [ActiveEditPage.resources], + ), + ); + final section = find.byType(DisableSection); + final opacity = tester.widget( + find.descendant(of: section, matching: find.byType(Opacity)).first, + ); + expect(opacity.opacity, equals(1.0)); + final pointer = tester.widget( + find + .descendant(of: section, matching: find.byType(IgnorePointer)) + .first, + ); + expect(pointer.ignoring, isFalse); + }); + + testWidgets('is disabled when active is not in letEnabledFor', + (tester) async { + await tester.pumpWidget( + buildWidget( + active: ActiveEditPage.bridge, + letEnabledFor: [ActiveEditPage.resources], + ), + ); + final section = find.byType(DisableSection); + final opacity = tester.widget( + find.descendant(of: section, matching: find.byType(Opacity)).first, + ); + expect(opacity.opacity, equals(0.5)); + final pointer = tester.widget( + find + .descendant(of: section, matching: find.byType(IgnorePointer)) + .first, + ); + expect(pointer.ignoring, isTrue); + }); + + testWidgets('is disabled when active is set and letEnabledFor is empty', + (tester) async { + await tester.pumpWidget( + buildWidget( + active: ActiveEditPage.mounts, + letEnabledFor: [], + ), + ); + final section = find.byType(DisableSection); + final opacity = tester.widget( + find.descendant(of: section, matching: find.byType(Opacity)).first, + ); + expect(opacity.opacity, equals(0.5)); + }); + + testWidgets('renders child widget', (tester) async { + await tester.pumpWidget( + buildWidget(active: null, letEnabledFor: []), + ); + expect(find.text('content'), findsOneWidget); + }); + }); +} diff --git a/src/client/gui/test/dropdown_test.dart b/src/client/gui/test/dropdown_test.dart new file mode 100644 index 0000000000..fa14142ad8 --- /dev/null +++ b/src/client/gui/test/dropdown_test.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/dropdown.dart'; + +void main() { + final stringItems = {'a': 'Item A', 'b': 'Item B', 'c': 'Item C'}; + final intItems = {1: 'One', 2: 'Two', 3: 'Three'}; + + Widget buildDropdown({ + String? label, + T? value, + required ValueChanged onChanged, + required Map items, + double width = 360, + }) { + return MaterialApp( + home: Scaffold( + body: Center( + child: Dropdown( + label: label, + value: value, + onChanged: onChanged, + items: items, + width: width, + ), + ), + ), + ); + } + + group('Dropdown', () { + group('rendering', () { + testWidgets('renders the selected item label', (tester) async { + await tester.pumpWidget(buildDropdown( + value: 'b', + onChanged: (_) {}, + items: stringItems, + )); + await tester.pumpAndSettle(); + + expect(find.text('Item B'), findsOneWidget); + }); + + testWidgets('renders label text when label is provided', (tester) async { + await tester.pumpWidget(buildDropdown( + label: 'My Label', + value: 'a', + onChanged: (_) {}, + items: stringItems, + )); + await tester.pumpAndSettle(); + + expect(find.text('My Label'), findsOneWidget); + }); + + testWidgets('does not render a label Text when label is null', + (tester) async { + await tester.pumpWidget(buildDropdown( + value: 'a', + onChanged: (_) {}, + items: stringItems, + )); + await tester.pumpAndSettle(); + + // Only the selected item text should be present — no extra Text for a label + expect(find.text('Item A'), findsOneWidget); + expect(find.text('Item B'), findsNothing); + expect(find.text('Item C'), findsNothing); + }); + + testWidgets('renders DropdownButton in the widget tree', (tester) async { + await tester.pumpWidget(buildDropdown( + value: 'a', + onChanged: (_) {}, + items: stringItems, + )); + await tester.pumpAndSettle(); + + expect(find.byType(DropdownButton), findsOneWidget); + }); + }); + + group('initial value', () { + testWidgets('DropdownButton has the correct initial value', + (tester) async { + await tester.pumpWidget(buildDropdown( + value: 'c', + onChanged: (_) {}, + items: stringItems, + )); + await tester.pumpAndSettle(); + + final button = tester.widget>( + find.byType(DropdownButton)); + expect(button.value, equals('c')); + }); + }); + + group('interaction', () { + testWidgets( + 'onChanged is called with the selected value when a new item is tapped', + (tester) async { + String? changedValue; + await tester.pumpWidget(buildDropdown( + value: 'a', + onChanged: (v) => changedValue = v, + items: stringItems, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(DropdownButton)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Item B').last); + await tester.pumpAndSettle(); + + expect(changedValue, equals('b')); + }); + }); + + group('generic type support', () { + testWidgets('works with String type items', (tester) async { + await tester.pumpWidget(buildDropdown( + value: 'a', + onChanged: (_) {}, + items: stringItems, + )); + await tester.pumpAndSettle(); + + expect(find.byType(DropdownButton), findsOneWidget); + expect(find.text('Item A'), findsOneWidget); + }); + + testWidgets('works with int type items', (tester) async { + await tester.pumpWidget(buildDropdown( + value: 2, + onChanged: (_) {}, + items: intItems, + )); + await tester.pumpAndSettle(); + + expect(find.byType(DropdownButton), findsOneWidget); + expect(find.text('Two'), findsOneWidget); + }); + + testWidgets('onChanged receives int value when int item is selected', + (tester) async { + int? changedValue; + await tester.pumpWidget(buildDropdown( + value: 1, + onChanged: (v) => changedValue = v, + items: intItems, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(DropdownButton)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Three').last); + await tester.pumpAndSettle(); + + expect(changedValue, equals(3)); + }); + }); + }); +} diff --git a/src/client/gui/test/extensions_test.dart b/src/client/gui/test/extensions_test.dart new file mode 100644 index 0000000000..5d9d0af30a --- /dev/null +++ b/src/client/gui/test/extensions_test.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/extensions.dart'; + +void main() { + group('NonBreakingString', () { + test('replaces hyphens with non-breaking hyphens', () { + expect('foo-bar'.nonBreaking, 'foo\u2011bar'); + }); + + test('replaces spaces with non-breaking spaces', () { + expect('foo bar'.nonBreaking, 'foo\u00A0bar'); + }); + + test('replaces both hyphens and spaces', () { + expect('foo-bar baz'.nonBreaking, 'foo\u2011bar\u00A0baz'); + }); + + test('returns the same string when no hyphens or spaces are present', () { + expect('foobar'.nonBreaking, 'foobar'); + }); + + test('handles empty string', () { + expect(''.nonBreaking, ''); + }); + }); + + group('NullableMap', () { + test('returns null when the value is null', () { + String? value; + expect(value.map((v) => v.length), isNull); + }); + + test('applies the function when the value is non-null', () { + const String? value = 'hello'; + expect(value.map((v) => v.length), 5); + }); + + test('returns null when the function throws', () { + const String? value = 'hello'; + expect(value.map((v) => throw Exception('oops')), isNull); + }); + + test('works with integer values', () { + const int? value = 42; + expect(value.map((v) => v * 2), 84); + }); + + test('returns null for null integer', () { + int? value; + expect(value.map((v) => v * 2), isNull); + }); + }); + + group('WidgetGap', () { + test('empty iterable produces no elements', () { + final result = [].gap(width: 8).toList(); + expect(result, isEmpty); + }); + + test('single element produces no gaps', () { + final widgets = [const SizedBox()]; + final result = widgets.gap(width: 8).toList(); + expect(result, hasLength(1)); + }); + + test('two elements produce one gap between them', () { + final a = const SizedBox(key: ValueKey('a')); + final b = const SizedBox(key: ValueKey('b')); + final result = [a, b].gap(width: 8).toList(); + expect(result, hasLength(3)); + expect(result[0], a); + expect(result[2], b); + final gap = result[1] as SizedBox; + expect(gap.width, 8); + }); + + test('three elements produce two gaps', () { + final widgets = [ + const SizedBox(key: ValueKey('a')), + const SizedBox(key: ValueKey('b')), + const SizedBox(key: ValueKey('c')), + ]; + final result = widgets.gap(height: 4).toList(); + expect(result, hasLength(5)); + final gap1 = result[1] as SizedBox; + final gap2 = result[3] as SizedBox; + expect(gap1.height, 4); + expect(gap2.height, 4); + }); + + test('gap SizedBox uses specified width and height', () { + final result = [const SizedBox(), const SizedBox()] + .gap(width: 10, height: 5) + .toList(); + final gap = result[1] as SizedBox; + expect(gap.width, 10); + expect(gap.height, 5); + }); + }); + + group('TextSpanFromStringExt', () { + test('span has the correct text', () { + expect('hello'.span.text, 'hello'); + }); + + test('span has black color', () { + expect('hello'.span.style?.color, Colors.black); + }); + + test('span has Ubuntu font family', () { + expect('hello'.span.style?.fontFamily, 'Ubuntu'); + }); + + test('span has emoji fallback fonts', () { + expect('hello'.span.style?.fontFamilyFallback, + containsAllInOrder(['NotoColorEmoji', 'FreeSans'])); + }); + }); + + group('TextSpanFromListExt', () { + test('spans wraps children in a TextSpan', () { + final children = ['a'.span, 'b'.span]; + final result = children.spans; + expect(result.children, children); + }); + + test('spans has black color', () { + final result = ['a'.span].spans; + expect(result.style?.color, Colors.black); + }); + + test('spans with empty list has no text', () { + final result = [].spans; + expect(result.text, isNull); + expect(result.children, isEmpty); + }); + }); + + group('TextSpanExt', () { + test('bold applies FontWeight.bold', () { + final span = 'hello'.span.bold; + expect(span.style?.fontWeight, FontWeight.bold); + }); + + test('bold preserves existing text', () { + final span = 'hello'.span.bold; + expect(span.text, 'hello'); + }); + + test('bold preserves children', () { + final child = 'child'.span; + final parent = TextSpan(children: [child]); + expect(parent.bold.children, [child]); + }); + + test('size applies the given fontSize', () { + final span = 'hello'.span.size(20); + expect(span.style?.fontSize, 20); + }); + + test('size preserves existing text', () { + final span = 'hello'.span.size(16); + expect(span.text, 'hello'); + }); + + test('color applies the given color', () { + final span = 'hello'.span.color(Colors.red); + expect(span.style?.color, Colors.red); + }); + + test('color preserves existing text', () { + final span = 'hello'.span.color(Colors.red); + expect(span.text, 'hello'); + }); + + test('font applies the given fontFamily', () { + final span = 'hello'.span.font('Monospace'); + expect(span.style?.fontFamily, 'Monospace'); + }); + + test('font preserves existing text', () { + final span = 'hello'.span.font('Monospace'); + expect(span.text, 'hello'); + }); + + test('backgroundColor applies the given background color', () { + final span = 'hello'.span.backgroundColor(Colors.yellow); + expect(span.style?.backgroundColor, Colors.yellow); + }); + + test('backgroundColor preserves existing text', () { + final span = 'hello'.span.backgroundColor(Colors.yellow); + expect(span.text, 'hello'); + }); + + test('chaining bold and size applies both styles', () { + final span = 'hello'.span.bold.size(18); + expect(span.style?.fontWeight, FontWeight.bold); + expect(span.style?.fontSize, 18); + }); + + test('chaining color and font applies both styles', () { + final span = 'hello'.span.color(Colors.green).font('Serif'); + expect(span.style?.color, Colors.green); + expect(span.style?.fontFamily, 'Serif'); + }); + + test('bold on span with no existing style still applies bold', () { + const span = TextSpan(text: 'plain'); + expect(span.bold.style?.fontWeight, FontWeight.bold); + }); + + test('size on span with no existing style still applies size', () { + const span = TextSpan(text: 'plain'); + expect(span.size(14).style?.fontSize, 14); + }); + }); +} diff --git a/src/client/gui/test/grpc_client_server_test.dart b/src/client/gui/test/grpc_client_server_test.dart new file mode 100644 index 0000000000..eacbcf1fe2 --- /dev/null +++ b/src/client/gui/test/grpc_client_server_test.dart @@ -0,0 +1,394 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:grpc/grpc.dart'; +import 'package:logger/logger.dart'; +import 'package:multipass_gui/logger.dart' show logger; +import 'package:multipass_gui/providers.dart'; +import 'package:multipass_gui/update_available.dart'; + +// Stub Grpc service +class _StubService extends RpcServiceBase { + static Never _unimplemented() => throw GrpcError.unimplemented('not set up'); + + Stream Function(StartRequest) onStart = (_) => _unimplemented(); + Stream Function(StopRequest) onStop = (_) => _unimplemented(); + Stream Function(SuspendRequest) onSuspend = + (_) => _unimplemented(); + Stream Function(RestartRequest) onRestart = + (_) => _unimplemented(); + Stream Function(DeleteRequest) onDelet = (_) => _unimplemented(); + Stream Function(RecoverRequest) onRecover = + (_) => _unimplemented(); + Stream Function(FindRequest) onFind = (_) => _unimplemented(); + Stream Function(NetworksRequest) onNetworks = + (_) => _unimplemented(); + Stream Function(VersionRequest) onVersion = + (_) => _unimplemented(); + Stream Function(InfoRequest) onInfo = (_) => _unimplemented(); + Stream Function(GetRequest) onGet = (_) => _unimplemented(); + Stream Function(SetRequest) onSet = (_) => _unimplemented(); + Stream Function(SSHInfoRequest) onSshInfo = + (_) => _unimplemented(); + Stream Function(DaemonInfoRequest) onDaemonInfo = + (_) => _unimplemented(); + + @override + Stream start(ServiceCall c, Stream req) async* { + yield* onStart(await req.first); + } + + @override + Stream stop(ServiceCall c, Stream req) async* { + yield* onStop(await req.first); + } + + @override + Stream suspend( + ServiceCall c, Stream req) async* { + yield* onSuspend(await req.first); + } + + @override + Stream restart( + ServiceCall c, Stream req) async* { + yield* onRestart(await req.first); + } + + @override + Stream delet(ServiceCall c, Stream req) async* { + yield* onDelet(await req.first); + } + + @override + Stream recover( + ServiceCall c, Stream req) async* { + yield* onRecover(await req.first); + } + + @override + Stream find(ServiceCall c, Stream req) async* { + yield* onFind(await req.first); + } + + @override + Stream networks( + ServiceCall c, Stream req) async* { + yield* onNetworks(await req.first); + } + + @override + Stream version( + ServiceCall c, Stream req) async* { + yield* onVersion(await req.first); + } + + @override + Stream info(ServiceCall c, Stream req) async* { + yield* onInfo(await req.first); + } + + @override + Stream get(ServiceCall c, Stream req) async* { + yield* onGet(await req.first); + } + + @override + Stream set(ServiceCall c, Stream req) async* { + yield* onSet(await req.first); + } + + @override + Stream ssh_info( + ServiceCall c, Stream req) async* { + yield* onSshInfo(await req.first); + } + + @override + Stream daemon_info( + ServiceCall c, Stream req) async* { + yield* onDaemonInfo(await req.first); + } + + @override + Stream create(ServiceCall c, Stream r) => + _unimplemented(); + @override + Stream launch(ServiceCall c, Stream r) => + _unimplemented(); + @override + Stream purge(ServiceCall c, Stream r) => + _unimplemented(); + @override + Stream list(ServiceCall c, Stream r) => + _unimplemented(); + @override + Stream mount(ServiceCall c, Stream r) => + _unimplemented(); + @override + Future ping(ServiceCall c, PingRequest r) => _unimplemented(); + @override + Stream umount(ServiceCall c, Stream r) => + _unimplemented(); + @override + Stream keys(ServiceCall c, Stream r) => + _unimplemented(); + @override + Stream authenticate( + ServiceCall c, Stream r) => + _unimplemented(); + @override + Stream snapshot(ServiceCall c, Stream r) => + _unimplemented(); + @override + Stream restore(ServiceCall c, Stream r) => + _unimplemented(); + @override + Stream clone(ServiceCall c, Stream r) => + _unimplemented(); + @override + Stream wait_ready( + ServiceCall c, Stream r) => + _unimplemented(); +} + +late _StubService _stub; +late GrpcClient _client; +late Server _server; +late ClientChannel _channel; + +Future _startServer() async { + _stub = _StubService(); + _server = Server.create(services: [_stub]); + await _server.serve(address: InternetAddress.loopbackIPv4, port: 0); + + _channel = ClientChannel( + 'localhost', + port: _server.port!, + options: const ChannelOptions(credentials: ChannelCredentials.insecure()), + ); + _client = GrpcClient(RpcClient(_channel)); +} + +Future _stopServer() async { + await _channel.shutdown(); + await _server.shutdown(); +} + +void main() { + setUpAll(() { + logger = Logger(filter: ProductionFilter(), output: MemoryOutput()); + + providerContainer = ProviderContainer(); + providerContainer.listen(updateProvider, (_, __) {}); + }); + + setUp(_startServer); + tearDown(_stopServer); + tearDown(() => providerContainer.invalidate(updateProvider)); + tearDownAll(() => providerContainer.dispose()); + + group('GrpcClient.start', () { + test('sends the vm names in the StartRequest', () async { + List? captured; + _stub.onStart = (req) { + captured = req.instanceNames.instanceName.toList(); + return Stream.value(StartReply()); + }; + await _client.start(['vm-a', 'vm-b']); + expect(captured, equals(['vm-a', 'vm-b'])); + }); + + test('calls checkForUpdate and sets updateProvider', () async { + final info = UpdateInfo()..version = '2.0.0'; + _stub.onStart = (_) => Stream.value(StartReply()..updateInfo = info); + await _client.start(['vm-a']); + expect(providerContainer.read(updateProvider).version, equals('2.0.0')); + }); + }); + + group('GrpcClient.stop', () { + test('sends the vm names in the StopRequest', () async { + List? captured; + _stub.onStop = (req) { + captured = req.instanceNames.instanceName.toList(); + return Stream.value(StopReply()); + }; + await _client.stop(['my-vm']); + expect(captured, equals(['my-vm'])); + }); + }); + + group('GrpcClient.suspend', () { + test('sends the vm names in the SuspendRequest', () async { + List? captured; + _stub.onSuspend = (req) { + captured = req.instanceNames.instanceName.toList(); + return Stream.value(SuspendReply()); + }; + await _client.suspend(['my-vm']); + expect(captured, equals(['my-vm'])); + }); + }); + + group('GrpcClient.restart', () { + test('sends the vm names in the RestartRequest', () async { + List? captured; + _stub.onRestart = (req) { + captured = req.instanceNames.instanceName.toList(); + return Stream.value(RestartReply()); + }; + await _client.restart(['my-vm']); + expect(captured, equals(['my-vm'])); + }); + + test('calls checkForUpdate and sets updateProvider', () async { + final info = UpdateInfo()..version = '3.0.0'; + _stub.onRestart = (_) => Stream.value(RestartReply()..updateInfo = info); + await _client.restart(['my-vm']); + expect(providerContainer.read(updateProvider).version, equals('3.0.0')); + }); + }); + + group('GrpcClient.delete', () { + test('sends a non-purge DeleteRequest with the instance names', () async { + DeleteRequest? captured; + _stub.onDelet = (req) { + captured = req; + return Stream.value(DeleteReply()); + }; + await _client.delete(['vm-a']); + expect(captured?.purge, isFalse); + expect( + captured?.instanceSnapshotPairs.map((p) => p.instanceName).toList(), + equals(['vm-a'])); + }); + }); + + group('GrpcClient.purge', () { + test('sends a purge=true DeleteRequest', () async { + DeleteRequest? captured; + _stub.onDelet = (req) { + captured = req; + return Stream.value(DeleteReply()); + }; + await _client.purge(['vm-a']); + expect(captured?.purge, isTrue); + }); + }); + + group('GrpcClient.recover', () { + test('sends the vm names in the RecoverRequest', () async { + List? captured; + _stub.onRecover = (req) { + captured = req.instanceNames.instanceName.toList(); + return Stream.value(RecoverReply()); + }; + await _client.recover(['my-vm']); + expect(captured, equals(['my-vm'])); + }); + }); + + group('GrpcClient.find', () { + test('returns the FindReply from the server', () async { + final imageInfo = FindReply_ImageInfo()..aliases.add('jammy'); + _stub.onFind = + (_) => Stream.value(FindReply()..imagesInfo.add(imageInfo)); + final reply = await _client.find(); + expect(reply.imagesInfo.first.aliases, contains('jammy')); + }); + }); + + group('GrpcClient.networks', () { + test('returns the list of interfaces', () async { + _stub.onNetworks = (_) => Stream.value( + NetworksReply()..interfaces.add(NetInterface()..name = 'eth0'), + ); + final interfaces = await _client.networks(); + expect(interfaces.map((i) => i.name), contains('eth0')); + }); + }); + + group('GrpcClient.version', () { + test('returns the version string', () async { + _stub.onVersion = (_) => Stream.value(VersionReply()..version = '1.15.0'); + expect(await _client.version(), equals('1.15.0')); + }); + + test('calls checkForUpdate when reply carries update info', () async { + final info = UpdateInfo()..version = '4.0.0'; + _stub.onVersion = (_) => Stream.value(VersionReply()..updateInfo = info); + await _client.version(); + expect(providerContainer.read(updateProvider).version, equals('4.0.0')); + }); + }); + + group('GrpcClient.info', () { + test('returns details from the InfoReply', () async { + _stub.onInfo = (_) => Stream.value( + InfoReply()..details.add(DetailedInfoItem()..name = 'my-vm'), + ); + final details = await _client.info(); + expect(details.map((d) => d.name), contains('my-vm')); + }); + + test('filters by name when names are provided', () async { + InfoRequest? captured; + _stub.onInfo = (req) { + captured = req; + return Stream.value(InfoReply()); + }; + await _client.info(['vm-a']); + expect( + captured?.instanceSnapshotPairs.map((p) => p.instanceName).toList(), + equals(['vm-a']), + ); + }); + }); + + group('GrpcClient.get', () { + test('sends the key and returns the value', () async { + String? capturedKey; + _stub.onGet = (req) { + capturedKey = req.key; + return Stream.value(GetReply()..value = 'some-value'); + }; + final result = await _client.get('my.key'); + expect(capturedKey, equals('my.key')); + expect(result, equals('some-value')); + }); + }); + + group('GrpcClient.set', () { + test('sends both key and value', () async { + SetRequest? captured; + _stub.onSet = (req) { + captured = req; + return Stream.value(SetReply()); + }; + await _client.set('my.key', 'new-value'); + expect(captured?.key, equals('my.key')); + expect(captured?.val, equals('new-value')); + }); + }); + + group('GrpcClient.sshInfo', () { + test('returns SSHInfo for the requested vm', () async { + _stub.onSshInfo = (_) => Stream.value( + SSHInfoReply()..sshInfo['my-vm'] = (SSHInfo()..host = '10.0.0.1'), + ); + final info = await _client.sshInfo('my-vm'); + expect(info?.host, equals('10.0.0.1')); + }); + }); + + group('GrpcClient.daemonInfo', () { + test('returns the DaemonInfoReply from the server', () async { + _stub.onDaemonInfo = (_) => Stream.value(DaemonInfoReply()..cpus = 4); + final reply = await _client.daemonInfo(); + expect(reply.cpus, equals(4)); + }); + }); +} diff --git a/src/client/gui/test/grpc_client_test.dart b/src/client/gui/test/grpc_client_test.dart new file mode 100644 index 0000000000..22a4512dc8 --- /dev/null +++ b/src/client/gui/test/grpc_client_test.dart @@ -0,0 +1,80 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:protobuf/protobuf.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/notifications/notifications_provider.dart'; +import 'package:multipass_gui/providers.dart'; +import 'package:multipass_gui/update_available.dart'; + +void main() { + late ProviderSubscription> notificationsSubscription; + + setUpAll(() { + providerContainer = ProviderContainer(); + }); + + setUp(() { + notificationsSubscription = + providerContainer.listen(notificationsProvider, (_, __) {}); + }); + + tearDown(() { + notificationsSubscription.close(); + providerContainer.invalidate(updateProvider); + }); + + tearDownAll(() { + providerContainer.dispose(); + }); + + group('checkForUpdate', () { + void runReplyTests(List<(String, GeneratedMessage)> cases) { + for (final (name, reply) in cases) { + test('sets updateProvider from a $name', () { + checkForUpdate(reply); + expect( + providerContainer.read(updateProvider).version, + equals('1.15.0'), + ); + }); + } + } + + final info = UpdateInfo()..version = '1.15.0'; + runReplyTests([ + ('LaunchReply', LaunchReply()..updateInfo = info), + ('InfoReply', InfoReply()..updateInfo = info), + ('ListReply', ListReply()..updateInfo = info), + ('NetworksReply', NetworksReply()..updateInfo = info), + ('StartReply', StartReply()..updateInfo = info), + ('RestartReply', RestartReply()..updateInfo = info), + ('VersionReply', VersionReply()..updateInfo = info), + ]); + + test('does not update the provider for an unrecognised message type', () { + checkForUpdate(FindReply()); + expect(providerContainer.read(updateProvider), equals(UpdateInfo())); + }); + + test('ignores a message whose version is blank', () { + checkForUpdate(LaunchReply()..updateInfo = (UpdateInfo()..version = '')); + expect(providerContainer.read(updateProvider), equals(UpdateInfo())); + }); + + test('adds an UpdateAvailableNotification for a valid update', () { + checkForUpdate( + LaunchReply()..updateInfo = (UpdateInfo()..version = '1.22.0')); + final notifications = providerContainer.read(notificationsProvider); + expect(notifications, hasLength(1)); + expect(notifications.first, isA()); + }); + + test('does not add a duplicate notification for the same version', () { + final info = UpdateInfo()..version = '1.23.0'; + checkForUpdate(LaunchReply()..updateInfo = info); + checkForUpdate(InfoReply()..updateInfo = info); + expect(providerContainer.read(notificationsProvider), hasLength(1)); + }); + }); +} diff --git a/src/client/gui/test/helpers.dart b/src/client/gui/test/helpers.dart new file mode 100644 index 0000000000..0599975a8e --- /dev/null +++ b/src/client/gui/test/helpers.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// A minimal valid SVG returned for any `.svg` asset request +const _minimalSvg = ''; + +/// An [AssetBundle] that stubs out `.svg` request with [_minimalSvg] +/// and delegates all other requests to [rootBundle]. +class FakeSvgAssetBundle extends CachingAssetBundle { + @override + Future load(String key) async { + if (key.endsWith('.svg')) { + final bytes = utf8.encode(_minimalSvg); + return ByteData.view(Uint8List.fromList(bytes).buffer); + } + return rootBundle.load(key); + } +} + +/// Wraps [child] in a [DefaultAssetBundle] that stubs SVG assets +Widget withFakeSvgAssetBundle(Widget child) => + DefaultAssetBundle(bundle: FakeSvgAssetBundle(), child: child); diff --git a/src/client/gui/test/ip_addresses_test.dart b/src/client/gui/test/ip_addresses_test.dart new file mode 100644 index 0000000000..5439cc0efc --- /dev/null +++ b/src/client/gui/test/ip_addresses_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/vm_details/ip_addresses.dart'; + +void main() { + Widget buildWidget(List ips) { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Row(children: [ + Expanded(child: IpAddresses(ips)), + ]), + ), + ); + } + + group('IpAddresses', () { + testWidgets('renders "-" when ips is empty', (tester) async { + await tester.pumpWidget(buildWidget([])); + await tester.pumpAndSettle(); + + expect(find.text('-'), findsOneWidget); + }); + + testWidgets('renders the IP text when ips has one entry', (tester) async { + await tester.pumpWidget(buildWidget(['192.168.1.1'])); + await tester.pumpAndSettle(); + + expect(find.text('192.168.1.1'), findsOneWidget); + }); + + testWidgets('does not render a PopupMenuButton when ips has one entry', + (tester) async { + await tester.pumpWidget(buildWidget(['192.168.1.1'])); + await tester.pumpAndSettle(); + + expect(find.byType(PopupMenuButton), findsNothing); + }); + + testWidgets('does not render a Badge when ips has one entry', + (tester) async { + await tester.pumpWidget(buildWidget(['192.168.1.1'])); + await tester.pumpAndSettle(); + + expect(find.byType(Badge), findsNothing); + }); + + testWidgets( + 'renders first IP in CopyableText and a PopupMenuButton when ips has two entries', + (tester) async { + await tester.pumpWidget(buildWidget(['10.0.0.1', '10.0.0.2'])); + await tester.pumpAndSettle(); + + expect(find.text('10.0.0.1'), findsOneWidget); + expect(find.byType(PopupMenuButton), findsOneWidget); + }); + + testWidgets('badge count is 2 when ips has three entries', (tester) async { + await tester + .pumpWidget(buildWidget(['10.0.0.1', '10.0.0.2', '10.0.0.3'])); + await tester.pumpAndSettle(); + + expect(find.byType(Badge), findsWidgets); + final badge = tester.widget(find.byType(Badge).first); + expect(badge.label, isNotNull); + // Badge.count with count=2 renders "2" as its label text + expect(find.text('2'), findsOneWidget); + }); + + testWidgets( + 'tapping the popup button opens a menu showing the second IP when ips has two entries', + (tester) async { + await tester.pumpWidget(buildWidget(['10.0.0.1', '10.0.0.2'])); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(PopupMenuButton)); + await tester.pumpAndSettle(); + + expect(find.text('10.0.0.2'), findsOneWidget); + }); + }); +} diff --git a/src/client/gui/test/mapping_slider_test.dart b/src/client/gui/test/mapping_slider_test.dart new file mode 100644 index 0000000000..e77e8983a8 --- /dev/null +++ b/src/client/gui/test/mapping_slider_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/vm_details/mapping_slider.dart'; + +void main() { + group('NumToHumanString.toNiceString', () { + test('integer returns plain int string', () { + expect(5.toNiceString(), '5'); + expect(0.toNiceString(), '0'); + expect(100.toNiceString(), '100'); + }); + + test('double with fractional part returns 2 decimal places', () { + expect(1.5.toNiceString(), '1.50'); + expect(3.14.toNiceString(), '3.14'); + expect(0.1.toNiceString(), '0.10'); + }); + + test('double equal to int returns int string', () { + expect(2.0.toNiceString(), '2'); + expect(0.0.toNiceString(), '0'); + expect(10.0.toNiceString(), '10'); + }); + }); + + group('BytesFromUnits', () { + test('1.kibi equals 1024', () { + expect(1.kibi, 1024); + }); + + test('1.mebi equals 1048576', () { + expect(1.mebi, 1048576); + }); + + test('1.gibi equals 1073741824', () { + expect(1.gibi, 1073741824); + }); + + test('2.kibi equals 2048', () { + expect(2.kibi, 2048); + }); + + test('0.kibi equals 0', () { + expect(0.kibi, 0); + }); + }); + + group('Conversion functions', () { + test('bytesToKibi round-trip with kibiToBytes', () { + final value = 1.kibi; + expect(kibiToBytes(bytesToKibi(value)), closeTo(value, 1e-9)); + }); + + test('bytesToGibi of 1.gibi is approximately 1.0', () { + expect(bytesToGibi(1.gibi), closeTo(1.0, 1e-9)); + }); + + test('gibiToBytes(1) equals 1.gibi', () { + expect(gibiToBytes(1), 1.gibi); + }); + + test('bytesToMebi round-trip with mebiToBytes', () { + final value = 1.mebi; + expect(mebiToBytes(bytesToMebi(value)), closeTo(value, 1e-9)); + }); + + test('bytesToBytes is identity', () { + expect(bytesToBytes(1.gibi), 1.gibi); + }); + }); + + group('nonLinearMapping and nonLinearInverseMapping', () { + test('round-trip for 1.gibi', () { + final value = 1.gibi; + expect(nonLinearInverseMapping(nonLinearMapping(value)), value); + }); + + test('round-trip for 4.gibi', () { + final value = 4.gibi; + expect(nonLinearInverseMapping(nonLinearMapping(value)), value); + }); + + test('round-trip for 8.gibi', () { + final value = 8.gibi; + expect(nonLinearInverseMapping(nonLinearMapping(value)), value); + }); + + test('larger mapped value for larger input', () { + expect(nonLinearMapping(4.gibi), greaterThan(nonLinearMapping(1.gibi))); + expect(nonLinearMapping(8.gibi), greaterThan(nonLinearMapping(4.gibi))); + }); + + test('inverseMapping produces multiples of sector size', () { + const sectorSize = 512; + for (var i = 0; i < 20; i++) { + expect(nonLinearInverseMapping(i) % sectorSize, 0); + } + }); + }); +} diff --git a/src/client/gui/test/memory_usage_test.dart b/src/client/gui/test/memory_usage_test.dart new file mode 100644 index 0000000000..7d0f2e400d --- /dev/null +++ b/src/client/gui/test/memory_usage_test.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/vm_details/memory_usage.dart'; + +void main() { + Widget buildWidget(String used, String total) { + return MaterialApp( + home: Scaffold( + body: MemoryUsage(used: used, total: total), + ), + ); + } + + group('MemoryUsage color logic', () { + testWidgets('uses normalColor when usage is below 80%', (tester) async { + await tester.pumpWidget(buildWidget('512', '1024')); + await tester.pumpAndSettle(); + + final indicator = tester.widget( + find.byType(LinearProgressIndicator), + ); + expect(indicator.color, MemoryUsage.normalColor); + }); + + testWidgets('uses almostFullColor when usage is exactly 80%', + (tester) async { + await tester.pumpWidget(buildWidget('800', '1000')); + await tester.pumpAndSettle(); + + final indicator = tester.widget( + find.byType(LinearProgressIndicator), + ); + expect(indicator.color, MemoryUsage.almostFullColor); + }); + + testWidgets('uses almostFullColor when usage is 100%', (tester) async { + await tester.pumpWidget(buildWidget('1000', '1000')); + await tester.pumpAndSettle(); + + final indicator = tester.widget( + find.byType(LinearProgressIndicator), + ); + expect(indicator.color, MemoryUsage.almostFullColor); + }); + }); + + group('MemoryUsage progress value', () { + testWidgets('computes 0.5 for used=512 total=1024', (tester) async { + await tester.pumpWidget(buildWidget('512', '1024')); + await tester.pumpAndSettle(); + + final indicator = tester.widget( + find.byType(LinearProgressIndicator), + ); + expect(indicator.value, 0.5); + }); + + testWidgets('computes 0.8 for used=800 total=1000', (tester) async { + await tester.pumpWidget(buildWidget('800', '1000')); + await tester.pumpAndSettle(); + + final indicator = tester.widget( + find.byType(LinearProgressIndicator), + ); + expect(indicator.value, 0.8); + }); + + testWidgets('uses backgroundColor on LinearProgressIndicator', + (tester) async { + await tester.pumpWidget(buildWidget('512', '1024')); + await tester.pumpAndSettle(); + + final indicator = tester.widget( + find.byType(LinearProgressIndicator), + ); + expect(indicator.backgroundColor, MemoryUsage.backgroundColor); + }); + }); + + group('MemoryUsage edge cases', () { + testWidgets('shows dash label when used is "0"', (tester) async { + await tester.pumpWidget(buildWidget('0', '1024')); + await tester.pumpAndSettle(); + + expect(find.text('-'), findsOneWidget); + }); + + testWidgets('value is 0.0 and shows dash when total is "0"', + (tester) async { + await tester.pumpWidget(buildWidget('0', '0')); + await tester.pumpAndSettle(); + + final indicator = tester.widget( + find.byType(LinearProgressIndicator), + ); + expect(indicator.value, 0.0); + expect(find.text('-'), findsOneWidget); + }); + + testWidgets('falls back gracefully when used is non-parseable', + (tester) async { + await tester.pumpWidget(buildWidget('abc', '1024')); + await tester.pumpAndSettle(); + + final indicator = tester.widget( + find.byType(LinearProgressIndicator), + ); + expect(indicator.value, 0.0); + expect(find.text('-'), findsOneWidget); + }); + + testWidgets('falls back gracefully when both are non-parseable', + (tester) async { + await tester.pumpWidget(buildWidget('foo', 'bar')); + await tester.pumpAndSettle(); + + final indicator = tester.widget( + find.byType(LinearProgressIndicator), + ); + expect(indicator.value, 0.0); + expect(find.text('-'), findsOneWidget); + }); + + testWidgets('shows dash when total is "0" but used is non-zero', + (tester) async { + // Division by zero yields Infinity; isFinite guard sets value to 0.0. + await tester.pumpWidget(buildWidget('512', '0')); + await tester.pumpAndSettle(); + + final indicator = tester.widget( + find.byType(LinearProgressIndicator), + ); + expect(indicator.value, 0.0); + expect(find.text('-'), findsOneWidget); + }); + + testWidgets('clamps to full when used exceeds total', (tester) async { + // value > 1.0 is clamped by LinearProgressIndicator; almostFullColor applied. + await tester.pumpWidget(buildWidget('2000', '1000')); + await tester.pumpAndSettle(); + + final indicator = tester.widget( + find.byType(LinearProgressIndicator), + ); + expect(indicator.value, 2.0); + expect(indicator.color, MemoryUsage.almostFullColor); + }); + }); + + group('MemoryUsage label formatting via widget', () { + testWidgets('formats 1 GiB correctly', (tester) async { + const oneGib = 1073741824; + await tester.pumpWidget(buildWidget('$oneGib', '$oneGib')); + await tester.pumpAndSettle(); + + expect(find.textContaining('1.0GiB'), findsWidgets); + }); + + testWidgets('formats 1 MiB correctly', (tester) async { + const oneMib = 1048576; + await tester.pumpWidget(buildWidget('$oneMib', '$oneMib')); + await tester.pumpAndSettle(); + + expect(find.textContaining('1.0MiB'), findsWidgets); + }); + + testWidgets('formats 1 KiB correctly', (tester) async { + const oneKib = 1024; + await tester.pumpWidget(buildWidget('$oneKib', '$oneKib')); + await tester.pumpAndSettle(); + + expect(find.textContaining('1.0KiB'), findsWidgets); + }); + + testWidgets('formats bytes below 1 KiB correctly', (tester) async { + await tester.pumpWidget(buildWidget('512', '1024')); + await tester.pumpAndSettle(); + + expect(find.textContaining('512B'), findsOneWidget); + }); + + testWidgets('label shows used / total format', (tester) async { + const oneGib = 1073741824; + const twoGib = 2 * oneGib; + await tester.pumpWidget(buildWidget('$oneGib', '$twoGib')); + await tester.pumpAndSettle(); + + expect(find.text('1.0GiB / 2.0GiB'), findsOneWidget); + }); + + testWidgets('label uses mixed formats', (tester) async { + const oneGib = 1073741824; + const oneMib = 1048576; + await tester.pumpWidget(buildWidget('$oneMib', '$oneGib')); + await tester.pumpAndSettle(); + + expect(find.text('1.0MiB / 1.0GiB'), findsOneWidget); + }); + }); +} diff --git a/src/client/gui/test/name_validator_test.dart b/src/client/gui/test/name_validator_test.dart new file mode 100644 index 0000000000..becee87142 --- /dev/null +++ b/src/client/gui/test/name_validator_test.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart' hide ImageInfo; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/catalogue/launch_form.dart'; +import 'package:multipass_gui/grpc_client.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; + +void main() { + Future getL10n(WidgetTester tester) async { + late AppLocalizations l10n; + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) { + l10n = AppLocalizations.of(context)!; + return const SizedBox.shrink(); + }), + ), + ); + await tester.pumpAndSettle(); + return l10n; + } + + group('nameValidator', () { + testWidgets('empty string is valid (will use random name)', (tester) async { + final l10n = await getL10n(tester); + final validator = nameValidator([], [], l10n); + expect(validator(''), isNull); + }); + + testWidgets('single character is invalid (too short)', (tester) async { + final l10n = await getL10n(tester); + final validator = nameValidator([], [], l10n); + expect(validator('a'), isNotNull); + }); + + testWidgets('name with underscore is invalid (invalid char)', + (tester) async { + final l10n = await getL10n(tester); + final validator = nameValidator([], [], l10n); + expect(validator('my_vm'), isNotNull); + }); + + testWidgets('name with space is invalid (invalid char)', (tester) async { + final l10n = await getL10n(tester); + final validator = nameValidator([], [], l10n); + expect(validator('my vm'), isNotNull); + }); + + testWidgets('name starting with digit is invalid', (tester) async { + final l10n = await getL10n(tester); + final validator = nameValidator([], [], l10n); + expect(validator('1vm'), isNotNull); + }); + + testWidgets('name starting with hyphen is invalid', (tester) async { + final l10n = await getL10n(tester); + final validator = nameValidator([], [], l10n); + expect(validator('-vm'), isNotNull); + }); + + testWidgets('name ending with hyphen is invalid', (tester) async { + final l10n = await getL10n(tester); + final validator = nameValidator([], [], l10n); + expect(validator('vm-'), isNotNull); + }); + + testWidgets('name in existing names is invalid', (tester) async { + final l10n = await getL10n(tester); + final validator = nameValidator(['existing-vm', 'other-vm'], [], l10n); + expect(validator('existing-vm'), isNotNull); + }); + + testWidgets('name in deleted names is invalid', (tester) async { + final l10n = await getL10n(tester); + final validator = nameValidator([], ['deleted-vm'], l10n); + expect(validator('deleted-vm'), isNotNull); + }); + + testWidgets('valid name with hyphen and no conflicts returns null', + (tester) async { + final l10n = await getL10n(tester); + final validator = nameValidator([], [], l10n); + expect(validator('my-vm'), isNull); + }); + + testWidgets('valid name with trailing digit returns null', (tester) async { + final l10n = await getL10n(tester); + final validator = nameValidator([], [], l10n); + expect(validator('vm1'), isNull); + }); + + testWidgets('valid name with uppercase and digits returns null', + (tester) async { + final l10n = await getL10n(tester); + final validator = nameValidator([], [], l10n); + expect(validator('MyVm1'), isNull); + }); + + testWidgets('exactly 2-character name is valid', (tester) async { + final l10n = await getL10n(tester); + final validator = nameValidator([], [], l10n); + expect(validator('vm'), isNull); + }); + + testWidgets('error messages are distinct for different validation failures', + (tester) async { + final l10n = await getL10n(tester); + final validator = nameValidator(['taken'], ['deleted'], l10n); + + final tooShort = validator('a'); + final invalidChars = validator('my_vm'); + final noStartLetter = validator('1vm'); + final badEndChar = validator('vm-'); + final inUse = validator('taken'); + final deletedInUse = validator('deleted'); + + expect(tooShort, isNotNull); + expect(invalidChars, isNotNull); + expect(noStartLetter, isNotNull); + expect(badEndChar, isNotNull); + expect(inUse, isNotNull); + expect(deletedInUse, isNotNull); + + final errors = { + tooShort, + invalidChars, + noStartLetter, + badEndChar, + inUse, + deletedInUse + }; + expect(errors.length, equals(6)); + }); + + testWidgets('name not in existing names is not flagged as in-use', + (tester) async { + final l10n = await getL10n(tester); + final validator = nameValidator(['other-vm'], [], l10n); + expect(validator('my-vm'), isNull); + }); + + testWidgets('name not in deleted names is not flagged as deleted-in-use', + (tester) async { + final l10n = await getL10n(tester); + final validator = nameValidator([], ['other-vm'], l10n); + expect(validator('my-vm'), isNull); + }); + }); + + group('imageName', () { + test('"core20" alias omits codename', () { + final info = ImageInfo( + os: 'Ubuntu', + release: '20.04', + codename: 'focal', + aliases: ['core20', '20.04'], + ); + expect(imageName(info), equals('Ubuntu 20.04')); + }); + + test('non-core alias includes codename', () { + final info = ImageInfo( + os: 'Ubuntu', + release: '24.04', + codename: 'noble', + aliases: ['24.04', 'lts'], + ); + expect(imageName(info), equals('Ubuntu 24.04 noble')); + }); + }); +} diff --git a/src/client/gui/test/no_vms_test.dart b/src/client/gui/test/no_vms_test.dart new file mode 100644 index 0000000000..7cbb9b50d9 --- /dev/null +++ b/src/client/gui/test/no_vms_test.dart @@ -0,0 +1,44 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/providers.dart'; +import 'package:multipass_gui/vm_table/no_vms.dart'; + +import 'helpers.dart'; + +Widget buildWidget() { + return withFakeSvgAssetBundle( + ProviderScope( + overrides: [ + vmNamesProvider.overrideWith((ref) => BuiltSet()), + ], + child: const MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: NoVms()), + ), + ), + ); +} + +void main() { + group('NoVms', () { + testWidgets('renders the title text', (tester) async { + await tester.pumpWidget(buildWidget()); + await tester.pump(); + + expect(find.byType(Text), findsWidgets); + }); + + testWidgets('renders rich text containing the catalogue link', + (tester) async { + await tester.pumpWidget(buildWidget()); + await tester.pump(); + + expect(find.byType(RichText), findsWidgets); + }); + }); +} diff --git a/src/client/gui/test/notifications/notification_entries_test.dart b/src/client/gui/test/notifications/notification_entries_test.dart new file mode 100644 index 0000000000..d9bac8188c --- /dev/null +++ b/src/client/gui/test/notifications/notification_entries_test.dart @@ -0,0 +1,460 @@ +import 'dart:async'; + +import 'package:built_collection/built_collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:grpc/grpc.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/notifications/notification_entries.dart'; +import 'package:multipass_gui/notifications/notifications_list.dart'; +import 'package:multipass_gui/providers.dart'; +import 'package:multipass_gui/sidebar.dart'; + +Widget buildWidget(Widget child) { + return MaterialApp(home: Scaffold(body: child)); +} + +void main() { + group('SimpleNotification', () { + testWidgets('renders child widget', (tester) async { + await tester.pumpWidget( + buildWidget( + SimpleNotification( + barColor: Colors.blue, + icon: const Icon(Icons.info), + child: const Text('hello'), + ), + ), + ); + expect(find.text('hello'), findsOneWidget); + }); + + testWidgets('shows close button when closeable is true', (tester) async { + await tester.pumpWidget( + buildWidget( + SimpleNotification( + barColor: Colors.blue, + icon: const Icon(Icons.info), + closeable: true, + child: const Text('x'), + ), + ), + ); + expect(find.byIcon(Icons.close), findsOneWidget); + }); + + testWidgets('hides close button when closeable is false', (tester) async { + await tester.pumpWidget( + buildWidget( + SimpleNotification( + barColor: Colors.blue, + icon: const Icon(Icons.info), + closeable: false, + child: const Text('x'), + ), + ), + ); + expect(find.byIcon(Icons.close), findsNothing); + }); + + testWidgets('renders the icon', (tester) async { + await tester.pumpWidget( + buildWidget( + SimpleNotification( + barColor: Colors.green, + icon: const Icon(Icons.check_circle), + child: const Text('x'), + ), + ), + ); + expect(find.byIcon(Icons.check_circle), findsOneWidget); + }); + }); + + group('ErrorNotification', () { + testWidgets('shows error text', (tester) async { + await tester.pumpWidget( + buildWidget(ErrorNotification(text: 'Something went wrong')), + ); + expect(find.text('Something went wrong'), findsOneWidget); + }); + + testWidgets('shows cancel outlined icon', (tester) async { + await tester.pumpWidget( + buildWidget(ErrorNotification(text: 'error')), + ); + expect(find.byIcon(Icons.cancel_outlined), findsOneWidget); + }); + + testWidgets('is closeable (shows close button)', (tester) async { + await tester.pumpWidget( + buildWidget(ErrorNotification(text: 'error')), + ); + expect(find.byIcon(Icons.close), findsOneWidget); + }); + }); + + group('OperationNotification', () { + testWidgets('shows CircularProgressIndicator while pending', + (tester) async { + final completer = Completer(); + await tester.pumpWidget( + buildWidget( + OperationNotification( + future: completer.future, + text: 'Working...', + ), + ), + ); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('Working...'), findsOneWidget); + + completer.complete(''); + }); + + testWidgets('shows success notification when future completes', + (tester) async { + await tester.pumpWidget( + buildWidget( + OperationNotification( + future: Future.value('Done!'), + text: 'Working...', + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Done!'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('shows error notification when future fails', (tester) async { + final future = Future.error('Failed badly'); + future.ignore(); + + await tester.pumpWidget( + buildWidget( + OperationNotification( + future: future, + text: 'Working...', + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Failed badly'), findsOneWidget); + expect(find.byIcon(Icons.cancel_outlined), findsOneWidget); + }); + }); + + group('SimpleNotification close button', () { + testWidgets('tapping close button dispatches CloseNotificationIntent', + (tester) async { + var invoked = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Actions( + actions: { + CloseNotificationIntent: + CallbackAction( + onInvoke: (_) => invoked = true, + ), + }, + child: SimpleNotification( + barColor: Colors.blue, + icon: const Icon(Icons.info), + closeable: true, + child: const Text('close me'), + ), + ), + ), + ), + ); + + await tester.tap(find.byIcon(Icons.close)); + expect(invoked, isTrue); + }); + }); + + group('TimeoutNotification', () { + testWidgets('renders child widget', (tester) async { + await tester.pumpWidget( + buildWidget( + TimeoutNotification( + barColor: Colors.green, + icon: const Icon(Icons.check), + child: const Text('timeout content'), + ), + ), + ); + await tester.pump(); + + expect(find.text('timeout content'), findsOneWidget); + }); + + testWidgets('renders icon', (tester) async { + await tester.pumpWidget( + buildWidget( + TimeoutNotification( + barColor: Colors.green, + icon: const Icon(Icons.check_circle), + child: const Text('x'), + ), + ), + ); + await tester.pump(); + + expect(find.byIcon(Icons.check_circle), findsOneWidget); + }); + + testWidgets('auto-closes after duration via CloseNotificationIntent', + (tester) async { + var closed = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Actions( + actions: { + CloseNotificationIntent: + CallbackAction( + onInvoke: (_) => closed = true, + ), + }, + child: TimeoutNotification( + barColor: Colors.green, + icon: const Icon(Icons.check), + duration: Duration.zero, + child: const Text('will close'), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(closed, isTrue); + }); + }); + + group('SuccessNotification', () { + testWidgets('renders child widget', (tester) async { + await tester.pumpWidget( + buildWidget( + const SuccessNotification(child: Text('success message')), + ), + ); + await tester.pump(); + + expect(find.text('success message'), findsOneWidget); + }); + + testWidgets('renders green check circle icon', (tester) async { + await tester.pumpWidget( + buildWidget( + const SuccessNotification(child: Text('ok')), + ), + ); + await tester.pump(); + + expect(find.byIcon(Icons.check_circle_outline), findsOneWidget); + }); + }); + + group('LaunchingNotification', () { + Widget buildApp(Widget child) { + return ProviderScope( + overrides: [ + vmNamesProvider.overrideWith((ref) => BuiltSet()), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), + ), + ); + } + + testWidgets('shows GrpcError.message text on stream error', (tester) async { + final controller = + StreamController?>.broadcast(); + addTearDown(controller.close); + + await tester.pumpWidget(buildApp( + LaunchingNotification( + stream: controller.stream, + cancelCompleter: Completer(), + name: 'my-vm', + ), + )); + await tester.pump(); + + final error = + GrpcError.custom(StatusCode.unknown, 'gRPC failure message'); + controller.addError(error); + await tester.pump(); + + expect(find.text('gRPC failure message'), findsOneWidget); + }); + + testWidgets('shows error.toString() for non-GrpcError on stream error', + (tester) async { + final controller = + StreamController?>.broadcast(); + addTearDown(controller.close); + + await tester.pumpWidget(buildApp( + LaunchingNotification( + stream: controller.stream, + cancelCompleter: Completer(), + name: 'my-vm', + ), + )); + await tester.pump(); + + controller.addError(Exception('plain exception')); + await tester.pump(); + + expect(find.textContaining('plain exception'), findsOneWidget); + }); + + testWidgets( + 'shows SuccessNotification with "Go to instance" when stream completes', + (tester) async { + await tester.pumpWidget(buildApp( + LaunchingNotification( + stream: Stream?>.fromIterable([]), + cancelCompleter: Completer(), + name: 'my-vm', + ), + )); + await tester.pump(); + + expect(find.byType(SuccessNotification), findsOneWidget); + expect(find.text('Go to instance'), findsOneWidget); + }); + + testWidgets( + '"Go to instance" button sets sidebarKeyProvider to "vm-{name}"', + (tester) async { + final container = ProviderContainer( + overrides: [ + vmNamesProvider.overrideWith((ref) => BuiltSet()), + ], + ); + addTearDown(container.dispose); + container.listen(sidebarKeyProvider, (_, __) {}); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: LaunchingNotification( + stream: + Stream?>.fromIterable([]), + cancelCompleter: Completer(), + name: 'my-vm', + ), + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Go to instance')); + expect(container.read(sidebarKeyProvider), equals('vm-my-vm')); + }); + + testWidgets('VERIFY progress shows verify message without cancel button', + (tester) async { + final controller = + StreamController?>.broadcast(); + addTearDown(controller.close); + + await tester.pumpWidget(buildApp( + LaunchingNotification( + stream: controller.stream, + cancelCompleter: Completer(), + name: 'my-vm', + ), + )); + + controller.add(Left(LaunchReply( + launchProgress: LaunchProgress( + type: LaunchProgress_ProgressTypes.VERIFY, + ), + ))); + await tester.pump(); + + expect(find.textContaining('Verifying image'), findsOneWidget); + expect(find.text('Cancel'), findsNothing); + }); + + testWidgets('download progress shows percentage and cancel button', + (tester) async { + final controller = + StreamController?>.broadcast(); + addTearDown(controller.close); + + await tester.pumpWidget(buildApp( + LaunchingNotification( + stream: controller.stream, + cancelCompleter: Completer(), + name: 'my-vm', + ), + )); + + controller.add(Left(LaunchReply( + launchProgress: LaunchProgress( + type: LaunchProgress_ProgressTypes.IMAGE, + percentComplete: '42', + ), + ))); + await tester.pump(); + + expect(find.textContaining('42'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + }); + + testWidgets('tapping cancel button completes cancelCompleter', + (tester) async { + final cancelCompleter = Completer(); + final controller = + StreamController?>.broadcast(); + addTearDown(controller.close); + + await tester.pumpWidget(buildApp( + LaunchingNotification( + stream: controller.stream, + cancelCompleter: cancelCompleter, + name: 'my-vm', + ), + )); + + controller.add(Left(LaunchReply( + launchProgress: LaunchProgress( + type: LaunchProgress_ProgressTypes.IMAGE, + percentComplete: '50', + ), + ))); + await tester.pump(); + + await tester.tap(find.text('Cancel')); + await tester.pump(); + + expect(cancelCompleter.isCompleted, isTrue); + }); + }); +} diff --git a/src/client/gui/test/notifications/notifications_list_test.dart b/src/client/gui/test/notifications/notifications_list_test.dart new file mode 100644 index 0000000000..ddddb3c5b1 --- /dev/null +++ b/src/client/gui/test/notifications/notifications_list_test.dart @@ -0,0 +1,110 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/notifications/notifications_list.dart'; +import 'package:multipass_gui/notifications/notifications_provider.dart'; + +/// A notifier that starts with a pre-seeded [NotificationList] +class _PreseededNotifier extends NotificationsNotifier { + final BuiltList _initial; + + _PreseededNotifier(this._initial); + + @override + BuiltList build() => _initial; +} + +void main() { + group('NotificationTile', () { + testWidgets('renders child notification widget', (tester) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Scaffold( + body: NotificationTile(const Text('tile content')), + ), + ), + ), + ); + + expect(find.text('tile content'), findsOneWidget); + }); + + testWidgets( + 'CloseNotificationIntent removes the notification from the provider', + (tester) async { + final notification = const Text('closeable note'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + notificationsProvider.overrideWith( + () => _PreseededNotifier(BuiltList([notification])), + ), + ], + child: MaterialApp( + home: Scaffold( + body: Consumer( + builder: (_, ref, __) { + ref.watch(notificationsProvider); + return NotificationTile(notification); + }, + ), + ), + ), + ), + ); + + final container = ProviderScope.containerOf( + tester.element(find.byType(Consumer)), + ); + + expect(container.read(notificationsProvider), hasLength(1)); + + // Dispatch the intent from within the tile's subtree. + final ctx = tester.element(find.text('closeable note')); + Actions.invoke(ctx, const CloseNotificationIntent()); + await tester.pump(); + + expect(container.read(notificationsProvider), isEmpty); + }); + }); + + group('NotificationList', () { + testWidgets('renders pre-seeded notification in the provider', + (tester) async { + final notification = const Text('initial notification'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + notificationsProvider.overrideWith( + () => _PreseededNotifier(BuiltList([notification])), + ), + ], + child: const MaterialApp( + home: Scaffold(body: NotificationList()), + ), + ), + ); + + expect(find.text('initial notification'), findsOneWidget); + }); + + testWidgets( + 'renders an AnimatedList with zero items when provider is empty', + (tester) async { + await tester.pumpWidget( + const ProviderScope( + child: MaterialApp( + home: Scaffold(body: NotificationList()), + ), + ), + ); + + expect(find.byType(NotificationList), findsOneWidget); + expect(find.byType(AnimatedList), findsOneWidget); + }); + }); +} diff --git a/src/client/gui/test/notifications/notifications_provider_test.dart b/src/client/gui/test/notifications/notifications_provider_test.dart new file mode 100644 index 0000000000..4f412d824e --- /dev/null +++ b/src/client/gui/test/notifications/notifications_provider_test.dart @@ -0,0 +1,196 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:grpc/grpc.dart'; +import 'package:multipass_gui/notifications/notification_entries.dart'; +import 'package:multipass_gui/notifications/notifications_provider.dart'; + +void main() { + group('objectToString', () { + test('returns the toString() of the argument', () { + expect(objectToString(42), equals('42')); + expect(objectToString('hello'), equals('hello')); + expect(objectToString(null), equals('null')); + expect(objectToString([1, 2, 3]), equals('[1, 2, 3]')); + }); + }); + + group('NotificationsNotifier', () { + late ProviderContainer container; + + setUp(() { + container = ProviderContainer(); + // Keep autoDispose provider alive for the duration of each test. + container.listen(notificationsProvider, (_, __) {}); + }); + + tearDown(() { + container.dispose(); + }); + + BuiltList state() => container.read(notificationsProvider); + NotificationsNotifier notifier() => + container.read(notificationsProvider.notifier); + + test('initial state is an empty BuiltList', () { + expect(state(), isEmpty); + expect(state(), isA>()); + }); + + test('add() appends a widget to the list', () { + final widget = const SizedBox(); + notifier().add(widget); + expect(state(), hasLength(1)); + expect(state().first, same(widget)); + }); + + test('add() multiple times grows the list in order', () { + final first = const SizedBox(key: ValueKey('first')); + final second = const SizedBox(key: ValueKey('second')); + final third = const SizedBox(key: ValueKey('third')); + notifier().add(first); + notifier().add(second); + notifier().add(third); + expect(state(), hasLength(3)); + expect(state()[0], same(first)); + expect(state()[1], same(second)); + expect(state()[2], same(third)); + }); + + test('remove() removes the exact widget object from the list', () { + final widget = const SizedBox(); + notifier().add(widget); + expect(state(), hasLength(1)); + notifier().remove(widget); + expect(state(), isEmpty); + }); + + test('remove() of a non-existent widget leaves the list unchanged', () { + final kept = const SizedBox(key: ValueKey('kept')); + final other = const SizedBox(key: ValueKey('other')); + notifier().add(kept); + notifier().remove(other); + expect(state(), hasLength(1)); + expect(state().first, same(kept)); + }); + + test('addError() with a plain object adds an ErrorNotification', () { + notifier().addError('something went wrong'); + expect(state(), hasLength(1)); + expect(state().first, isA()); + }); + + test('addError() with a plain object uses objectToString by default', () { + const error = 'plain error'; + notifier().addError(error); + // The default format is objectToString which calls .toString(). + expect(state(), hasLength(1)); + expect(state().first, isA()); + }); + + test( + 'addError() with a GrpcError extracts the message instead of using the ' + 'full error string', () { + const message = 'grpc error message'; + final grpcError = GrpcError.internal(message); + + var capturedArg = Object(); + notifier().addError(grpcError, (e) { + capturedArg = e!; + return e.toString(); + }); + + // The notifier must have unwrapped the GrpcError to its message before + // calling the format function. + expect(capturedArg, equals(message)); + expect(state(), hasLength(1)); + expect(state().first, isA()); + }); + + test('addError() with a custom format function calls it with the error', + () { + const error = 'raw error'; + var called = false; + String? captured; + + notifier().addError(error, (e) { + called = true; + captured = e as String?; + return 'formatted: $e'; + }); + + expect(called, isTrue); + expect(captured, equals(error)); + expect(state(), hasLength(1)); + expect(state().first, isA()); + }); + + test('addOperation() adds an OperationNotification to the list', () { + final future = Future.value('done'); + notifier().addOperation( + future, + loading: 'loading...', + onSuccess: (result) => 'success: $result', + onError: (e) => 'error: $e', + ); + expect(state(), hasLength(1)); + expect(state().first, isA()); + }); + + test('addOperation() OperationNotification carries the loading text', () { + const loadingText = 'doing work'; + final future = Future.value('result'); + notifier().addOperation( + future, + loading: loadingText, + onSuccess: (r) => r, + onError: (e) => e.toString(), + ); + + final notification = state().first as OperationNotification; + expect(notification.text, equals(loadingText)); + }); + }); + + group('ErrorNotificationWidgetRefExtension.notifyError', () { + testWidgets('adds an ErrorNotification when called with a plain error', + (tester) async { + late WidgetRef capturedRef; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Scaffold( + body: Consumer( + builder: (_, ref, __) { + capturedRef = ref; + ref.watch(notificationsProvider); + return const SizedBox(); + }, + ), + ), + ), + ), + ); + + final handler = capturedRef.notifyError((e) => 'Formatted: $e'); + handler('something went wrong', StackTrace.empty); + + final container = ProviderScope.containerOf( + tester.element(find.byType(Consumer)), + ); + expect(container.read(notificationsProvider), hasLength(1)); + expect( + container.read(notificationsProvider).first, + isA(), + ); + + final notification = + container.read(notificationsProvider).first as ErrorNotification; + + await tester.pumpWidget(MaterialApp(home: Scaffold(body: notification))); + expect(find.text('Formatted: something went wrong'), findsOneWidget); + }); + }); +} diff --git a/src/client/gui/test/providers_extended_test.dart b/src/client/gui/test/providers_extended_test.dart new file mode 100644 index 0000000000..113d7609cb --- /dev/null +++ b/src/client/gui/test/providers_extended_test.dart @@ -0,0 +1,226 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/providers.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _StaticVmInfosNotifier extends AllVmInfosNotifier { + _StaticVmInfosNotifier(this._infos); + final List _infos; + + @override + List build() => _infos; +} + +/// Build a container with [allVmInfosProvider] overridden to return [infos]. +ProviderContainer _containerWithVms(List infos) { + return ProviderContainer( + overrides: [ + allVmInfosProvider.overrideWith(() => _StaticVmInfosNotifier(infos)), + ], + ); +} + +DetailedInfoItem _vm(String name, Status status) => DetailedInfoItem( + name: name, + instanceStatus: InstanceStatus(status: status), + ); + +void main() { + group('deletedVmsProvider', () { + test('returns empty set when no VMs exist', () { + final container = _containerWithVms([]); + addTearDown(container.dispose); + + expect(container.read(deletedVmsProvider), isEmpty); + }); + + test('returns names of DELETED VMs only', () { + final container = _containerWithVms([ + _vm('running-vm', Status.RUNNING), + _vm('deleted-vm', Status.DELETED), + _vm('stopped-vm', Status.STOPPED), + ]); + addTearDown(container.dispose); + + expect( + container.read(deletedVmsProvider), + equals(BuiltSet(['deleted-vm'])), + ); + }); + + test('includes multiple DELETED VMs', () { + final container = _containerWithVms([ + _vm('del1', Status.DELETED), + _vm('del2', Status.DELETED), + _vm('live', Status.RUNNING), + ]); + addTearDown(container.dispose); + + expect( + container.read(deletedVmsProvider), + containsAll(['del1', 'del2']), + ); + expect(container.read(deletedVmsProvider), hasLength(2)); + }); + + test('returns empty set when all VMs are non-DELETED', () { + final container = _containerWithVms([ + _vm('vm1', Status.RUNNING), + _vm('vm2', Status.STOPPED), + ]); + addTearDown(container.dispose); + + expect(container.read(deletedVmsProvider), isEmpty); + }); + }); + + group('trayMenuDataProvider', () { + test('returns vmStatusesProvider when daemon is available', () { + final statuses = BuiltMap({ + 'vm1': Status.RUNNING, + 'vm2': Status.STOPPED, + }); + + final container = ProviderContainer( + overrides: [ + daemonAvailableProvider.overrideWithValue(true), + vmStatusesProvider.overrideWithValue(statuses), + ], + ); + addTearDown(container.dispose); + + expect(container.read(trayMenuDataProvider), equals(statuses)); + }); + + test('returns null when daemon is not available', () { + final container = ProviderContainer( + overrides: [ + daemonAvailableProvider.overrideWithValue(false), + vmStatusesProvider.overrideWithValue(BuiltMap()), + ], + ); + addTearDown(container.dispose); + + expect(container.read(trayMenuDataProvider), isNull); + }); + }); + + group('SessionTerminalFontSizeNotifier', () { + test('initial state is the default font size (13.0)', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect( + container.read(sessionTerminalFontSizeProvider), + equals(SessionTerminalFontSizeNotifier.defaultFontSize), + ); + }); + + test('set() updates the font size', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(sessionTerminalFontSizeProvider.notifier).set(16.0); + + expect(container.read(sessionTerminalFontSizeProvider), equals(16.0)); + }); + }); + + group('GuiSettingNotifier', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('returns stored value when key is present in SharedPreferences', + () async { + SharedPreferences.setMockInitialValues({'hotkey': 'ctrl+h'}); + final prefs = await SharedPreferences.getInstance(); + + final container = ProviderContainer( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + ], + ); + addTearDown(container.dispose); + + expect(container.read(guiSettingProvider('hotkey')), equals('ctrl+h')); + }); + + test('returns default "ask" for onAppCloseKey when not stored', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + final container = ProviderContainer( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + ], + ); + addTearDown(container.dispose); + + expect(container.read(guiSettingProvider(onAppCloseKey)), equals('ask')); + }); + + test('returns null for unknown key with no stored value', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + final container = ProviderContainer( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + ], + ); + addTearDown(container.dispose); + + expect(container.read(guiSettingProvider('unknown-key')), isNull); + }); + + test('set() persists to SharedPreferences and updates state', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + final container = ProviderContainer( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + ], + ); + addTearDown(container.dispose); + container.listen(guiSettingProvider('hotkey'), (_, __) {}); + + container.read(guiSettingProvider('hotkey').notifier).set('ctrl+k'); + + expect(container.read(guiSettingProvider('hotkey')), equals('ctrl+k')); + expect(prefs.getString('hotkey'), equals('ctrl+k')); + }); + + test('updateShouldNotify returns true when value changes', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + final container = ProviderContainer( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + ], + ); + addTearDown(container.dispose); + + final notifier = container.read(guiSettingProvider('k').notifier); + expect(notifier.updateShouldNotify('old', 'new'), isTrue); + }); + + test('updateShouldNotify returns false when value is unchanged', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + final container = ProviderContainer( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + ], + ); + addTearDown(container.dispose); + + final notifier = container.read(guiSettingProvider('k').notifier); + expect(notifier.updateShouldNotify('same', 'same'), isFalse); + }); + }); +} diff --git a/src/client/gui/test/providers_logic_test.dart b/src/client/gui/test/providers_logic_test.dart new file mode 100644 index 0000000000..f1295801cd --- /dev/null +++ b/src/client/gui/test/providers_logic_test.dart @@ -0,0 +1,239 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:grpc/grpc.dart'; +import 'package:multipass_gui/grpc_client.dart'; +import 'package:multipass_gui/providers.dart'; + +class _FakeAllVmInfosNotifier extends AllVmInfosNotifier { + _FakeAllVmInfosNotifier(this._items); + + final List _items; + + @override + List build() => _items; +} + +class _FakeLaunchingVmsNotifier extends LaunchingVmsNotifier { + _FakeLaunchingVmsNotifier(this._items); + + final BuiltList _items; + + @override + BuiltList build() => _items; +} + +void main() { + group('daemonAvailableProvider', () { + test('returns false when ffi is unavailable, regardless of stream state', + () { + final container = ProviderContainer( + overrides: [ffiAvailableProvider.overrideWithValue(false)], + ); + addTearDown(container.dispose); + + expect(container.read(daemonAvailableProvider), isFalse); + }); + + test('returns true when ffi is available and stream has no error', () { + final container = ProviderContainer( + overrides: [ + ffiAvailableProvider.overrideWithValue(true), + vmInfosStreamProvider.overrideWithValue(const AsyncData([])), + ], + ); + addTearDown(container.dispose); + + expect(container.read(daemonAvailableProvider), isTrue); + }); + + test( + 'returns false when ffi is available but stream has a GrpcError with ' + 'an unrecognised message', () { + final error = GrpcError.unavailable('some unknown error'); + final container = ProviderContainer( + overrides: [ + ffiAvailableProvider.overrideWithValue(true), + vmInfosStreamProvider.overrideWithValue( + AsyncError(error, StackTrace.empty), + ), + ], + ); + addTearDown(container.dispose); + + expect(container.read(daemonAvailableProvider), isFalse); + }); + + test( + 'returns true when ffi is available and stream has the known harmless ' + 'GrpcError message', () { + final error = GrpcError.unavailable( + 'failed to obtain exit status for remote process', + ); + final container = ProviderContainer( + overrides: [ + ffiAvailableProvider.overrideWithValue(true), + vmInfosStreamProvider.overrideWithValue( + AsyncError(error, StackTrace.empty), + ), + ], + ); + addTearDown(container.dispose); + + expect(container.read(daemonAvailableProvider), isTrue); + }); + + test('returns false when ffi is available but stream has a non-GrpcError', + () { + final container = ProviderContainer( + overrides: [ + ffiAvailableProvider.overrideWithValue(true), + vmInfosStreamProvider.overrideWithValue( + AsyncError(Exception('some non-grpc error'), StackTrace.empty), + ), + ], + ); + addTearDown(container.dispose); + + expect(container.read(daemonAvailableProvider), isFalse); + }); + }); + + group('vmInfosProvider', () { + ProviderContainer makeContainer({ + required List allVmInfos, + BuiltList? launchingVms, + }) { + return ProviderContainer( + overrides: [ + allVmInfosProvider.overrideWith( + () => _FakeAllVmInfosNotifier(allVmInfos), + ), + launchingVmsProvider.overrideWith( + () => _FakeLaunchingVmsNotifier(launchingVms ?? BuiltList()), + ), + ], + ); + } + + test('excludes DELETED VMs from the result', () { + final container = makeContainer( + allVmInfos: [ + DetailedInfoItem( + name: 'vm-running', + instanceStatus: InstanceStatus(status: Status.RUNNING), + ), + DetailedInfoItem( + name: 'vm-deleted', + instanceStatus: InstanceStatus(status: Status.DELETED), + ), + ], + ); + addTearDown(container.dispose); + + final names = container.read(vmInfosProvider).map((v) => v.name).toList(); + + expect(names, contains('vm-running')); + expect(names, isNot(contains('vm-deleted'))); + }); + + test('sorts VMs alphabetically by name', () { + final container = makeContainer( + allVmInfos: [ + DetailedInfoItem( + name: 'vm-c', + instanceStatus: InstanceStatus(status: Status.STOPPED), + ), + DetailedInfoItem( + name: 'vm-a', + instanceStatus: InstanceStatus(status: Status.RUNNING), + ), + DetailedInfoItem( + name: 'vm-b', + instanceStatus: InstanceStatus(status: Status.STOPPED), + ), + ], + ); + addTearDown(container.dispose); + + final names = container.read(vmInfosProvider).map((v) => v.name).toList(); + + expect(names, equals(['vm-a', 'vm-b', 'vm-c'])); + }); + + test('appends launching VMs not already in the existing list', () { + final container = makeContainer( + allVmInfos: [ + DetailedInfoItem( + name: 'vm-a', + instanceStatus: InstanceStatus(status: Status.RUNNING), + ), + ], + launchingVms: BuiltList([ + DetailedInfoItem( + name: 'vm-launching', + instanceStatus: InstanceStatus(status: Status.STARTING), + ), + ]), + ); + addTearDown(container.dispose); + + final names = container.read(vmInfosProvider).map((v) => v.name).toList(); + + expect(names, containsAll(['vm-a', 'vm-launching'])); + }); + + test( + 'does not duplicate a launching VM already present in the existing list', + () { + final container = makeContainer( + allVmInfos: [ + DetailedInfoItem( + name: 'vm-a', + instanceStatus: InstanceStatus(status: Status.RUNNING), + ), + ], + launchingVms: BuiltList([ + DetailedInfoItem( + name: 'vm-a', + instanceStatus: InstanceStatus(status: Status.STARTING), + ), + ]), + ); + addTearDown(container.dispose); + + final names = container.read(vmInfosProvider).map((v) => v.name).toList(); + + expect(names.where((n) => n == 'vm-a'), hasLength(1)); + }); + + test('returns empty list when all inputs are empty', () { + final container = makeContainer(allVmInfos: []); + addTearDown(container.dispose); + + expect(container.read(vmInfosProvider), isEmpty); + }); + + test('combines and sorts existing VMs and launching VMs by name', () { + final container = makeContainer( + allVmInfos: [ + DetailedInfoItem( + name: 'vm-b', + instanceStatus: InstanceStatus(status: Status.STOPPED), + ), + ], + launchingVms: BuiltList([ + DetailedInfoItem( + name: 'vm-a', + instanceStatus: InstanceStatus(status: Status.STARTING), + ), + ]), + ); + addTearDown(container.dispose); + + final names = container.read(vmInfosProvider).map((v) => v.name).toList(); + + expect(names, equals(['vm-a', 'vm-b'])); + }); + }); +} diff --git a/src/client/gui/test/quit_and_terminal_dialogs_test.dart b/src/client/gui/test/quit_and_terminal_dialogs_test.dart new file mode 100644 index 0000000000..5f8aa89927 --- /dev/null +++ b/src/client/gui/test/quit_and_terminal_dialogs_test.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/before_quit_dialog.dart'; +import 'package:multipass_gui/close_terminal_dialog.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; + +Widget buildLocalized(Widget widget) { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: Dialog(child: widget)), + ); +} + +void main() { + group('BeforeQuitDialog', () { + testWidgets('initial remember state is false', (tester) async { + await tester.pumpWidget(buildLocalized( + BeforeQuitDialog( + runningCount: 1, + onStop: (_) {}, + onKeep: (_) {}, + ), + )); + await tester.pumpAndSettle(); + + expect(tester.widget(find.byType(Checkbox)).value, isFalse); + }); + + testWidgets('tapping Checkbox toggles remember to true', (tester) async { + await tester.pumpWidget(buildLocalized( + BeforeQuitDialog( + runningCount: 1, + onStop: (_) {}, + onKeep: (_) {}, + ), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(Checkbox)); + await tester.pump(); + + expect(tester.widget(find.byType(Checkbox)).value, isTrue); + }); + + testWidgets('tapping action button calls onStop(false) when remember=false', + (tester) async { + bool? rememberedValue; + await tester.pumpWidget(buildLocalized( + BeforeQuitDialog( + runningCount: 1, + onStop: (v) => rememberedValue = v, + onKeep: (_) {}, + ), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(TextButton)); + await tester.pump(); + + expect(rememberedValue, isFalse); + }); + + testWidgets( + 'tapping action button calls onStop(true) when checkbox checked first', + (tester) async { + bool? rememberedValue; + await tester.pumpWidget(buildLocalized( + BeforeQuitDialog( + runningCount: 1, + onStop: (v) => rememberedValue = v, + onKeep: (_) {}, + ), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(Checkbox)); + await tester.pump(); + await tester.tap(find.byType(TextButton)); + await tester.pump(); + + expect(rememberedValue, isTrue); + }); + + testWidgets( + 'tapping inaction button calls onKeep(false) when remember=false', + (tester) async { + bool? keptValue; + await tester.pumpWidget(buildLocalized( + BeforeQuitDialog( + runningCount: 1, + onStop: (_) {}, + onKeep: (v) => keptValue = v, + ), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(OutlinedButton)); + await tester.pump(); + + expect(keptValue, isFalse); + }); + + testWidgets( + 'tapping inaction button calls onKeep(true) when checkbox checked first', + (tester) async { + bool? keptValue; + await tester.pumpWidget(buildLocalized( + BeforeQuitDialog( + runningCount: 1, + onStop: (_) {}, + onKeep: (v) => keptValue = v, + ), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(Checkbox)); + await tester.pump(); + await tester.tap(find.byType(OutlinedButton)); + await tester.pump(); + + expect(keptValue, isTrue); + }); + }); + + group('CloseTerminalDialog', () { + testWidgets('initial doNotAsk state is false', (tester) async { + await tester.pumpWidget(buildLocalized( + CloseTerminalDialog( + onYes: () {}, + onNo: () {}, + onDoNotAsk: (_) {}, + ), + )); + await tester.pumpAndSettle(); + + expect(tester.widget(find.byType(Checkbox)).value, isFalse); + }); + + testWidgets('tapping Checkbox toggles doNotAsk to true', (tester) async { + await tester.pumpWidget(buildLocalized( + CloseTerminalDialog( + onYes: () {}, + onNo: () {}, + onDoNotAsk: (_) {}, + ), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(Checkbox)); + await tester.pump(); + + expect(tester.widget(find.byType(Checkbox)).value, isTrue); + }); + + testWidgets( + 'tapping action button calls onYes() and onDoNotAsk(false) when doNotAsk=false', + (tester) async { + bool yesCalled = false; + bool? doNotAskValue; + await tester.pumpWidget(buildLocalized( + CloseTerminalDialog( + onYes: () => yesCalled = true, + onNo: () {}, + onDoNotAsk: (v) => doNotAskValue = v, + ), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(TextButton)); + await tester.pump(); + + expect(yesCalled, isTrue); + expect(doNotAskValue, isFalse); + }); + + testWidgets( + 'tapping action button calls onDoNotAsk(true) and onYes() when checkbox checked first', + (tester) async { + bool yesCalled = false; + bool? doNotAskValue; + await tester.pumpWidget(buildLocalized( + CloseTerminalDialog( + onYes: () => yesCalled = true, + onNo: () {}, + onDoNotAsk: (v) => doNotAskValue = v, + ), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(Checkbox)); + await tester.pump(); + await tester.tap(find.byType(TextButton)); + await tester.pump(); + + expect(doNotAskValue, isTrue); + expect(yesCalled, isTrue); + }); + + testWidgets( + 'tapping inaction button calls onNo() and does NOT call onDoNotAsk', + (tester) async { + bool noCalled = false; + bool doNotAskCalled = false; + await tester.pumpWidget(buildLocalized( + CloseTerminalDialog( + onYes: () {}, + onNo: () => noCalled = true, + onDoNotAsk: (_) => doNotAskCalled = true, + ), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(OutlinedButton)); + await tester.pump(); + + expect(noCalled, isTrue); + expect(doNotAskCalled, isFalse); + }); + }); +} diff --git a/src/client/gui/test/search_box_test.dart b/src/client/gui/test/search_box_test.dart new file mode 100644 index 0000000000..62b124164b --- /dev/null +++ b/src/client/gui/test/search_box_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/vm_table/search_box.dart'; + +void main() { + group('SearchBox', () { + Widget buildApp({required Widget child}) { + return ProviderScope( + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), + ), + ); + } + + testWidgets('renders a TextField', (tester) async { + await tester.pumpWidget(buildApp(child: const SearchBox())); + expect(find.byType(TextField), findsOneWidget); + }); + + testWidgets('shows a search suffix icon', (tester) async { + await tester.pumpWidget(buildApp(child: const SearchBox())); + expect(find.byIcon(Icons.search), findsOneWidget); + }); + + testWidgets('typing text updates searchNameProvider', (tester) async { + late WidgetRef capturedRef; + + await tester.pumpWidget( + buildApp( + child: Consumer( + builder: (_, ref, __) { + capturedRef = ref; + return const SearchBox(); + }, + ), + ), + ); + + await tester.enterText(find.byType(TextField), 'myvm'); + + expect(capturedRef.read(searchNameProvider), equals('myvm')); + }); + + testWidgets('clearing the text resets searchNameProvider to empty', + (tester) async { + late WidgetRef capturedRef; + + await tester.pumpWidget( + buildApp( + child: Consumer( + builder: (_, ref, __) { + capturedRef = ref; + return const SearchBox(); + }, + ), + ), + ); + + await tester.enterText(find.byType(TextField), 'filter'); + expect(capturedRef.read(searchNameProvider), equals('filter')); + + await tester.enterText(find.byType(TextField), ''); + expect(capturedRef.read(searchNameProvider), equals('')); + }); + }); +} diff --git a/src/client/gui/test/settings/general_settings_test.dart b/src/client/gui/test/settings/general_settings_test.dart new file mode 100644 index 0000000000..aca0db5d0d --- /dev/null +++ b/src/client/gui/test/settings/general_settings_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart' hide Switch; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/grpc_client.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/providers.dart'; +import 'package:multipass_gui/settings/autostart_notifiers.dart'; +import 'package:multipass_gui/settings/general_settings.dart'; +import 'package:multipass_gui/update_available.dart'; + +Widget _buildApp({ + UpdateInfo? update, + String? onAppClose, +}) { + return ProviderScope( + overrides: [ + updateProvider.overrideWithBuild( + (ref, _) => update ?? UpdateInfo(), + ), + onAppCloseProvider.overrideWithBuild( + (ref, _) => onAppClose ?? 'ask', + ), + autostartProvider.overrideWith( + () => _StaticAutostartNotifier(false), + ), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: SizedBox(width: 900, child: GeneralSettings()), + ), + ), + ); +} + +class _StaticAutostartNotifier extends AutostartNotifier { + _StaticAutostartNotifier(this._value); + final bool _value; + + @override + Future build() async => _value; + + @override + Future doSet(bool value) async {} +} + +void main() { + group('GeneralSettings — UpdateAvailable banner', () { + testWidgets('shows UpdateAvailable widget when version is set', + (tester) async { + await tester.pumpWidget( + _buildApp(update: UpdateInfo()..version = '1.2.3'), + ); + await tester.pumpAndSettle(); + + expect(find.byType(UpdateAvailable), findsOneWidget); + }); + + testWidgets('hides UpdateAvailable widget when version is blank', + (tester) async { + await tester.pumpWidget(_buildApp(update: UpdateInfo())); + await tester.pumpAndSettle(); + + expect(find.byType(UpdateAvailable), findsNothing); + }); + }); + + group('GeneralSettings — on-close dropdown', () { + testWidgets('renders on-close dropdown', (tester) async { + await tester.pumpWidget(_buildApp(onAppClose: 'ask')); + await tester.pumpAndSettle(); + + expect(find.byType(DropdownButton), findsOneWidget); + }); + }); +} diff --git a/src/client/gui/test/settings/usage_settings_test.dart b/src/client/gui/test/settings/usage_settings_test.dart new file mode 100644 index 0000000000..6b4df3a2e6 --- /dev/null +++ b/src/client/gui/test/settings/usage_settings_test.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/settings/usage_settings.dart'; + +Widget _buildApp(Widget child) { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), + ); +} + +void main() { + group('PrimaryNameField validation', () { + Future pumpField( + WidgetTester tester, { + String value = '', + void Function(String)? onSave, + }) async { + await tester.pumpWidget( + _buildApp( + Builder( + builder: (context) { + final l10n = AppLocalizations.of(context)!; + return PrimaryNameField( + value: value, + l10n: l10n, + onSave: onSave ?? (_) {}, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('empty input shows no validation error', (tester) async { + await pumpField(tester, value: ''); + + await tester.enterText(find.byType(TextFormField), ''); + await tester.pump(); + + expect(find.byType(ErrorWidget), findsNothing); + }); + + testWidgets('input starting with a digit shows error', (tester) async { + await pumpField(tester, value: ''); + + await tester.enterText(find.byType(TextFormField), '1abc'); + await tester.pump(); + + expect(find.byIcon(Icons.check), findsOneWidget); + await tester.tap(find.byIcon(Icons.check)); + await tester.pump(); + + final l10n = tester.element(find.byType(TextFormField)).l10n; + expect(find.text(l10n.usagePrimaryNameErrorStartLetter), findsOneWidget); + }); + + testWidgets('single character input shows too-short error', (tester) async { + await pumpField(tester, value: ''); + + await tester.enterText(find.byType(TextFormField), 'a'); + await tester.pump(); + await tester.tap(find.byIcon(Icons.check)); + await tester.pump(); + + final l10n = tester.element(find.byType(TextFormField)).l10n; + expect(find.text(l10n.usagePrimaryNameErrorTooShort), findsOneWidget); + }); + + testWidgets('input ending with a dash shows error', (tester) async { + await pumpField(tester, value: ''); + + await tester.enterText(find.byType(TextFormField), 'abc-'); + await tester.pump(); + await tester.tap(find.byIcon(Icons.check)); + await tester.pump(); + + final l10n = tester.element(find.byType(TextFormField)).l10n; + expect(find.text(l10n.usagePrimaryNameErrorEndChar), findsOneWidget); + }); + + testWidgets('valid input calls onSave', (tester) async { + String? saved; + await pumpField(tester, value: '', onSave: (v) => saved = v); + + await tester.enterText(find.byType(TextFormField), 'my-vm'); + await tester.pump(); + await tester.tap(find.byIcon(Icons.check)); + await tester.pump(); + + expect(saved, equals('my-vm')); + }); + + testWidgets('discard reverts text to original value', (tester) async { + await pumpField(tester, value: 'original'); + + await tester.enterText(find.byType(TextFormField), 'changed'); + await tester.pump(); + + expect(find.byIcon(Icons.close), findsOneWidget); + await tester.tap(find.byIcon(Icons.close)); + await tester.pump(); + + final textField = + tester.widget(find.byType(TextFormField)); + expect(textField.controller?.text, equals('original')); + }); + }); + + group('SettingField', () { + testWidgets('save and discard buttons hidden when not changed', + (tester) async { + await tester.pumpWidget( + _buildApp( + SettingField( + label: 'Test Label', + onSave: () {}, + onDiscard: () {}, + changed: false, + child: const SizedBox(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.check), findsNothing); + expect(find.byIcon(Icons.close), findsNothing); + }); + + testWidgets('save and discard buttons visible when changed', + (tester) async { + await tester.pumpWidget( + _buildApp( + SettingField( + label: 'Test Label', + onSave: () {}, + onDiscard: () {}, + changed: true, + child: const SizedBox(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.check), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget); + }); + + testWidgets('tapping save calls onSave', (tester) async { + var saveCalled = false; + await tester.pumpWidget( + _buildApp( + SettingField( + label: 'Test Label', + onSave: () => saveCalled = true, + onDiscard: () {}, + changed: true, + child: const SizedBox(), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.check)); + await tester.pump(); + + expect(saveCalled, isTrue); + }); + + testWidgets('tapping discard calls onDiscard', (tester) async { + var discardCalled = false; + await tester.pumpWidget( + _buildApp( + SettingField( + label: 'Test Label', + onSave: () {}, + onDiscard: () => discardCalled = true, + changed: true, + child: const SizedBox(), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pump(); + + expect(discardCalled, isTrue); + }); + + testWidgets('label text is displayed', (tester) async { + await tester.pumpWidget( + _buildApp( + SettingField( + label: 'My Setting', + onSave: () {}, + onDiscard: () {}, + changed: false, + child: const SizedBox(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('My Setting'), findsOneWidget); + }); + }); +} + +extension on BuildContext { + AppLocalizations get l10n => AppLocalizations.of(this)!; +} diff --git a/src/client/gui/test/settings/virtualization_settings_test.dart b/src/client/gui/test/settings/virtualization_settings_test.dart new file mode 100644 index 0000000000..500c131fcc --- /dev/null +++ b/src/client/gui/test/settings/virtualization_settings_test.dart @@ -0,0 +1,78 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/providers.dart'; +import 'package:multipass_gui/settings/virtualization_settings.dart'; + +Widget _buildApp({ + String driver = 'qemu', + String bridgedNetwork = '', + Set networks = const {}, +}) { + return ProviderScope( + overrides: [ + driverProvider.overrideWithBuild((ref, _) => driver), + bridgedNetworkProvider.overrideWithBuild((ref, _) => bridgedNetwork), + networksProvider.overrideWith((_) async => BuiltSet(networks)), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: SizedBox(width: 600, child: VirtualizationSettings()), + ), + ), + ); +} + +void main() { + group('VirtualizationSettings', () { + testWidgets('shows the Virtualization section title', (tester) async { + await tester.pumpWidget(_buildApp()); + await tester.pumpAndSettle(); + + expect(find.text('Virtualization'), findsOneWidget); + }); + + testWidgets('shows the Driver label and dropdown', (tester) async { + await tester.pumpWidget(_buildApp(driver: 'qemu')); + await tester.pumpAndSettle(); + + expect(find.text('Driver'), findsOneWidget); + expect(find.byType(DropdownButton), findsOneWidget); + }); + + testWidgets('hides bridge dropdown when no networks are available', + (tester) async { + await tester.pumpWidget(_buildApp(networks: {})); + await tester.pumpAndSettle(); + + expect(find.text('Bridged network'), findsNothing); + expect(find.byType(DropdownButton), findsOneWidget); + }); + + testWidgets('shows bridge dropdown when networks are available', + (tester) async { + await tester.pumpWidget(_buildApp(networks: {'eth0', 'en0'})); + await tester.pumpAndSettle(); + + expect(find.text('Bridged network'), findsOneWidget); + expect(find.byType(DropdownButton), findsNWidgets(2)); + }); + + testWidgets( + 'bridge dropdown value is "none" when bridged network is not in the networks list', + (tester) async { + await tester.pumpWidget(_buildApp( + bridgedNetwork: 'eth99', + networks: {'eth0', 'en0'}, + )); + await tester.pumpAndSettle(); + + expect(find.text('None'), findsOneWidget); + }); + }); +} diff --git a/src/client/gui/test/sidebar_test.dart b/src/client/gui/test/sidebar_test.dart new file mode 100644 index 0000000000..3268a19671 --- /dev/null +++ b/src/client/gui/test/sidebar_test.dart @@ -0,0 +1,232 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/providers.dart'; +import 'package:multipass_gui/sidebar.dart'; +import 'package:multipass_gui/vm_details/terminal.dart'; + +import 'helpers.dart'; + +class _MutableVmNamesNotifier extends Notifier> { + @override + BuiltSet build() => BuiltSet(); + + void setNames(BuiltSet names) { + state = names; + } +} + +final _mutableVmNamesProvider = + NotifierProvider<_MutableVmNamesNotifier, BuiltSet>( + _MutableVmNamesNotifier.new, +); + +void main() { + group('SidebarKeyNotifier', () { + ProviderContainer buildContainer(Iterable vmNames) { + return ProviderContainer( + overrides: [ + vmNamesProvider.overrideWith((ref) => vmNames.toBuiltSet()), + ], + ); + } + + test('initial state is catalogue', () { + final container = buildContainer([]); + addTearDown(container.dispose); + + expect(container.read(sidebarKeyProvider), equals('catalogue')); + }); + + test('set updates state to the given key', () { + final container = buildContainer([]); + addTearDown(container.dispose); + + container.read(sidebarKeyProvider.notifier).set('vms'); + + expect(container.read(sidebarKeyProvider), equals('vms')); + }); + + test('set with a vm key marks vmVisitedProvider as visited', () { + final container = buildContainer(['myvm']); + addTearDown(container.dispose); + + container.read(sidebarKeyProvider.notifier).set('vm-myvm'); + + expect(container.read(sidebarKeyProvider), equals('vm-myvm')); + expect(container.read(vmVisitedProvider('vm-myvm')), isTrue); + }); + + test('set with a non-vm key does not affect vmVisitedProvider', () { + final container = buildContainer([]); + addTearDown(container.dispose); + + container.read(sidebarKeyProvider.notifier).set('catalogue'); + + expect(container.read(vmVisitedProvider('catalogue')), isFalse); + }); + + test('resets to catalogue when current vm is removed from vmNamesProvider', + () { + final container = ProviderContainer( + overrides: [ + vmNamesProvider.overrideWith( + (ref) => ref.watch(_mutableVmNamesProvider), + ), + ], + ); + addTearDown(container.dispose); + + // Seed with 'foo' so the sidebar key can be set to 'vm-foo'. + container + .read(_mutableVmNamesProvider.notifier) + .setNames(BuiltSet(['foo'])); + + // Listen to keep sidebarKeyProvider alive across state changes. + container.listen(sidebarKeyProvider, (_, __) {}); + + container.read(sidebarKeyProvider.notifier).set('vm-foo'); + expect(container.read(sidebarKeyProvider), equals('vm-foo')); + + // Remove 'foo', the notifier should invalidate and reset to 'catalogue'. + container.read(_mutableVmNamesProvider.notifier).setNames(BuiltSet()); + + expect(container.read(sidebarKeyProvider), equals('catalogue')); + }); + }); + + group('VmVisitedNotifier', () { + test('initial state is false', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect(container.read(vmVisitedProvider('vm-test')), isFalse); + }); + + test('setVisited changes state to true', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(vmVisitedProvider('vm-test').notifier).setVisited(); + + expect(container.read(vmVisitedProvider('vm-test')), isTrue); + }); + + test('remains true after multiple setVisited calls', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(vmVisitedProvider('vm-test').notifier).setVisited(); + container.read(vmVisitedProvider('vm-test').notifier).setVisited(); + + expect(container.read(vmVisitedProvider('vm-test')), isTrue); + }); + }); + + group('SidebarExpandedNotifier', () { + test('initial state is false', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect(container.read(sidebarExpandedProvider), isFalse); + }); + + test('setExpanded(true) updates state to true', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(sidebarExpandedProvider.notifier).setExpanded(true); + + expect(container.read(sidebarExpandedProvider), isTrue); + }); + + test('setExpanded(false) updates state to false', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(sidebarExpandedProvider.notifier).setExpanded(true); + container.read(sidebarExpandedProvider.notifier).setExpanded(false); + + expect(container.read(sidebarExpandedProvider), isFalse); + }); + }); + + group('SidebarPushContentNotifier', () { + test('initial state is false', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect(container.read(sidebarPushContentProvider), isFalse); + }); + + test('setPushContent(true) updates state to true', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(sidebarPushContentProvider.notifier).setPushContent(true); + + expect(container.read(sidebarPushContentProvider), isTrue); + }); + + test('setPushContent(false) updates state to false', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(sidebarPushContentProvider.notifier).setPushContent(true); + container.read(sidebarPushContentProvider.notifier).setPushContent(false); + + expect(container.read(sidebarPushContentProvider), isFalse); + }); + }); + + group('SideBar widget', () { + Widget buildSidebar({List vmNames = const []}) { + return withFakeSvgAssetBundle( + ProviderScope( + overrides: [ + vmNamesProvider.overrideWith((ref) => BuiltSet(vmNames)), + for (final name in vmNames) + runningShellsProvider(name).overrideWithBuild((ref, _) => 0), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: SideBar()), + ), + ), + ); + } + + testWidgets('renders a SidebarEntry for each VM name', (tester) async { + await tester.pumpWidget(buildSidebar(vmNames: ['alpha', 'beta'])); + await tester.pump(); + + // 4 fixed + 2 VM entries + expect(find.byType(SidebarEntry), findsNWidgets(6)); + }); + + testWidgets('instance count badge reflects the number of VMs', + (tester) async { + await tester.pumpWidget(buildSidebar(vmNames: ['alpha', 'beta'])); + await tester.pump(); + + expect(find.text('2'), findsOneWidget); + }); + + testWidgets('selected entry receives highlighted background', + (tester) async { + await tester.pumpWidget(buildSidebar()); + await tester.pump(); + + // The catalogue entry is selected by default. + final selectedEntries = tester + .widgetList(find.byType(SidebarEntry)) + .where((e) => e.selected) + .toList(); + expect(selectedEntries, hasLength(1)); + }); + }); +} diff --git a/src/client/gui/test/switch_test.dart b/src/client/gui/test/switch_test.dart new file mode 100644 index 0000000000..efb3ae07c9 --- /dev/null +++ b/src/client/gui/test/switch_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/switch.dart' show Switch; + +void main() { + Widget buildSwitch({ + required bool value, + ValueChanged? onChanged, + String label = '', + bool trailingSwitch = false, + double size = 25, + bool enabled = true, + }) { + return CupertinoApp( + home: CupertinoPageScaffold( + child: Center( + child: Switch( + value: value, + onChanged: onChanged, + label: label, + trailingSwitch: trailingSwitch, + size: size, + enabled: enabled, + ), + ), + ), + ); + } + + testWidgets('CupertinoSwitch value is true when value is true', + (tester) async { + await tester.pumpWidget(buildSwitch(value: true)); + await tester.pumpAndSettle(); + + final cupertinoSwitch = + tester.widget(find.byType(CupertinoSwitch)); + expect(cupertinoSwitch.value, isTrue); + }); + + testWidgets('CupertinoSwitch value is false when value is false', + (tester) async { + await tester.pumpWidget(buildSwitch(value: false)); + await tester.pumpAndSettle(); + + final cupertinoSwitch = + tester.widget(find.byType(CupertinoSwitch)); + expect(cupertinoSwitch.value, isFalse); + }); + + testWidgets('CupertinoSwitch onChanged is not null when enabled is true', + (tester) async { + await tester.pumpWidget(buildSwitch(value: false, onChanged: (_) {})); + await tester.pumpAndSettle(); + + final cupertinoSwitch = + tester.widget(find.byType(CupertinoSwitch)); + expect(cupertinoSwitch.onChanged, isNotNull); + }); + + testWidgets('CupertinoSwitch onChanged is null when enabled is false', + (tester) async { + await tester.pumpWidget( + buildSwitch(value: false, onChanged: (_) {}, enabled: false)); + await tester.pumpAndSettle(); + + final cupertinoSwitch = + tester.widget(find.byType(CupertinoSwitch)); + expect(cupertinoSwitch.onChanged, isNull); + }); + + testWidgets('switch appears before label when trailingSwitch is false', + (tester) async { + await tester.pumpWidget(buildSwitch(value: false, label: 'My Label')); + await tester.pumpAndSettle(); + + final switchRect = tester.getTopLeft(find.byType(CupertinoSwitch)); + final textRect = tester.getTopLeft(find.text('My Label')); + expect(switchRect.dx, lessThan(textRect.dx)); + }); + + testWidgets('label appears before switch when trailingSwitch is true', + (tester) async { + await tester.pumpWidget( + buildSwitch(value: false, label: 'My Label', trailingSwitch: true)); + await tester.pumpAndSettle(); + + final switchRect = tester.getTopLeft(find.byType(CupertinoSwitch)); + final textRect = tester.getTopLeft(find.text('My Label')); + expect(textRect.dx, lessThan(switchRect.dx)); + }); + + testWidgets('tapping switch invokes callback when enabled is true', + (tester) async { + bool? receivedValue; + await tester.pumpWidget( + buildSwitch(value: false, onChanged: (v) => receivedValue = v)); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(CupertinoSwitch)); + await tester.pumpAndSettle(); + + expect(receivedValue, isTrue); + }); + + testWidgets('tapping switch does not invoke callback when enabled is false', + (tester) async { + var callCount = 0; + await tester.pumpWidget(buildSwitch( + value: false, onChanged: (_) => callCount++, enabled: false)); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(CupertinoSwitch)); + await tester.pumpAndSettle(); + + expect(callCount, equals(0)); + }); + + testWidgets('empty label renders an empty Text widget', (tester) async { + await tester.pumpWidget(buildSwitch(value: false)); + await tester.pumpAndSettle(); + + final textWidget = tester.widget(find.byType(Text)); + expect(textWidget.data, isEmpty); + }); +} diff --git a/src/client/gui/test/update_available_test.dart b/src/client/gui/test/update_available_test.dart new file mode 100644 index 0000000000..09a7233675 --- /dev/null +++ b/src/client/gui/test/update_available_test.dart @@ -0,0 +1,151 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/grpc_client.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/notifications/notifications_provider.dart'; +import 'package:multipass_gui/update_available.dart'; + +import 'helpers.dart'; + +void main() { + group('UpdateNotifier', () { + late ProviderContainer container; + + setUp(() { + container = ProviderContainer(); + // Keep the autoDispose notificationsProvider alive for the duration of each test. + container.listen(notificationsProvider, (_, __) {}); + }); + + tearDown(() { + container.dispose(); + }); + + UpdateInfo state() => container.read(updateProvider); + UpdateNotifier notifier() => container.read(updateProvider.notifier); + BuiltList notifications() => container.read(notificationsProvider); + + test('initial state is an empty UpdateInfo', () { + expect(state(), equals(UpdateInfo())); + }); + + test('set() with default UpdateInfo (empty version) does not update state', + () { + notifier().set(UpdateInfo()); + expect(state(), equals(UpdateInfo())); + }); + + test( + 'set() with explicitly blank version does not update state or add notification', + () { + notifier().set(UpdateInfo(version: '')); + expect(state(), equals(UpdateInfo())); + expect(notifications(), isEmpty); + }); + + test('set() updates state', () { + final info = UpdateInfo(version: '1.15.0'); + notifier().set(info); + expect(state(), equals(info)); + }); + + test('set() adds an UpdateAvailableNotification', () { + final info = UpdateInfo(version: '1.15.0'); + notifier().set(info); + expect(notifications(), hasLength(1)); + expect(notifications().first, isA()); + final notification = notifications().first as UpdateAvailableNotification; + expect(notification.updateInfo, equals(info)); + }); + + test( + 'set() called twice with the same UpdateInfo only adds one notification', + () { + final info = UpdateInfo(version: '1.15.0'); + notifier().set(info); + notifier().set(info); + expect(notifications(), hasLength(1)); + }); + + test( + 'set() called with two different versions adds a notification for each', + () { + notifier().set(UpdateInfo(version: '1.15.0')); + notifier().set(UpdateInfo(version: '1.16.0')); + expect(notifications(), hasLength(2)); + }); + + test('updateShouldNotify returns true when versions differ', () { + final previous = UpdateInfo(version: '1.14.0'); + final next = UpdateInfo(version: '1.15.0'); + expect(notifier().updateShouldNotify(previous, next), isTrue); + }); + + test('updateShouldNotify returns false when versions are the same', () { + final previous = UpdateInfo(version: '1.15.0'); + final next = UpdateInfo(version: '1.15.0'); + expect(notifier().updateShouldNotify(previous, next), isFalse); + }); + }); + + group('UpdateAvailable widget', () { + Widget buildApp(UpdateInfo info) { + return withFakeSvgAssetBundle( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: UpdateAvailable(info)), + ), + ); + } + + testWidgets('displays the version in the title text', (tester) async { + await tester.pumpWidget(buildApp(UpdateInfo(version: '1.15.0'))); + await tester.pumpAndSettle(); + expect( + find.text('Multipass 1.15.0 is available'), + findsOneWidget, + ); + }); + + testWidgets('displays the upgrade button', (tester) async { + await tester.pumpWidget(buildApp(UpdateInfo(version: '1.15.0'))); + await tester.pumpAndSettle(); + expect(find.text('Upgrade now'), findsOneWidget); + }); + }); + + group('UpdateAvailableNotification widget', () { + Widget buildApp(UpdateInfo info) { + return withFakeSvgAssetBundle( + ProviderScope( + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: UpdateAvailableNotification(info)), + ), + ), + ); + } + + testWidgets('displays the version in the notification text', + (tester) async { + await tester.pumpWidget(buildApp(UpdateInfo(version: '2.0.0'))); + await tester.pumpAndSettle(); + expect( + find.text('Multipass 2.0.0 is available'), + findsOneWidget, + ); + }); + + testWidgets('displays the upgrade button', (tester) async { + await tester.pumpWidget(buildApp(UpdateInfo(version: '2.0.0'))); + await tester.pumpAndSettle(); + expect(find.text('Upgrade now'), findsOneWidget); + }); + }); +} diff --git a/src/client/gui/test/vm_details/action_tile_test.dart b/src/client/gui/test/vm_details/action_tile_test.dart new file mode 100644 index 0000000000..0345c9ebec --- /dev/null +++ b/src/client/gui/test/vm_details/action_tile_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/grpc_client.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/providers.dart'; +import 'package:multipass_gui/vm_action.dart'; +import 'package:multipass_gui/vm_details/vm_action_buttons.dart'; + +void main() { + const vmName = 'test-vm'; + + Widget buildWidget({ + required VmAction action, + required Status status, + VoidCallback? onTap, + }) { + return ProviderScope( + overrides: [ + vmInfoProvider(vmName).overrideWithBuild( + (ref, notifier) => DetailedInfoItem( + instanceStatus: InstanceStatus(status: status), + ), + ), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: ActionTile(vmName, action, onTap ?? () {}), + ), + ), + ); + } + + group('ActionTile', () { + void runEnabledTests(VmAction action, List<(Status, bool)> cases) { + for (final (status, enabled) in cases) { + testWidgets( + 'is ${enabled ? 'enabled' : 'disabled'} when status is ${status.name}', + (tester) async { + await tester.pumpWidget(buildWidget(action: action, status: status)); + await tester.pumpAndSettle(); + + final tile = tester.widget(find.byType(ListTile)); + expect(tile.enabled, enabled); + }); + } + } + + const allStatuses = [ + Status.UNKNOWN, + Status.RUNNING, + Status.STARTING, + Status.RESTARTING, + Status.STOPPED, + Status.DELETED, + Status.DELAYED_SHUTDOWN, + Status.SUSPENDING, + Status.SUSPENDED, + ]; + + group('VmAction.start', () { + runEnabledTests(VmAction.start, [ + for (final s in allStatuses) + (s, s == Status.STOPPED || s == Status.SUSPENDED), + ]); + }); + + group('VmAction.stop', () { + runEnabledTests(VmAction.stop, [ + for (final s in allStatuses) (s, s == Status.RUNNING), + ]); + }); + + group('VmAction.suspend', () { + runEnabledTests(VmAction.suspend, [ + for (final s in allStatuses) (s, s == Status.RUNNING), + ]); + }); + + group('VmAction.delete', () { + runEnabledTests(VmAction.delete, [ + for (final s in allStatuses) + ( + s, + s == Status.STOPPED || s == Status.SUSPENDED || s == Status.RUNNING + ), + ]); + }); + + group('tap behaviour', () { + testWidgets('invokes callback when tapped while enabled', + (WidgetTester tester) async { + var tapped = false; + + await tester.pumpWidget(buildWidget( + action: VmAction.stop, + status: Status.RUNNING, + onTap: () => tapped = true, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(ListTile)); + await tester.pumpAndSettle(); + + expect(tapped, isTrue); + }); + + testWidgets('displays action label as tile title', + (WidgetTester tester) async { + await tester.pumpWidget(buildWidget( + action: VmAction.start, + status: Status.STOPPED, + )); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byType(ListTile), + matching: find.byType(Text), + ), + findsOneWidget, + ); + }); + + testWidgets('title text style is not null when enabled', + (WidgetTester tester) async { + await tester.pumpWidget(buildWidget( + action: VmAction.start, + status: Status.STOPPED, + )); + await tester.pumpAndSettle(); + + final text = tester.widget( + find.descendant( + of: find.byType(ListTile), + matching: find.byType(Text), + ), + ); + expect(text.style?.color, isNotNull); + }); + + testWidgets('title text style is null when disabled', + (WidgetTester tester) async { + await tester.pumpWidget(buildWidget( + action: VmAction.start, + status: Status.RUNNING, + )); + await tester.pumpAndSettle(); + + final text = tester.widget( + find.descendant( + of: find.byType(ListTile), + matching: find.byType(Text), + ), + ); + expect(text.style, isNull); + }); + }); + }); +} diff --git a/src/client/gui/test/vm_details/cpus_slider_test.dart b/src/client/gui/test/vm_details/cpus_slider_test.dart new file mode 100644 index 0000000000..a915b3fc10 --- /dev/null +++ b/src/client/gui/test/vm_details/cpus_slider_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/providers.dart'; +import 'package:multipass_gui/vm_details/cpus_slider.dart'; + +Widget _buildSlider( + {required int cpus, int? initialValue, void Function(int?)? onSaved}) { + return ProviderScope( + overrides: [ + daemonInfoProvider.overrideWith((_) async => DaemonInfoReply(cpus: cpus)), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 600, + child: Form( + child: CpusSlider( + initialValue: initialValue, + onSaved: onSaved ?? (_) {}, + ), + ), + ), + ), + ), + ); +} + +void main() { + group('CpusSlider', () { + testWidgets('initial value shown in text field', (tester) async { + await tester.pumpWidget(_buildSlider(cpus: 8, initialValue: 3)); + await tester.pumpAndSettle(); + + final textField = tester.widget(find.byType(TextField).first); + expect(textField.controller?.text, equals('3')); + }); + + testWidgets('text field rejects non-integer input', (tester) async { + await tester.pumpWidget(_buildSlider(cpus: 4, initialValue: 2)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField).first, 'abc'); + await tester.pump(); + + final textField = tester.widget(find.byType(TextField).first); + // 'abc' should be rejected by the input formatter; the field stays at '2' + expect(textField.controller?.text, equals('2')); + }); + + testWidgets('over-provisioning warning shown when value exceeds cores', + (tester) async { + await tester.pumpWidget(_buildSlider(cpus: 2, initialValue: 4)); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.warning_rounded), findsOneWidget); + }); + + testWidgets('no over-provisioning warning when value equals cores', + (tester) async { + await tester.pumpWidget(_buildSlider(cpus: 4, initialValue: 4)); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.warning_rounded), findsNothing); + }); + + testWidgets('onSaved is called with current value when form is saved', + (tester) async { + int? savedValue; + final formKey = GlobalKey(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + daemonInfoProvider.overrideWith( + (_) async => DaemonInfoReply(cpus: 8), + ), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 600, + child: Form( + key: formKey, + child: CpusSlider( + initialValue: 3, + onSaved: (v) => savedValue = v, + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + formKey.currentState!.save(); + await tester.pump(); + + expect(savedValue, equals(3)); + }); + + testWidgets('min and max range labels are displayed', (tester) async { + await tester.pumpWidget(_buildSlider(cpus: 6, initialValue: 2)); + await tester.pumpAndSettle(); + + expect(find.text('1'), findsOneWidget); + expect(find.text('6'), findsOneWidget); + }); + }); +} diff --git a/src/client/gui/test/vm_details/disable_section_test.dart b/src/client/gui/test/vm_details/disable_section_test.dart new file mode 100644 index 0000000000..d69e89dd02 --- /dev/null +++ b/src/client/gui/test/vm_details/disable_section_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/vm_details/vm_details.dart'; + +Widget _buildApp(ActiveEditPage? active, List letEnabledFor) { + return MaterialApp( + home: Scaffold( + body: DisableSection( + active: active, + letEnabledFor: letEnabledFor, + child: const SizedBox(key: Key('child'), width: 100, height: 100), + ), + ), + ); +} + +void main() { + const fullyVisible = 1.0; + const dimmed = 0.5; + + group('DisableSection', () { + Opacity findOpacity(WidgetTester tester) { + return tester + .widgetList( + find.ancestor( + of: find.byKey(const Key('child')), + matching: find.byType(Opacity), + ), + ) + .first; + } + + IgnorePointer findIgnorePointer(WidgetTester tester) { + return tester + .widgetList( + find.ancestor( + of: find.byKey(const Key('child')), + matching: find.byType(IgnorePointer), + ), + ) + .first; + } + + testWidgets('child is fully visible when active is null', (tester) async { + await tester.pumpWidget(_buildApp(null, [])); + await tester.pump(); + + expect(findOpacity(tester).opacity, equals(fullyVisible)); + expect(findIgnorePointer(tester).ignoring, isFalse); + }); + + testWidgets('child is dimmed and non-interactive when not in letEnabledFor', + (tester) async { + await tester.pumpWidget( + _buildApp(ActiveEditPage.resources, [ActiveEditPage.bridge]), + ); + await tester.pump(); + + expect(findOpacity(tester).opacity, equals(dimmed)); + expect(findIgnorePointer(tester).ignoring, isTrue); + }); + + testWidgets('child is fully visible when page is in letEnabledFor', + (tester) async { + await tester.pumpWidget( + _buildApp(ActiveEditPage.resources, [ActiveEditPage.resources]), + ); + await tester.pump(); + + expect(findOpacity(tester).opacity, equals(fullyVisible)); + expect(findIgnorePointer(tester).ignoring, isFalse); + }); + + testWidgets( + 'child is fully visible when active is null regardless of letEnabledFor', + (tester) async { + await tester.pumpWidget(_buildApp(null, [ActiveEditPage.mounts])); + await tester.pump(); + + expect(findOpacity(tester).opacity, equals(fullyVisible)); + }); + + testWidgets( + 'child is dimmed when active page differs even if letEnabledFor has entries', + (tester) async { + await tester.pumpWidget( + _buildApp( + ActiveEditPage.bridge, + [ActiveEditPage.resources, ActiveEditPage.mounts], + ), + ); + await tester.pump(); + + expect(findOpacity(tester).opacity, equals(dimmed)); + }); + }); +} diff --git a/src/client/gui/test/vm_details/memory_slider_test.dart b/src/client/gui/test/vm_details/memory_slider_test.dart new file mode 100644 index 0000000000..56909f2cdb --- /dev/null +++ b/src/client/gui/test/vm_details/memory_slider_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/vm_details/memory_slider.dart'; + +String _fakeFormat(int bytes) => '${bytes}B'; + +Widget _buildApp({ + int min = 512, + int max = 8192, + int sysMax = 4096, + int? initialValue, + void Function(int?)? onSaved, +}) { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 600, + height: 400, + child: Form( + child: MemorySlider( + label: 'Memory', + min: min, + max: max, + sysMax: sysMax, + initialValue: initialValue, + onSaved: onSaved ?? (_) {}, + memoryFormatter: _fakeFormat, + ), + ), + ), + ), + ); +} + +void main() { + group('MemorySlider', () { + testWidgets('shows min and max labels using the injected formatter', + (tester) async { + await tester.pumpWidget(_buildApp(min: 512, max: 8192)); + await tester.pump(); + + expect(find.text('512B'), findsOneWidget); + expect(find.text('8192B'), findsOneWidget); + }); + + testWidgets('shows over-provisioning warning when initialValue > sysMax', + (tester) async { + await tester.pumpWidget( + _buildApp( + min: 512, + max: 8000, + sysMax: 4000, + initialValue: 6000, + ), + ); + await tester.pump(); + + expect(find.byIcon(Icons.warning_rounded), findsOneWidget); + }); + + testWidgets('does not show warning when initialValue <= sysMax', + (tester) async { + await tester.pumpWidget( + _buildApp( + min: 512, + max: 8000, + sysMax: 4000, + initialValue: 2000, + ), + ); + await tester.pump(); + + expect(find.byIcon(Icons.warning_rounded), findsNothing); + }); + + testWidgets('unit dropdown is rendered', (tester) async { + await tester.pumpWidget(_buildApp()); + await tester.pump(); + + expect( + find.byType(DropdownButton<(num Function(num), num Function(num))>), + findsOneWidget); + }); + + testWidgets('slider is rendered with a valid initialValue', (tester) async { + await tester.pumpWidget( + _buildApp( + min: 512, + max: 8000, + sysMax: 4000, + initialValue: 2048, + ), + ); + await tester.pump(); + + expect(find.byType(Slider), findsOneWidget); + }); + }); +} diff --git a/src/client/gui/test/vm_details/spec_input_test.dart b/src/client/gui/test/vm_details/spec_input_test.dart new file mode 100644 index 0000000000..04ad51f52a --- /dev/null +++ b/src/client/gui/test/vm_details/spec_input_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/vm_details/spec_input.dart'; + +Widget buildWidget({ + String? label, + String? hint, + String? helper, + String? initialValue, + bool enabled = true, + double width = 170, + FormFieldSetter? onSaved, + FormFieldValidator? validator, + List? inputFormatters, +}) { + return MaterialApp( + home: Scaffold( + body: Form( + child: SpecInput( + label: label, + hint: hint, + helper: helper, + initialValue: initialValue, + enabled: enabled, + width: width, + onSaved: onSaved, + validator: validator, + inputFormatters: inputFormatters, + ), + ), + ), + ); +} + +void main() { + group('SpecInput', () { + testWidgets('renders label text when label is provided', (tester) async { + await tester.pumpWidget(buildWidget(label: 'Instance Name')); + expect(find.text('Instance Name'), findsOneWidget); + }); + + testWidgets('does not render label when label is null', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.byType(Text), findsNothing); + }); + + testWidgets('shows hint text in the text field', (tester) async { + await tester.pumpWidget(buildWidget(hint: 'Enter name')); + expect(find.text('Enter name'), findsOneWidget); + }); + + testWidgets('shows helper text', (tester) async { + await tester.pumpWidget(buildWidget(helper: 'Must be unique')); + expect(find.text('Must be unique'), findsOneWidget); + }); + + testWidgets('populates initial value', (tester) async { + await tester.pumpWidget(buildWidget(initialValue: 'my-vm')); + expect(find.text('my-vm'), findsOneWidget); + }); + + testWidgets('applies the given width via SizedBox', (tester) async { + await tester.pumpWidget(buildWidget(width: 250)); + final sizedBox = tester.widget(find.byType(SizedBox).first); + expect(sizedBox.width, equals(250)); + }); + + testWidgets('text field is enabled when enabled is true', (tester) async { + await tester.pumpWidget(buildWidget(enabled: true)); + final field = tester.widget(find.byType(TextFormField)); + expect(field.enabled, isTrue); + }); + + testWidgets('text field is disabled when enabled is false', (tester) async { + await tester.pumpWidget(buildWidget(enabled: false)); + final field = tester.widget(find.byType(TextFormField)); + expect(field.enabled, isFalse); + }); + }); +} diff --git a/src/client/gui/test/vm_details/vm_action_buttons_test.dart b/src/client/gui/test/vm_details/vm_action_buttons_test.dart new file mode 100644 index 0000000000..de0d504acb --- /dev/null +++ b/src/client/gui/test/vm_details/vm_action_buttons_test.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:grpc/grpc.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/notifications/notifications_provider.dart'; +import 'package:multipass_gui/providers.dart'; +import 'package:multipass_gui/vm_details/vm_action_buttons.dart'; + +class _FakeGrpcClient extends GrpcClient { + final List<({String method, String name})> calls = []; + + _FakeGrpcClient() : super(RpcClient(ClientChannel('localhost'))); + + @override + Future start(Iterable names) async { + calls.add((method: 'start', name: names.first)); + return StartReply(); + } + + @override + Future stop(Iterable names) async { + calls.add((method: 'stop', name: names.first)); + return StopReply(); + } + + @override + Future suspend(Iterable names) async { + calls.add((method: 'suspend', name: names.first)); + return SuspendReply(); + } + + @override + Future purge(Iterable names) async { + calls.add((method: 'purge', name: names.first)); + return DeleteReply(); + } +} + +void main() { + const vmName = 'test-vm'; + late _FakeGrpcClient fakeClient; + + setUp(() => fakeClient = _FakeGrpcClient()); + + Widget buildApp({Status vmStatus = Status.RUNNING}) { + return ProviderScope( + overrides: [ + grpcClientProvider.overrideWithValue(fakeClient), + vmInfoProvider(vmName).overrideWithBuild( + (ref, notifier) => DetailedInfoItem( + instanceStatus: InstanceStatus(status: vmStatus), + ), + ), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, ref, _) { + ref.watch(notificationsProvider); + return VmActionButtons(vmName); + }, + ), + ), + ), + ); + } + + group('VmActionButtons', () { + // VmActionButtons has a fixed-width (110px) container and the testing + // environment uses a different font then production code causing a + // layout overflow. + void suppressOverflowErrors() { + final original = FlutterError.onError; + FlutterError.onError = (details) { + if (details.exceptionAsString().contains('overflowed')) return; + original?.call(details); + }; + addTearDown(() => FlutterError.onError = original); + } + + testWidgets('renders the "Actions" label', (tester) async { + suppressOverflowErrors(); + await tester.pumpWidget(buildApp()); + await tester.pump(); + expect(find.text('Actions'), findsOneWidget); + }); + + testWidgets('opening the menu shows Start, Stop, Suspend and Delete items', + (tester) async { + suppressOverflowErrors(); + await tester.pumpWidget(buildApp()); + await tester.pump(); + await tester.tap(find.byWidgetPredicate((w) => w is PopupMenuButton)); + await tester.pumpAndSettle(); + expect(find.text('Start'), findsOneWidget); + expect(find.text('Stop'), findsOneWidget); + expect(find.text('Suspend'), findsOneWidget); + expect(find.text('Delete'), findsOneWidget); + }); + + testWidgets('tapping Stop calls grpcClient.stop with the vm name', + (tester) async { + suppressOverflowErrors(); + await tester.pumpWidget(buildApp(vmStatus: Status.RUNNING)); + await tester.pump(); + await tester.tap(find.byWidgetPredicate((w) => w is PopupMenuButton)); + await tester.pumpAndSettle(); + await tester.tap(find.ancestor( + of: find.text('Stop'), matching: find.byType(ListTile))); + await tester.pumpAndSettle(); + expect(fakeClient.calls, contains((method: 'stop', name: vmName))); + }); + + testWidgets('tapping Start calls grpcClient.start with the vm name', + (tester) async { + suppressOverflowErrors(); + await tester.pumpWidget(buildApp(vmStatus: Status.STOPPED)); + await tester.pump(); + await tester.tap(find.byWidgetPredicate((w) => w is PopupMenuButton)); + await tester.pumpAndSettle(); + await tester.tap(find.ancestor( + of: find.text('Start'), matching: find.byType(ListTile))); + await tester.pumpAndSettle(); + expect(fakeClient.calls, contains((method: 'start', name: vmName))); + }); + + testWidgets('tapping Suspend calls grpcClient.suspend with the vm name', + (tester) async { + suppressOverflowErrors(); + await tester.pumpWidget(buildApp(vmStatus: Status.RUNNING)); + await tester.pump(); + await tester.tap(find.byWidgetPredicate((w) => w is PopupMenuButton)); + await tester.pumpAndSettle(); + await tester.tap(find.ancestor( + of: find.text('Suspend'), matching: find.byType(ListTile))); + await tester.pumpAndSettle(); + expect(fakeClient.calls, contains((method: 'suspend', name: vmName))); + }); + + testWidgets('Stop is enabled for a RUNNING vm', (tester) async { + suppressOverflowErrors(); + await tester.pumpWidget(buildApp(vmStatus: Status.RUNNING)); + await tester.pump(); + await tester.tap(find.byWidgetPredicate((w) => w is PopupMenuButton)); + await tester.pumpAndSettle(); + final tile = tester.widget(find.ancestor( + of: find.text('Stop'), matching: find.byType(ListTile))); + expect(tile.enabled, isTrue); + }); + + testWidgets('Stop is disabled for a STOPPED vm', (tester) async { + suppressOverflowErrors(); + await tester.pumpWidget(buildApp(vmStatus: Status.STOPPED)); + await tester.pump(); + await tester.tap(find.byWidgetPredicate((w) => w is PopupMenuButton)); + await tester.pumpAndSettle(); + final tile = tester.widget(find.ancestor( + of: find.text('Stop'), matching: find.byType(ListTile))); + expect(tile.enabled, isFalse); + }); + }); +} diff --git a/src/client/gui/test/vm_details/vm_action_test.dart b/src/client/gui/test/vm_details/vm_action_test.dart new file mode 100644 index 0000000000..16940f0a8a --- /dev/null +++ b/src/client/gui/test/vm_details/vm_action_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/grpc_client.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/vm_action.dart'; + +void main() { + group('VmAction.allowedStatuses', () { + const cases = [ + (VmAction.start, {Status.STOPPED, Status.SUSPENDED}), + (VmAction.stop, {Status.RUNNING}), + (VmAction.suspend, {Status.RUNNING}), + (VmAction.restart, {Status.RUNNING}), + (VmAction.delete, {Status.STOPPED, Status.SUSPENDED, Status.RUNNING}), + (VmAction.recover, {Status.DELETED}), + (VmAction.purge, {Status.DELETED}), + (VmAction.edit, {Status.STOPPED}), + ]; + + for (final (action, expectedStatuses) in cases) { + test('${action.name} allows the expected statuses', () { + expect(action.allowedStatuses, equals(expectedStatuses)); + }); + } + }); + + group('VmActionButton', () { + Widget buildButton({ + required VmAction action, + required List statuses, + VoidCallback? onPressed, + }) { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: VmActionButton( + action: action, + currentStatuses: statuses, + function: onPressed ?? () {}, + ), + ), + ); + } + + testWidgets('is enabled when current status is in allowedStatuses', + (WidgetTester tester) async { + await tester.pumpWidget(buildButton( + action: VmAction.start, + statuses: [Status.STOPPED], + )); + + final button = tester.widget(find.byType(OutlinedButton)); + expect(button.onPressed, isNotNull); + }); + + testWidgets('is disabled when no current status is in allowedStatuses', + (WidgetTester tester) async { + await tester.pumpWidget(buildButton( + action: VmAction.start, + statuses: [Status.RUNNING], + )); + + final button = tester.widget(find.byType(OutlinedButton)); + expect(button.onPressed, isNull); + }); + + testWidgets('is enabled when at least one status matches allowedStatuses', + (WidgetTester tester) async { + // delete allows STOPPED, SUSPENDED, or RUNNING + await tester.pumpWidget(buildButton( + action: VmAction.delete, + statuses: [Status.RUNNING], + )); + + final button = tester.widget(find.byType(OutlinedButton)); + expect(button.onPressed, isNotNull); + }); + + testWidgets('invokes callback when tapped while enabled', + (WidgetTester tester) async { + var tapped = false; + + await tester.pumpWidget(buildButton( + action: VmAction.stop, + statuses: [Status.RUNNING], + onPressed: () => tapped = true, + )); + + await tester.tap(find.byType(OutlinedButton)); + expect(tapped, isTrue); + }); + + testWidgets('does not invoke callback when tapped while disabled', + (WidgetTester tester) async { + var tapped = false; + + await tester.pumpWidget(buildButton( + action: VmAction.stop, + statuses: [Status.STOPPED], + onPressed: () => tapped = true, + )); + + await tester.tap(find.byType(OutlinedButton)); + expect(tapped, isFalse); + }); + + testWidgets('is disabled when statuses list is empty', + (WidgetTester tester) async { + await tester.pumpWidget(buildButton( + action: VmAction.start, + statuses: [], + )); + + final button = tester.widget(find.byType(OutlinedButton)); + expect(button.onPressed, isNull); + }); + }); +} diff --git a/src/client/gui/test/vm_details/vm_details_bridge_test.dart b/src/client/gui/test/vm_details/vm_details_bridge_test.dart new file mode 100644 index 0000000000..f937d99a4e --- /dev/null +++ b/src/client/gui/test/vm_details/vm_details_bridge_test.dart @@ -0,0 +1,135 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:flutter/material.dart' hide Tooltip; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/providers.dart'; +import 'package:multipass_gui/vm_details/vm_details_bridge.dart'; + +const _vmName = 'test-vm'; + +Widget _buildApp({ + Status status = Status.STOPPED, + bool bridged = false, + Set networks = const {}, + String? bridgedNetworkSetting, +}) { + final vmInfo = DetailedInfoItem( + name: _vmName, + instanceStatus: InstanceStatus(status: status), + ); + final bridgeKey = (name: _vmName, resource: VmResource.bridged); + + return ProviderScope( + overrides: [ + vmInfoProvider(_vmName).overrideWithBuild((ref, _) => vmInfo), + vmResourceProvider(bridgeKey).overrideWithBuild( + (ref, _) => bridged.toString(), + ), + networksProvider.overrideWith( + (_) async => BuiltSet(networks), + ), + daemonSettingProvider('local.bridged-network').overrideWithBuild( + (ref, _) => bridgedNetworkSetting ?? '', + ), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 600, + child: BridgedDetails(_vmName), + ), + ), + ), + ); +} + +void main() { + group('BridgedDetails', () { + testWidgets('shows "not connected" status text when not bridged', + (tester) async { + await tester.pumpWidget( + _buildApp(status: Status.STOPPED, bridged: false), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('not connected', findRichText: true), + findsOneWidget); + }); + + testWidgets('shows "connected" status text when bridged', (tester) async { + await tester.pumpWidget( + _buildApp(status: Status.STOPPED, bridged: true), + ); + await tester.pumpAndSettle(); + + expect( + find.text('Status: connected'), + findsOneWidget, + ); + }); + + testWidgets('Configure button is disabled when VM is running', + (tester) async { + await tester.pumpWidget( + _buildApp(status: Status.RUNNING, bridged: false), + ); + await tester.pumpAndSettle(); + + final button = tester.widget( + find.widgetWithText(OutlinedButton, 'Configure'), + ); + expect(button.onPressed, isNull); + }); + + testWidgets('shows Bridge title', (tester) async { + await tester.pumpWidget(_buildApp()); + await tester.pumpAndSettle(); + + expect(find.textContaining('Bridge', findRichText: true), findsOneWidget); + }); + + testWidgets('tapping Configure enters editing mode', (tester) async { + await tester.pumpWidget( + _buildApp( + status: Status.STOPPED, + bridged: false, + networks: {'eth0'}, + bridgedNetworkSetting: 'eth0', + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Configure')); + await tester.pumpAndSettle(); + + // In editing mode, Cancel button is shown instead of Configure + expect(find.widgetWithText(OutlinedButton, 'Cancel'), findsOneWidget); + expect(find.widgetWithText(OutlinedButton, 'Configure'), findsNothing); + }); + + testWidgets('Cancel button exits editing mode', (tester) async { + await tester.pumpWidget( + _buildApp( + status: Status.STOPPED, + bridged: false, + networks: {'eth0'}, + bridgedNetworkSetting: 'eth0', + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Configure')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Cancel')); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(OutlinedButton, 'Configure'), findsOneWidget); + expect(find.widgetWithText(OutlinedButton, 'Cancel'), findsNothing); + }); + }); +} diff --git a/src/client/gui/test/vm_details/vm_details_general_test.dart b/src/client/gui/test/vm_details/vm_details_general_test.dart new file mode 100644 index 0000000000..302a01b284 --- /dev/null +++ b/src/client/gui/test/vm_details/vm_details_general_test.dart @@ -0,0 +1,158 @@ +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart'; +import 'package:multipass_gui/grpc_client.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/providers.dart'; +import 'package:multipass_gui/vm_details/vm_details_general.dart'; +import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart'; + +void main() { + group('InstanceDetailsExtensions.formattedCreationTime', () { + test('returns "-" when isLaunching is true and timestamp is zero', () { + final details = InstanceDetails( + creationTimestamp: Timestamp(seconds: Int64(0), nanos: 0), + ); + + expect(details.formattedCreationTime(isLaunching: true), equals('-')); + }); + + test( + 'returns formatted datetime when isLaunching is true and timestamp is non-zero', + () { + final dt = DateTime.utc(2024, 1, 15, 10, 30, 0); + final seconds = dt.millisecondsSinceEpoch ~/ 1000; + final ts = Timestamp(seconds: Int64(seconds), nanos: 0); + final details = InstanceDetails(creationTimestamp: ts); + + final result = details.formattedCreationTime(isLaunching: true); + + final expected = + DateFormat('yyyy-MM-dd HH:mm:ss').format(ts.toDateTime()); + expect(result, equals(expected)); + expect(result, isNot(equals('-'))); + }); + + test( + 'returns formatted datetime when isLaunching is false with a real timestamp', + () { + final dt = DateTime.utc(2024, 1, 15, 10, 30, 0); + final seconds = dt.millisecondsSinceEpoch ~/ 1000; + final ts = Timestamp(seconds: Int64(seconds), nanos: 0); + final details = InstanceDetails(creationTimestamp: ts); + + final result = details.formattedCreationTime(isLaunching: false); + + final expected = + DateFormat('yyyy-MM-dd HH:mm:ss').format(ts.toDateTime()); + expect(result, equals(expected)); + }); + + test('uses default zero timestamp when creationTimestamp is not set', () { + final details = InstanceDetails(); + + expect(details.formattedCreationTime(isLaunching: true), equals('-')); + }); + }); + + group('VmStat', () { + testWidgets('renders the label text', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: const VmStat( + label: 'CPU Usage', + width: 100, + height: 35, + child: SizedBox.shrink(), + ), + ), + ); + + expect(find.text('CPU Usage'), findsOneWidget); + }); + + testWidgets('renders the child widget', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: const VmStat( + label: '', + width: 100, + height: 35, + child: Text('child content'), + ), + ), + ); + + expect(find.text('child content'), findsOneWidget); + }); + }); + + group('GeneralDetails', () { + Widget buildWidget({ + List ipv4 = const [], + String uptime = '', + bool isLaunching = false, + }) { + return ProviderScope( + overrides: [ + vmInfoProvider('test-vm').overrideWithBuild( + (ref, notifier) => DetailedInfoItem( + instanceInfo: InstanceDetails( + ipv4: ipv4, + uptime: uptime, + ), + ), + ), + isLaunchingProvider.overrideWith((ref, _) => isLaunching), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: GeneralDetails('test-vm')), + ), + ); + } + + testWidgets('shows first IPv4 address as private IP', (tester) async { + await tester.pumpWidget(buildWidget(ipv4: ['192.168.64.1', '10.0.0.1'])); + await tester.pumpAndSettle(); + + expect(find.text('192.168.64.1'), findsOneWidget); + }); + + testWidgets('shows second IPv4 address as public IP', (tester) async { + await tester.pumpWidget(buildWidget(ipv4: ['192.168.64.1', '10.0.0.1'])); + await tester.pumpAndSettle(); + + expect(find.text('10.0.0.1'), findsOneWidget); + }); + + testWidgets('shows "-" for public IP when only one IPv4 address', + (tester) async { + await tester.pumpWidget(buildWidget(ipv4: ['192.168.64.1'])); + await tester.pumpAndSettle(); + + expect(find.text('192.168.64.1'), findsOneWidget); + // The public IP falls back to '-' since there is no second address. + expect(find.text('-'), findsWidgets); + }); + + testWidgets('shows "-" for both IPs when ipv4 list is empty', + (tester) async { + await tester.pumpWidget(buildWidget()); + await tester.pumpAndSettle(); + + expect(find.text('-'), findsWidgets); + }); + + testWidgets('renders the uptime text', (tester) async { + await tester.pumpWidget(buildWidget(uptime: '1 hour, 23 minutes')); + await tester.pumpAndSettle(); + + expect(find.text('1 hour, 23 minutes'), findsOneWidget); + }); + }); +} diff --git a/src/client/gui/test/vm_details/vm_details_mounts_test.dart b/src/client/gui/test/vm_details/vm_details_mounts_test.dart new file mode 100644 index 0000000000..80fc854856 --- /dev/null +++ b/src/client/gui/test/vm_details/vm_details_mounts_test.dart @@ -0,0 +1,209 @@ +import 'package:extended_text/extended_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/grpc_client.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/providers.dart' show vmInfoProvider; +import 'package:multipass_gui/vm_details/vm_details_mounts.dart'; + +Widget _buildApp({ + required Widget child, + required DetailedInfoItem vmInfo, + String vmName = 'test-vm', +}) { + return ProviderScope( + overrides: [ + vmInfoProvider(vmName).overrideWithBuild((ref, _) => vmInfo), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 600, + child: child, + ), + ), + ), + ); +} + +DetailedInfoItem _itemWithMounts(List mounts) { + return DetailedInfoItem( + instanceStatus: InstanceStatus(status: Status.STOPPED), + mountInfo: MountInfo(mountPaths: mounts), + ); +} + +void main() { + const vmName = 'test-vm'; + + group('MountDetails — idle phase with no mounts', () { + testWidgets('shows Add Mount button', (tester) async { + await tester.pumpWidget( + _buildApp( + vmInfo: _itemWithMounts([]), + child: const MountDetails(vmName), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Add mount'), findsOneWidget); + }); + + testWidgets('does not show Configure button', (tester) async { + await tester.pumpWidget( + _buildApp( + vmInfo: _itemWithMounts([]), + child: const MountDetails(vmName), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Configure'), findsNothing); + }); + + testWidgets('shows Mounts title', (tester) async { + await tester.pumpWidget( + _buildApp( + vmInfo: _itemWithMounts([]), + child: const MountDetails(vmName), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Mounts'), findsOneWidget); + }); + }); + + group('MountDetails — idle phase with existing mounts', () { + final existingMount = MountPaths( + sourcePath: '/home/user/projects', + targetPath: '/home/ubuntu/projects', + ); + + testWidgets('shows Configure button', (tester) async { + await tester.pumpWidget( + _buildApp( + vmInfo: _itemWithMounts([existingMount]), + child: const MountDetails(vmName), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Configure'), findsOneWidget); + }); + + testWidgets('does not show Add Mount button initially', (tester) async { + await tester.pumpWidget( + _buildApp( + vmInfo: _itemWithMounts([existingMount]), + child: const MountDetails(vmName), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Add mount'), findsNothing); + }); + + testWidgets('source path is displayed', (tester) async { + await tester.pumpWidget( + _buildApp( + vmInfo: _itemWithMounts([existingMount]), + child: const MountDetails(vmName), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate( + (w) => w is ExtendedText && w.data == '/home/user/projects', + ), + findsOneWidget, + ); + }); + + testWidgets('target path is displayed', (tester) async { + await tester.pumpWidget( + _buildApp( + vmInfo: _itemWithMounts([existingMount]), + child: const MountDetails(vmName), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate( + (w) => w is ExtendedText && w.data == '/home/ubuntu/projects', + ), + findsOneWidget, + ); + }); + + testWidgets('column headers HOST DIRECTORY and GUEST DIRECTORY shown', + (tester) async { + await tester.pumpWidget( + _buildApp( + vmInfo: _itemWithMounts([existingMount]), + child: const MountDetails(vmName), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('HOST DIRECTORY'), findsOneWidget); + expect(find.text('GUEST DIRECTORY'), findsOneWidget); + }); + }); + + group('MountDetails — phase transitions', () { + testWidgets('tapping Configure then Cancel returns to Configure', + (tester) async { + final existingMount = MountPaths( + sourcePath: '/home/user/projects', + targetPath: '/home/ubuntu/projects', + ); + + await tester.pumpWidget( + _buildApp( + vmInfo: _itemWithMounts([existingMount]), + child: const MountDetails(vmName), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Configure')); + await tester.pumpAndSettle(); + + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Configure'), findsNothing); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(find.text('Configure'), findsOneWidget); + expect(find.text('Cancel'), findsNothing); + }); + + testWidgets('tapping Configure shows Add Mount button', (tester) async { + final existingMount = MountPaths( + sourcePath: '/home/user/projects', + targetPath: '/home/ubuntu/projects', + ); + + await tester.pumpWidget( + _buildApp( + vmInfo: _itemWithMounts([existingMount]), + child: const MountDetails(vmName), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Configure')); + await tester.pumpAndSettle(); + + expect(find.text('Add mount'), findsOneWidget); + }); + }); +} diff --git a/src/client/gui/test/vm_details/vm_details_resources_test.dart b/src/client/gui/test/vm_details/vm_details_resources_test.dart new file mode 100644 index 0000000000..c57635f628 --- /dev/null +++ b/src/client/gui/test/vm_details/vm_details_resources_test.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/providers.dart'; +import 'package:multipass_gui/vm_details/vm_details_resources.dart'; + +const _vmName = 'test-vm'; + +Widget _buildApp({Status status = Status.STOPPED}) { + final vmInfo = DetailedInfoItem( + name: _vmName, + instanceStatus: InstanceStatus(status: status), + ); + final cpusKey = (name: _vmName, resource: VmResource.cpus); + final ramKey = (name: _vmName, resource: VmResource.memory); + final diskKey = (name: _vmName, resource: VmResource.disk); + + return ProviderScope( + overrides: [ + vmInfoProvider(_vmName).overrideWithBuild((ref, _) => vmInfo), + vmResourceProvider(cpusKey).overrideWithBuild((ref, _) => '4'), + // Keep memory/disk in perpetual loading state so humanReadableMemory + // is never called. + vmResourceProvider(ramKey).overrideWithBuild( + (ref, _) => Completer().future, + ), + vmResourceProvider(diskKey).overrideWithBuild( + (ref, _) => Completer().future, + ), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 800, + child: ResourcesDetails(_vmName), + ), + ), + ), + ); +} + +void main() { + group('ResourcesDetails', () { + testWidgets('shows the Resources title', (tester) async { + await tester.pumpWidget(_buildApp()); + await tester.pump(); + + expect(find.text('Resources'), findsOneWidget); + }); + + testWidgets('shows CPUs value from provider data', (tester) async { + await tester.pumpWidget(_buildApp()); + await tester.pump(); + + expect(find.text('CPUs 4'), findsOneWidget); + }); + + testWidgets('shows "…" placeholders while memory and disk are loading', + (tester) async { + await tester.pumpWidget(_buildApp()); + await tester.pump(); + + expect(find.textContaining('…'), findsWidgets); + }); + + testWidgets('Configure button is enabled when VM is stopped', + (tester) async { + await tester.pumpWidget(_buildApp(status: Status.STOPPED)); + await tester.pump(); + + final button = tester.widget( + find.widgetWithText(OutlinedButton, 'Configure'), + ); + expect(button.onPressed, isNotNull); + }); + + testWidgets('Configure button is disabled when VM is running', + (tester) async { + await tester.pumpWidget(_buildApp(status: Status.RUNNING)); + await tester.pump(); + + final button = tester.widget( + find.widgetWithText(OutlinedButton, 'Configure'), + ); + expect(button.onPressed, isNull); + }); + }); +} diff --git a/src/client/gui/test/vm_details/vm_details_state_test.dart b/src/client/gui/test/vm_details/vm_details_state_test.dart new file mode 100644 index 0000000000..923560a463 --- /dev/null +++ b/src/client/gui/test/vm_details/vm_details_state_test.dart @@ -0,0 +1,237 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/grpc_client.dart'; +import 'package:multipass_gui/providers.dart'; +import 'package:multipass_gui/vm_details/vm_details.dart'; + +class _StatusNotifier extends Notifier { + @override + Status build() => Status.STOPPED; + + void set(Status value) => state = value; +} + +final _vmStatusProvider = + NotifierProvider<_StatusNotifier, Status>(_StatusNotifier.new); + +void main() { + group('VmScreenLocationNotifier', () { + test('initial state is shells', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + container.listen(vmScreenLocationProvider('vm1'), (_, __) {}); + + expect( + container.read(vmScreenLocationProvider('vm1')), + equals(VmDetailsLocation.shells), + ); + }); + + test('set(details) updates state to details', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + container.listen(vmScreenLocationProvider('vm1'), (_, __) {}); + + container + .read(vmScreenLocationProvider('vm1').notifier) + .set(VmDetailsLocation.details); + + expect( + container.read(vmScreenLocationProvider('vm1')), + equals(VmDetailsLocation.details), + ); + }); + + test('set(shells) updates state to shells', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + container.listen(vmScreenLocationProvider('vm1'), (_, __) {}); + + container + .read(vmScreenLocationProvider('vm1').notifier) + .set(VmDetailsLocation.details); + container + .read(vmScreenLocationProvider('vm1').notifier) + .set(VmDetailsLocation.shells); + + expect( + container.read(vmScreenLocationProvider('vm1')), + equals(VmDetailsLocation.shells), + ); + }); + + test('different vm names have independent state', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + container.listen(vmScreenLocationProvider('vm1'), (_, __) {}); + container.listen(vmScreenLocationProvider('vm2'), (_, __) {}); + + container + .read(vmScreenLocationProvider('vm1').notifier) + .set(VmDetailsLocation.details); + + expect( + container.read(vmScreenLocationProvider('vm1')), + equals(VmDetailsLocation.details), + ); + expect( + container.read(vmScreenLocationProvider('vm2')), + equals(VmDetailsLocation.shells), + ); + }); + }); + + group('ActiveEditPageNotifier', () { + ProviderContainer buildContainer() { + final container = ProviderContainer( + overrides: [ + vmInfoProvider('test-vm').overrideWithBuild( + (ref, notifier) => DetailedInfoItem( + instanceStatus: + InstanceStatus(status: ref.watch(_vmStatusProvider)), + ), + ), + ], + ); + addTearDown(container.dispose); + container.listen(activeEditPageProvider('test-vm'), (_, __) {}); + return container; + } + + test('initial state is null', () { + final container = buildContainer(); + + expect(container.read(activeEditPageProvider('test-vm')), isNull); + }); + + test('set(resources) updates state to resources', () { + final container = buildContainer(); + + container + .read(activeEditPageProvider('test-vm').notifier) + .set(ActiveEditPage.resources); + + expect( + container.read(activeEditPageProvider('test-vm')), + equals(ActiveEditPage.resources), + ); + }); + + test('set(bridge) updates state to bridge', () { + final container = buildContainer(); + + container + .read(activeEditPageProvider('test-vm').notifier) + .set(ActiveEditPage.bridge); + + expect( + container.read(activeEditPageProvider('test-vm')), + equals(ActiveEditPage.bridge), + ); + }); + + test('set(mounts) updates state to mounts', () { + final container = buildContainer(); + + container + .read(activeEditPageProvider('test-vm').notifier) + .set(ActiveEditPage.mounts); + + expect( + container.read(activeEditPageProvider('test-vm')), + equals(ActiveEditPage.mounts), + ); + }); + + test('set(null) updates state to null', () { + final container = buildContainer(); + + container + .read(activeEditPageProvider('test-vm').notifier) + .set(ActiveEditPage.resources); + container.read(activeEditPageProvider('test-vm').notifier).set(null); + + expect(container.read(activeEditPageProvider('test-vm')), isNull); + }); + + test('resets to null when in bridge state and vm status becomes RUNNING', + () { + final container = buildContainer(); + + container + .read(activeEditPageProvider('test-vm').notifier) + .set(ActiveEditPage.bridge); + expect( + container.read(activeEditPageProvider('test-vm')), + equals(ActiveEditPage.bridge), + ); + + container.read(_vmStatusProvider.notifier).set(Status.RUNNING); + + expect(container.read(activeEditPageProvider('test-vm')), isNull); + }); + + test('resets to null when in resources state and vm status becomes RUNNING', + () { + final container = buildContainer(); + + container + .read(activeEditPageProvider('test-vm').notifier) + .set(ActiveEditPage.resources); + expect( + container.read(activeEditPageProvider('test-vm')), + equals(ActiveEditPage.resources), + ); + + container.read(_vmStatusProvider.notifier).set(Status.RUNNING); + + expect(container.read(activeEditPageProvider('test-vm')), isNull); + }); + + test('does not reset when in mounts state and vm status becomes RUNNING', + () { + final container = buildContainer(); + + container + .read(activeEditPageProvider('test-vm').notifier) + .set(ActiveEditPage.mounts); + container.read(_vmStatusProvider.notifier).set(Status.RUNNING); + + expect( + container.read(activeEditPageProvider('test-vm')), + equals(ActiveEditPage.mounts), + ); + }); + + test('does not reset when in bridge state and vm status changes to STOPPED', + () { + final container = buildContainer(); + + container + .read(activeEditPageProvider('test-vm').notifier) + .set(ActiveEditPage.bridge); + container.read(_vmStatusProvider.notifier).set(Status.STOPPED); + + expect( + container.read(activeEditPageProvider('test-vm')), + equals(ActiveEditPage.bridge), + ); + }); + + test( + 'does not reset when in resources state and vm status changes to STOPPED', + () { + final container = buildContainer(); + + container + .read(activeEditPageProvider('test-vm').notifier) + .set(ActiveEditPage.resources); + container.read(_vmStatusProvider.notifier).set(Status.STOPPED); + + expect( + container.read(activeEditPageProvider('test-vm')), + equals(ActiveEditPage.resources), + ); + }); + }); +} diff --git a/src/client/gui/test/vm_status_icon_test.dart b/src/client/gui/test/vm_status_icon_test.dart new file mode 100644 index 0000000000..bbd4d6eaac --- /dev/null +++ b/src/client/gui/test/vm_status_icon_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart' hide Tooltip; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/grpc_client.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/vm_details/vm_status_icon.dart'; + +Widget buildWidget(Status status, {required bool isLaunching}) { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Row( + children: [ + Expanded(child: VmStatusIcon(status, isLaunching: isLaunching)), + ], + ), + ), + ); +} + +void main() { + group('VmStatusIcon', () { + testWidgets('shows CircularProgressIndicator when isLaunching is true', + (tester) async { + await tester.pumpWidget( + buildWidget(Status.RUNNING, isLaunching: true), + ); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('does not show CircularProgressIndicator when not launching', + (tester) async { + await tester.pumpWidget( + buildWidget(Status.RUNNING, isLaunching: false), + ); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + }); + + group('icons map', () { + const cases = [ + (Status.RUNNING, Icons.circle), + (Status.STOPPED, Icons.circle), + (Status.SUSPENDED, Icons.circle), + (Status.RESTARTING, Icons.more_horiz), + (Status.STARTING, Icons.more_horiz), + (Status.SUSPENDING, Icons.more_horiz), + (Status.DELETED, Icons.close), + (Status.DELAYED_SHUTDOWN, Icons.circle), + ]; + + for (final (status, expectedIcon) in cases) { + test('has correct icon for ${status.name}', () { + expect(icons.containsKey(status), isTrue); + expect(icons[status]!.icon, equals(expectedIcon)); + }); + } + }); +} diff --git a/src/client/gui/test/vm_table/bulk_actions_test.dart b/src/client/gui/test/vm_table/bulk_actions_test.dart new file mode 100644 index 0000000000..2b739c989e --- /dev/null +++ b/src/client/gui/test/vm_table/bulk_actions_test.dart @@ -0,0 +1,189 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:grpc/grpc.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/notifications/notifications_provider.dart'; +import 'package:multipass_gui/providers.dart'; +import 'package:multipass_gui/vm_table/bulk_actions.dart'; +import 'package:multipass_gui/vm_table/vms.dart'; + +class _FakeGrpcClient extends GrpcClient { + final List<({String method, Iterable names})> calls = []; + + _FakeGrpcClient() : super(RpcClient(ClientChannel('localhost'))); + + @override + Future start(Iterable names) async { + calls.add((method: 'start', names: names)); + return StartReply(); + } + + @override + Future stop(Iterable names) async { + calls.add((method: 'stop', names: names)); + return StopReply(); + } + + @override + Future suspend(Iterable names) async { + calls.add((method: 'suspend', names: names)); + return SuspendReply(); + } + + @override + Future purge(Iterable names) async { + calls.add((method: 'purge', names: names)); + return DeleteReply(); + } +} + +Widget _buildApp({ + required _FakeGrpcClient client, + BuiltSet? selected, + Map statuses = const {}, +}) { + return ProviderScope( + overrides: [ + grpcClientProvider.overrideWithValue(client), + selectedVmsProvider.overrideWithBuild( + (ref, _) => selected ?? BuiltSet(), + ), + vmStatusesProvider.overrideWith( + (ref) => BuiltMap(statuses), + ), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, ref, _) { + ref.watch(notificationsProvider); + return const BulkActionsBar(); + }, + ), + ), + ), + ); +} + +void main() { + late _FakeGrpcClient fakeClient; + setUp(() => fakeClient = _FakeGrpcClient()); + + group('BulkActionsBar', () { + testWidgets('renders four action buttons', (tester) async { + await tester.pumpWidget(_buildApp(client: fakeClient)); + await tester.pump(); + + expect(find.byType(OutlinedButton), findsNWidgets(4)); + }); + + testWidgets('all buttons disabled when no VMs selected', (tester) async { + await tester.pumpWidget( + _buildApp(client: fakeClient, selected: BuiltSet()), + ); + await tester.pump(); + + final buttons = tester + .widgetList(find.byType(OutlinedButton)) + .toList(); + for (final button in buttons) { + expect(button.onPressed, isNull); + } + }); + + testWidgets('Start enabled when selected VM is STOPPED', (tester) async { + await tester.pumpWidget(_buildApp( + client: fakeClient, + selected: BuiltSet({'vm1'}), + statuses: {'vm1': Status.STOPPED}, + )); + await tester.pump(); + + final start = tester.widget( + find.widgetWithText(OutlinedButton, 'Start'), + ); + expect(start.onPressed, isNotNull); + }); + + testWidgets('Start disabled when selected VM is RUNNING', (tester) async { + await tester.pumpWidget(_buildApp( + client: fakeClient, + selected: BuiltSet({'vm1'}), + statuses: {'vm1': Status.RUNNING}, + )); + await tester.pump(); + + final start = tester.widget( + find.widgetWithText(OutlinedButton, 'Start'), + ); + expect(start.onPressed, isNull); + }); + + testWidgets('Stop enabled when selected VM is RUNNING', (tester) async { + await tester.pumpWidget(_buildApp( + client: fakeClient, + selected: BuiltSet({'vm1'}), + statuses: {'vm1': Status.RUNNING}, + )); + await tester.pump(); + + final stop = tester.widget( + find.widgetWithText(OutlinedButton, 'Stop'), + ); + expect(stop.onPressed, isNotNull); + }); + + testWidgets('Delete enabled when selected VM is STOPPED', (tester) async { + await tester.pumpWidget(_buildApp( + client: fakeClient, + selected: BuiltSet({'vm1'}), + statuses: {'vm1': Status.STOPPED}, + )); + await tester.pump(); + + final delete = tester.widget( + find.widgetWithText(OutlinedButton, 'Delete'), + ); + expect(delete.onPressed, isNotNull); + }); + + testWidgets('tapping Start calls client.start() with selected VM names', + (tester) async { + await tester.pumpWidget(_buildApp( + client: fakeClient, + selected: BuiltSet({'vm1'}), + statuses: {'vm1': Status.STOPPED}, + )); + await tester.pump(); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Start')); + await tester.pump(); + + expect(fakeClient.calls, hasLength(1)); + expect(fakeClient.calls.first.method, equals('start')); + expect(fakeClient.calls.first.names, containsAll(['vm1'])); + }); + + testWidgets('tapping Stop calls client.stop() with selected VM names', + (tester) async { + await tester.pumpWidget(_buildApp( + client: fakeClient, + selected: BuiltSet({'vm1'}), + statuses: {'vm1': Status.RUNNING}, + )); + await tester.pump(); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Stop')); + await tester.pump(); + + expect(fakeClient.calls, hasLength(1)); + expect(fakeClient.calls.first.method, equals('stop')); + expect(fakeClient.calls.first.names, containsAll(['vm1'])); + }); + }); +} diff --git a/src/client/gui/test/vm_table/header_selection_test.dart b/src/client/gui/test/vm_table/header_selection_test.dart new file mode 100644 index 0000000000..34fd8c75c8 --- /dev/null +++ b/src/client/gui/test/vm_table/header_selection_test.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/vm_table/header_selection.dart'; +import 'package:multipass_gui/vm_table/vm_table_headers.dart'; + +import '../helpers.dart'; + +void main() { + group('HeaderSelectionTile', () { + Widget buildApp(String name) { + return withFakeSvgAssetBundle( + ProviderScope( + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: HeaderSelectionTile(name, name)), + ), + ), + ); + } + + testWidgets('shows checkbox checked when header is enabled', + (tester) async { + await tester.pumpWidget(buildApp('STATE')); + await tester.pump(); + + final checkbox = tester.widget( + find.byType(CheckboxListTile), + ); + expect(checkbox.value, isTrue); + }); + + testWidgets('tapping checkbox disables the header', (tester) async { + late WidgetRef capturedRef; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (_, ref, __) { + capturedRef = ref; + return HeaderSelectionTile('STATE', 'State'); + }, + ), + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.byType(CheckboxListTile)); + await tester.pump(); + + expect(capturedRef.read(enabledHeadersProvider)['STATE'], isFalse); + }); + + testWidgets('tapping again re-enables the header', (tester) async { + late WidgetRef capturedRef; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (_, ref, __) { + capturedRef = ref; + return HeaderSelectionTile('STATE', 'State'); + }, + ), + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.byType(CheckboxListTile)); + await tester.pump(); + await tester.tap(find.byType(CheckboxListTile)); + await tester.pump(); + + expect(capturedRef.read(enabledHeadersProvider)['STATE'], isTrue); + }); + + testWidgets('shows the provided label text', (tester) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Scaffold( + body: HeaderSelectionTile('CPU USAGE', 'CPU Usage'), + ), + ), + ), + ); + + expect(find.text('CPU Usage'), findsOneWidget); + }); + }); + + group('HeaderSelection', () { + Widget buildApp() { + return withFakeSvgAssetBundle( + ProviderScope( + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: Center(child: HeaderSelection())), + ), + ), + ); + } + + testWidgets('renders the Columns button text', (tester) async { + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + expect(find.text('Columns'), findsOneWidget); + }); + + testWidgets('renders a PopupMenuButton', (tester) async { + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate((w) => w is PopupMenuButton), + findsOneWidget, + ); + }); + + testWidgets('opening the popup shows a checkbox for each column header', + (tester) async { + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + await tester.tap(find.byWidgetPredicate((w) => w is PopupMenuButton)); + await tester.pumpAndSettle(); + + expect( + find.byType(CheckboxListTile), + findsNWidgets(headers.skip(2).length), + ); + }); + }); +} diff --git a/src/client/gui/test/vm_table/table_test.dart b/src/client/gui/test/vm_table/table_test.dart new file mode 100644 index 0000000000..7178c30a79 --- /dev/null +++ b/src/client/gui/test/vm_table/table_test.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart' hide Table; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/vm_table/table.dart'; + +/// Builds a [Table] with a single sortable "Name" column and an +/// optional non-sortable "Static" column. +Widget buildTable({ + List data = const ['banana', 'apple', 'cherry'], + bool includeNonSortable = false, +}) { + final headers = >[ + TableHeader( + name: 'Name', + sortKey: (s) => s, + width: 120, + minWidth: 60, + cellBuilder: (s) => Text(s), + ), + if (includeNonSortable) + TableHeader( + name: 'Static', + width: 100, + minWidth: 60, + cellBuilder: (s) => Text(s.toUpperCase()), + ), + ]; + + return MaterialApp( + home: Scaffold( + body: SizedBox( + width: 500, + height: 300, + child: Table( + headers: headers, + data: data, + finalRow: const [], + ), + ), + ), + ); +} + +void main() { + group('Table sort cycling', () { + testWidgets('initially no sort arrow is shown', (tester) async { + await tester.pumpWidget(buildTable()); + await tester.pump(); + + expect(find.byIcon(Icons.arrow_drop_up_rounded), findsNothing); + expect(find.byIcon(Icons.arrow_drop_down_rounded), findsNothing); + }); + + testWidgets('first tap on sortable header shows ascending arrow', + (tester) async { + await tester.pumpWidget(buildTable()); + await tester.pump(); + + await tester.tap(find.text('Name')); + await tester.pump(); + + expect(find.byIcon(Icons.arrow_drop_up_rounded), findsOneWidget); + expect(find.byIcon(Icons.arrow_drop_down_rounded), findsNothing); + }); + + testWidgets('second tap shows descending arrow', (tester) async { + await tester.pumpWidget(buildTable()); + await tester.pump(); + + await tester.tap(find.text('Name')); + await tester.pump(); + await tester.tap(find.text('Name')); + await tester.pump(); + + expect(find.byIcon(Icons.arrow_drop_down_rounded), findsOneWidget); + expect(find.byIcon(Icons.arrow_drop_up_rounded), findsNothing); + }); + + testWidgets('third tap removes the sort arrow', (tester) async { + await tester.pumpWidget(buildTable()); + await tester.pump(); + + await tester.tap(find.text('Name')); + await tester.pump(); + await tester.tap(find.text('Name')); + await tester.pump(); + await tester.tap(find.text('Name')); + await tester.pump(); + + expect(find.byIcon(Icons.arrow_drop_up_rounded), findsNothing); + expect(find.byIcon(Icons.arrow_drop_down_rounded), findsNothing); + }); + + testWidgets('tapping non-sortable header does not show a sort arrow', + (tester) async { + await tester.pumpWidget(buildTable(includeNonSortable: true)); + await tester.pump(); + + await tester.tap(find.text('Static')); + await tester.pump(); + + expect(find.byIcon(Icons.arrow_drop_up_rounded), findsNothing); + expect(find.byIcon(Icons.arrow_drop_down_rounded), findsNothing); + }); + + testWidgets('ascending sort renders data in alphabetical order', + (tester) async { + await tester.pumpWidget(buildTable()); + await tester.pump(); + + await tester.tap(find.text('Name')); + await tester.pump(); + + final appleTop = tester.getRect(find.text('apple')).top; + final bananaTop = tester.getRect(find.text('banana')).top; + final cherryTop = tester.getRect(find.text('cherry')).top; + expect(appleTop < bananaTop, isTrue); + expect(bananaTop < cherryTop, isTrue); + }); + + testWidgets('descending sort renders data in reverse alphabetical order', + (tester) async { + await tester.pumpWidget(buildTable()); + await tester.pump(); + + await tester.tap(find.text('Name')); + await tester.pump(); + await tester.tap(find.text('Name')); + await tester.pump(); + + final appleTop = tester.getRect(find.text('apple')).top; + final bananaTop = tester.getRect(find.text('banana')).top; + final cherryTop = tester.getRect(find.text('cherry')).top; + expect(cherryTop < bananaTop, isTrue); + expect(bananaTop < appleTop, isTrue); + }); + }); +} diff --git a/src/client/gui/test/vm_table/vm_action_button_test.dart b/src/client/gui/test/vm_table/vm_action_button_test.dart new file mode 100644 index 0000000000..6f0528ccd5 --- /dev/null +++ b/src/client/gui/test/vm_table/vm_action_button_test.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/grpc_client.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/vm_action.dart'; + +Widget _buildButton({ + required VmAction action, + required Iterable statuses, + VoidCallback? onPressed, +}) { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: VmActionButton( + action: action, + currentStatuses: statuses, + function: onPressed ?? () {}, + ), + ), + ); +} + +OutlinedButton _outlinedButton(WidgetTester tester) => + tester.widget(find.byType(OutlinedButton)); + +void main() { + group('VmActionButton — start', () { + testWidgets('enabled when status is STOPPED', (tester) async { + await tester.pumpWidget( + _buildButton(action: VmAction.start, statuses: [Status.STOPPED]), + ); + await tester.pumpAndSettle(); + + expect(_outlinedButton(tester).onPressed, isNotNull); + }); + + testWidgets('enabled when status is SUSPENDED', (tester) async { + await tester.pumpWidget( + _buildButton(action: VmAction.start, statuses: [Status.SUSPENDED]), + ); + await tester.pumpAndSettle(); + + expect(_outlinedButton(tester).onPressed, isNotNull); + }); + + testWidgets('disabled when status is RUNNING', (tester) async { + await tester.pumpWidget( + _buildButton(action: VmAction.start, statuses: [Status.RUNNING]), + ); + await tester.pumpAndSettle(); + + expect(_outlinedButton(tester).onPressed, isNull); + }); + }); + + group('VmActionButton — stop', () { + testWidgets('enabled when status is RUNNING', (tester) async { + await tester.pumpWidget( + _buildButton(action: VmAction.stop, statuses: [Status.RUNNING]), + ); + await tester.pumpAndSettle(); + + expect(_outlinedButton(tester).onPressed, isNotNull); + }); + + testWidgets('disabled when status is STOPPED', (tester) async { + await tester.pumpWidget( + _buildButton(action: VmAction.stop, statuses: [Status.STOPPED]), + ); + await tester.pumpAndSettle(); + + expect(_outlinedButton(tester).onPressed, isNull); + }); + + testWidgets('disabled when status is SUSPENDED', (tester) async { + await tester.pumpWidget( + _buildButton(action: VmAction.stop, statuses: [Status.SUSPENDED]), + ); + await tester.pumpAndSettle(); + + expect(_outlinedButton(tester).onPressed, isNull); + }); + }); + + group('VmActionButton — suspend', () { + testWidgets('enabled when status is RUNNING', (tester) async { + await tester.pumpWidget( + _buildButton(action: VmAction.suspend, statuses: [Status.RUNNING]), + ); + await tester.pumpAndSettle(); + + expect(_outlinedButton(tester).onPressed, isNotNull); + }); + + testWidgets('disabled when status is STOPPED', (tester) async { + await tester.pumpWidget( + _buildButton(action: VmAction.suspend, statuses: [Status.STOPPED]), + ); + await tester.pumpAndSettle(); + + expect(_outlinedButton(tester).onPressed, isNull); + }); + }); + + group('VmActionButton — delete', () { + testWidgets('enabled when status is STOPPED', (tester) async { + await tester.pumpWidget( + _buildButton(action: VmAction.delete, statuses: [Status.STOPPED]), + ); + await tester.pumpAndSettle(); + + expect(_outlinedButton(tester).onPressed, isNotNull); + }); + + testWidgets('enabled when status is RUNNING', (tester) async { + await tester.pumpWidget( + _buildButton(action: VmAction.delete, statuses: [Status.RUNNING]), + ); + await tester.pumpAndSettle(); + + expect(_outlinedButton(tester).onPressed, isNotNull); + }); + + testWidgets('enabled when status is SUSPENDED', (tester) async { + await tester.pumpWidget( + _buildButton(action: VmAction.delete, statuses: [Status.SUSPENDED]), + ); + await tester.pumpAndSettle(); + + expect(_outlinedButton(tester).onPressed, isNotNull); + }); + + testWidgets('disabled when no matching statuses', (tester) async { + await tester.pumpWidget( + _buildButton(action: VmAction.delete, statuses: [Status.DELETED]), + ); + await tester.pumpAndSettle(); + + expect(_outlinedButton(tester).onPressed, isNull); + }); + }); + + group('VmActionButton — function callback', () { + testWidgets('invokes function when enabled and tapped', (tester) async { + var tapped = false; + await tester.pumpWidget( + _buildButton( + action: VmAction.start, + statuses: [Status.STOPPED], + onPressed: () => tapped = true, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(OutlinedButton)); + await tester.pump(); + + expect(tapped, isTrue); + }); + + testWidgets('does not invoke function when disabled', (tester) async { + var tapped = false; + await tester.pumpWidget( + _buildButton( + action: VmAction.start, + statuses: [Status.RUNNING], + onPressed: () => tapped = true, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(OutlinedButton)); + await tester.pump(); + + expect(tapped, isFalse); + }); + }); + + group('VmActionButton — mixed statuses', () { + testWidgets('start enabled when at least one status is STOPPED', + (tester) async { + await tester.pumpWidget( + _buildButton( + action: VmAction.start, + statuses: [Status.RUNNING, Status.STOPPED], + ), + ); + await tester.pumpAndSettle(); + + expect(_outlinedButton(tester).onPressed, isNotNull); + }); + + testWidgets('stop disabled when no statuses are RUNNING', (tester) async { + await tester.pumpWidget( + _buildButton( + action: VmAction.stop, + statuses: [Status.STOPPED, Status.SUSPENDED], + ), + ); + await tester.pumpAndSettle(); + + expect(_outlinedButton(tester).onPressed, isNull); + }); + }); +} diff --git a/src/client/gui/test/vm_table/vm_table_headers_test.dart b/src/client/gui/test/vm_table/vm_table_headers_test.dart new file mode 100644 index 0000000000..1e137d8f85 --- /dev/null +++ b/src/client/gui/test/vm_table/vm_table_headers_test.dart @@ -0,0 +1,164 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/providers.dart'; +import 'package:multipass_gui/sidebar.dart'; +import 'package:multipass_gui/vm_table/vm_table_headers.dart'; +import 'package:multipass_gui/vm_table/vms.dart'; + +class _FixedSelectedVmsNotifier extends SelectedVmsNotifier { + final BuiltSet _initial; + + _FixedSelectedVmsNotifier(this._initial); + + @override + BuiltSet build() => _initial; +} + +ProviderScope _wrap( + Widget child, { + BuiltSet? selectedVms, + List? vmInfos, + List? vmNames, +}) { + return ProviderScope( + overrides: [ + selectedVmsProvider.overrideWith( + () => _FixedSelectedVmsNotifier(selectedVms ?? BuiltSet()), + ), + if (vmInfos != null) vmInfosProvider.overrideWith((ref) => vmInfos), + if (vmNames != null) + vmNamesProvider.overrideWith((ref) => vmNames.toBuiltSet()), + ], + child: MaterialApp(home: Scaffold(body: child)), + ); +} + +void main() { + group('SelectVmCheckbox', () { + testWidgets('is unchecked when vm is not in selectedVmsProvider', + (tester) async { + await tester.pumpWidget(_wrap(const SelectVmCheckbox('vm1'))); + + final checkbox = tester.widget(find.byType(Checkbox)); + expect(checkbox.value, isFalse); + }); + + testWidgets('is checked when vm is in selectedVmsProvider', (tester) async { + await tester.pumpWidget( + _wrap( + const SelectVmCheckbox('vm1'), + selectedVms: BuiltSet(['vm1']), + ), + ); + + final checkbox = tester.widget(find.byType(Checkbox)); + expect(checkbox.value, isTrue); + }); + + testWidgets('tapping adds the vm to selectedVmsProvider', (tester) async { + late WidgetRef capturedRef; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + selectedVmsProvider.overrideWith( + () => _FixedSelectedVmsNotifier(BuiltSet()), + ), + ], + child: MaterialApp( + home: Scaffold( + body: Consumer( + builder: (_, ref, __) { + capturedRef = ref; + return const SelectVmCheckbox('vm1'); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.byType(Checkbox)); + await tester.pump(); + + expect(capturedRef.read(selectedVmsProvider), contains('vm1')); + }); + }); + + group('SelectAllCheckbox', () { + final twoVms = [ + DetailedInfoItem(name: 'vm1'), + DetailedInfoItem(name: 'vm2'), + ]; + + testWidgets('shows false when no vms are selected', (tester) async { + await tester.pumpWidget( + _wrap(const SelectAllCheckbox(), vmInfos: twoVms), + ); + + final checkbox = tester.widget(find.byType(Checkbox)); + expect(checkbox.value, isFalse); + }); + + testWidgets('shows true when all vms are selected', (tester) async { + await tester.pumpWidget( + _wrap( + const SelectAllCheckbox(), + vmInfos: twoVms, + selectedVms: BuiltSet(['vm1', 'vm2']), + ), + ); + + final checkbox = tester.widget(find.byType(Checkbox)); + expect(checkbox.value, isTrue); + }); + + testWidgets('shows null (tristate) when only some vms are selected', + (tester) async { + await tester.pumpWidget( + _wrap( + const SelectAllCheckbox(), + vmInfos: twoVms, + selectedVms: BuiltSet(['vm1']), + ), + ); + + final checkbox = tester.widget(find.byType(Checkbox)); + expect(checkbox.value, isNull); + }); + }); + + group('VmNameLink', () { + testWidgets('tapping navigates to the vm details page', (tester) async { + late WidgetRef capturedRef; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + vmNamesProvider.overrideWith((ref) => BuiltSet(['test-vm'])), + ], + child: MaterialApp( + home: Scaffold( + body: Consumer( + builder: (_, ref, __) { + capturedRef = ref; + return const VmNameLink('test-vm'); + }, + ), + ), + ), + ), + ); + + // Ensure the autoDispose sidebarKeyProvider is alive before tapping. + capturedRef.read(sidebarKeyProvider); + + await tester.tapAt(tester.getCenter(find.byType(VmNameLink))); + await tester.pump(); + + expect(capturedRef.read(sidebarKeyProvider), equals('vm-test-vm')); + }); + }); +} diff --git a/src/client/gui/test/vm_table/vm_table_screen_test.dart b/src/client/gui/test/vm_table/vm_table_screen_test.dart new file mode 100644 index 0000000000..bdce1e9c64 --- /dev/null +++ b/src/client/gui/test/vm_table/vm_table_screen_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:grpc/grpc.dart'; +import 'package:multipass_gui/l10n/app_localizations.dart'; +import 'package:multipass_gui/providers.dart'; +import 'package:multipass_gui/vm_table/no_vms.dart'; +import 'package:multipass_gui/vm_table/vm_table_screen.dart'; +import 'package:multipass_gui/vm_table/vms.dart'; + +import '../helpers.dart'; + +class _FakeGrpcClient extends GrpcClient { + _FakeGrpcClient() : super(RpcClient(ClientChannel('localhost'))); +} + +class _StaticVmInfosNotifier extends AllVmInfosNotifier { + _StaticVmInfosNotifier(this._vms); + final List _vms; + + @override + List build() => _vms; +} + +Widget _buildScreen({List vms = const []}) { + return withFakeSvgAssetBundle( + ProviderScope( + overrides: [ + grpcClientProvider.overrideWithValue(_FakeGrpcClient()), + allVmInfosProvider.overrideWith(() => _StaticVmInfosNotifier(vms)), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const VmTableScreen(), + ), + ), + ); +} + +void main() { + group('VmTableScreen', () { + testWidgets('shows NoVms widget when there are no VMs', (tester) async { + await tester.pumpWidget(_buildScreen()); + await tester.pump(); + + expect(find.byType(NoVms), findsOneWidget); + expect(find.byType(Vms), findsNothing); + }); + + testWidgets('shows Vms widget when at least one VM exists', (tester) async { + // Suppress layout overflow from the HeaderSelection widget's fixed-width Row + final originalOnError = FlutterError.onError; + FlutterError.onError = (details) { + if (details.exception.toString().contains('A RenderFlex overflowed')) { + return; + } + originalOnError?.call(details); + }; + addTearDown(() => FlutterError.onError = originalOnError); + + final vm = DetailedInfoItem( + name: 'test-vm', + instanceStatus: InstanceStatus(status: Status.RUNNING), + ); + await tester.pumpWidget(_buildScreen(vms: [vm])); + await tester.pump(); + + expect(find.byType(Vms), findsOneWidget); + expect(find.byType(NoVms), findsNothing); + }); + + testWidgets('shows NoVms widget when only DELETED VMs exist', + (tester) async { + final vm = DetailedInfoItem( + name: 'deleted-vm', + instanceStatus: InstanceStatus(status: Status.DELETED), + ); + await tester.pumpWidget(_buildScreen(vms: [vm])); + await tester.pump(); + + expect(find.byType(NoVms), findsOneWidget); + expect(find.byType(Vms), findsNothing); + }); + }); +} diff --git a/src/client/gui/test/vm_table/vm_table_state_test.dart b/src/client/gui/test/vm_table/vm_table_state_test.dart new file mode 100644 index 0000000000..3b2bc7d589 --- /dev/null +++ b/src/client/gui/test/vm_table/vm_table_state_test.dart @@ -0,0 +1,234 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multipass_gui/providers.dart'; +import 'package:multipass_gui/vm_table/header_selection.dart'; +import 'package:multipass_gui/vm_table/search_box.dart'; +import 'package:multipass_gui/vm_table/vms.dart'; + +class _MutableVmNamesNotifier extends Notifier> { + @override + BuiltSet build() => BuiltSet(); + + void setNames(BuiltSet names) { + state = names; + } +} + +final _mutableVmNamesProvider = + NotifierProvider<_MutableVmNamesNotifier, BuiltSet>( + _MutableVmNamesNotifier.new, +); + +void main() { + group('SearchNameNotifier', () { + late ProviderContainer container; + + setUp(() { + container = ProviderContainer(); + }); + + tearDown(() { + container.dispose(); + }); + + test('initial state is empty string', () { + expect(container.read(searchNameProvider), equals('')); + }); + + test('set updates state to given value', () { + container.read(searchNameProvider.notifier).set('hello'); + expect(container.read(searchNameProvider), equals('hello')); + }); + + test('set with empty string resets to empty', () { + container.read(searchNameProvider.notifier).set('hello'); + container.read(searchNameProvider.notifier).set(''); + expect(container.read(searchNameProvider), equals('')); + }); + }); + + group('RunningOnlyNotifier', () { + late ProviderContainer container; + + setUp(() { + container = ProviderContainer(); + }); + + tearDown(() { + container.dispose(); + }); + + test('initial state is false', () { + expect(container.read(runningOnlyProvider), isFalse); + }); + + test('set true updates state to true', () { + container.read(runningOnlyProvider.notifier).set(true); + expect(container.read(runningOnlyProvider), isTrue); + }); + + test('set false after true resets to false', () { + container.read(runningOnlyProvider.notifier).set(true); + container.read(runningOnlyProvider.notifier).set(false); + expect(container.read(runningOnlyProvider), isFalse); + }); + }); + + group('SelectedVmsNotifier', () { + ProviderContainer makeContainer({ + List vmNames = const [], + }) { + return ProviderContainer( + overrides: [ + vmNamesProvider.overrideWith( + (ref) => BuiltSet(vmNames), + ), + ], + ); + } + + test('initial state is empty BuiltSet', () { + final container = makeContainer(); + addTearDown(container.dispose); + expect(container.read(selectedVmsProvider), equals(BuiltSet())); + }); + + test('toggle true adds vm name to the set', () { + final container = makeContainer(vmNames: ['vm1', 'vm2']); + addTearDown(container.dispose); + + container.read(selectedVmsProvider.notifier).toggle('vm1', true); + expect(container.read(selectedVmsProvider), equals(BuiltSet(['vm1']))); + }); + + test('toggle false removes vm name from the set', () { + final container = makeContainer(vmNames: ['vm1', 'vm2']); + addTearDown(container.dispose); + + container.read(selectedVmsProvider.notifier).toggle('vm1', true); + container.read(selectedVmsProvider.notifier).toggle('vm2', true); + container.read(selectedVmsProvider.notifier).toggle('vm1', false); + expect(container.read(selectedVmsProvider), equals(BuiltSet(['vm2']))); + }); + + test('set replaces entire state', () { + final container = makeContainer(vmNames: ['vm1', 'vm2']); + addTearDown(container.dispose); + + container + .read(selectedVmsProvider.notifier) + .set(BuiltSet(['vm1', 'vm2'])); + expect( + container.read(selectedVmsProvider), + equals(BuiltSet(['vm1', 'vm2'])), + ); + }); + + test('vm removed from vmNamesProvider is auto-removed from selection', () { + final container = ProviderContainer( + overrides: [ + vmNamesProvider.overrideWith( + (ref) => ref.watch(_mutableVmNamesProvider), + ), + ], + ); + addTearDown(container.dispose); + + container.read(_mutableVmNamesProvider.notifier).setNames( + BuiltSet(['vm1', 'vm2']), + ); + container.listen(selectedVmsProvider, (_, __) {}); + + container.read(selectedVmsProvider.notifier).toggle('vm1', true); + container.read(selectedVmsProvider.notifier).toggle('vm2', true); + expect( + container.read(selectedVmsProvider), + equals(BuiltSet(['vm1', 'vm2'])), + ); + + container.read(_mutableVmNamesProvider.notifier).setNames( + BuiltSet(['vm2']), + ); + + expect(container.read(selectedVmsProvider), equals(BuiltSet(['vm2']))); + }); + + test('changing runningOnlyProvider resets selected vms to empty', () { + final container = makeContainer(vmNames: ['vm1', 'vm2']); + addTearDown(container.dispose); + + container + .read(selectedVmsProvider.notifier) + .set(BuiltSet(['vm1', 'vm2'])); + expect(container.read(selectedVmsProvider).isNotEmpty, isTrue); + + container.read(runningOnlyProvider.notifier).set(true); + expect(container.read(selectedVmsProvider), equals(BuiltSet())); + }); + + test('changing searchNameProvider resets selected vms to empty', () { + final container = makeContainer(vmNames: ['vm1', 'vm2']); + addTearDown(container.dispose); + + container + .read(selectedVmsProvider.notifier) + .set(BuiltSet(['vm1', 'vm2'])); + expect(container.read(selectedVmsProvider).isNotEmpty, isTrue); + + container.read(searchNameProvider.notifier).set('filter'); + expect(container.read(selectedVmsProvider), equals(BuiltSet())); + }); + }); + + group('EnabledHeadersNotifier', () { + late ProviderContainer container; + + setUp(() { + container = ProviderContainer(); + }); + + tearDown(() { + container.dispose(); + }); + + test('initial state has all headers enabled', () { + final state = container.read(enabledHeadersProvider); + expect(state.values, everyElement(isTrue)); + }); + + test('toggleHeader false disables the named header', () { + container.read(enabledHeadersProvider.notifier).toggleHeader( + 'STATE', + false, + ); + expect(container.read(enabledHeadersProvider)['STATE'], isFalse); + }); + + test('toggleHeader true re-enables a disabled header', () { + container.read(enabledHeadersProvider.notifier).toggleHeader( + 'STATE', + false, + ); + container.read(enabledHeadersProvider.notifier).toggleHeader( + 'STATE', + true, + ); + expect(container.read(enabledHeadersProvider)['STATE'], isTrue); + }); + + test('toggling one header does not affect others', () { + container.read(enabledHeadersProvider.notifier).toggleHeader( + 'STATE', + false, + ); + final state = container.read(enabledHeadersProvider); + for (final entry in state.entries) { + if (entry.key != 'STATE') { + expect(entry.value, isTrue, + reason: '${entry.key} should remain enabled'); + } + } + }); + }); +}