import 'dart:async'; import 'dart:convert'; import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:plezy/database/app_database.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/widgets/server_activities_button.dart'; import 'package:provider/provider.dart'; import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/multi_server_fixtures.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); late AppDatabase database; setUp(() { LocaleSettings.setLocaleSync(AppLocale.en); database = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(database); }); tearDown(() => database.close()); testWidgets('togglePanel opens and closes the server activities overlay', (tester) async { final multiServerProvider = testMultiServer().provider; final buttonKey = GlobalKey(); await tester.pumpWidget( TranslationProvider( child: ChangeNotifierProvider.value( value: multiServerProvider, child: MaterialApp( home: Scaffold(body: ServerActivitiesButton(key: buttonKey)), ), ), ), ); buttonKey.currentState!.togglePanel(); await tester.pump(); await tester.pump(); expect(find.text(t.serverTasks.title), findsOneWidget); expect(find.text(t.serverTasks.noTasks), findsOneWidget); buttonKey.currentState!.togglePanel(); await tester.pump(); expect(find.text(t.serverTasks.title), findsNothing); }); testWidgets('polls three seconds after completion without overlapping activity aggregates', (tester) async { final transport = _ControlledActivitiesClient(); final harness = await _pumpActivitiesHarness(tester, transport); expect(transport.activityRequests, hasLength(1)); transport.completeActivities(0, const ['Initial activity']); await tester.pump(); await tester.pump(); expect(find.text('Initial activity'), findsOneWidget); await tester.pump(const Duration(seconds: 3)); expect(transport.activityRequests, hasLength(2)); expect(transport.activeActivityGets, 1); await tester.pump(const Duration(seconds: 4)); expect(transport.activityRequests, hasLength(2)); expect(transport.maximumActiveActivityGets, 1); transport.completeActivities(1, const ['Completed poll']); await tester.pump(); await tester.pump(); expect(find.text('Completed poll'), findsOneWidget); await tester.pump(const Duration(milliseconds: 2999)); expect(transport.activityRequests, hasLength(2)); await tester.pump(const Duration(milliseconds: 1)); expect(transport.activityRequests, hasLength(3)); expect(transport.maximumActiveActivityGets, 1); harness.buttonKey.currentState!.togglePanel(); await tester.pump(); }); testWidgets('close and reopen rejects a stale completion and preserves the new poll cadence', (tester) async { final transport = _ControlledActivitiesClient(honorAbort: false); final harness = await _pumpActivitiesHarness(tester, transport); final oldRequest = transport.activityRequests.single; harness.buttonKey.currentState!.togglePanel(); await tester.pump(); await expectLater(oldRequest.abortObserved.future, completes); harness.buttonKey.currentState!.togglePanel(); await tester.pump(); expect(transport.activityRequests, hasLength(2)); transport.completeActivities(1, const ['Fresh activity']); await tester.pump(); await tester.pump(); expect(find.text('Fresh activity'), findsOneWidget); transport.completeActivities(0, const ['Old activity']); await tester.pump(); await tester.pump(); expect(find.text('Fresh activity'), findsOneWidget); expect(find.text('Old activity'), findsNothing); await tester.pump(const Duration(milliseconds: 2999)); expect(transport.activityRequests, hasLength(2)); await tester.pump(const Duration(milliseconds: 1)); expect(transport.activityRequests, hasLength(3)); transport.completeActivities(2, const ['Next activity']); await tester.pump(); await tester.pump(); harness.buttonKey.currentState!.togglePanel(); await tester.pump(); }); testWidgets('post-cancel refresh cannot be undone by a pre-cancel poll', (tester) async { final transport = _ControlledActivitiesClient(honorAbort: false); final harness = await _pumpActivitiesHarness(tester, transport); transport.completeActivities(0, const ['Cancelable activity'], cancellable: true); await tester.pump(); await tester.pump(); expect(find.text('Cancelable activity'), findsOneWidget); await tester.pump(const Duration(seconds: 3)); expect(transport.activityRequests, hasLength(2)); await tester.tap(find.byTooltip(t.common.cancel)); await tester.pump(); await tester.pump(); expect(transport.deleteCount, 1); expect(transport.activityRequests, hasLength(3)); await expectLater(transport.activityRequests[1].abortObserved.future, completes); transport.completeActivities(2, const []); await tester.pump(); await tester.pump(); expect(find.text(t.serverTasks.noTasks), findsOneWidget); transport.completeActivities(1, const ['Cancelable activity'], cancellable: true); await tester.pump(); await tester.pump(); expect(find.text(t.serverTasks.noTasks), findsOneWidget); expect(find.text('Cancelable activity'), findsNothing); expect(transport.activityRequests, hasLength(3)); harness.buttonKey.currentState!.togglePanel(); await tester.pump(); }); } class _ActivitiesHarness { const _ActivitiesHarness(this.buttonKey); final GlobalKey buttonKey; } Future<_ActivitiesHarness> _pumpActivitiesHarness(WidgetTester tester, _ControlledActivitiesClient transport) async { final serverId = ServerId('plex-server'); final client = testPlexClient(serverId: serverId, serverName: 'Test server', httpClient: transport); final multiServerProvider = testMultiServer(clients: [client]).provider; final buttonKey = GlobalKey(); await tester.pumpWidget( TranslationProvider( child: ChangeNotifierProvider.value( value: multiServerProvider, child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold(body: ServerActivitiesButton(key: buttonKey)), ), ), ), ); buttonKey.currentState!.togglePanel(); await tester.pump(); return _ActivitiesHarness(buttonKey); } class _ControlledActivityRequest { _ControlledActivityRequest(this.request); final http.BaseRequest request; final response = Completer(); final abortObserved = Completer(); } class _ControlledActivitiesClient extends http.BaseClient { _ControlledActivitiesClient({this.honorAbort = true}); final bool honorAbort; final activityRequests = <_ControlledActivityRequest>[]; var activeActivityGets = 0; var maximumActiveActivityGets = 0; var deleteCount = 0; @override Future send(http.BaseRequest request) { if (request.method == 'DELETE' && request.url.path.startsWith('/activities/')) { deleteCount++; return Future.value(_response(request, const {})); } if (request.method != 'GET' || request.url.path != '/activities') { return Future.value(_response(request, const {})); } final pending = _ControlledActivityRequest(request); activityRequests.add(pending); activeActivityGets++; if (activeActivityGets > maximumActiveActivityGets) { maximumActiveActivityGets = activeActivityGets; } final abortTrigger = (request as http.Abortable).abortTrigger!; unawaited( abortTrigger.then((_) { if (!pending.abortObserved.isCompleted) { pending.abortObserved.complete(); } if (honorAbort && !pending.response.isCompleted) { pending.response.completeError(http.RequestAbortedException(request.url)); } }), ); return pending.response.future.whenComplete(() { activeActivityGets--; }); } void completeActivities(int requestIndex, List titles, {bool cancellable = false}) { final pending = activityRequests[requestIndex]; if (pending.response.isCompleted) return; pending.response.complete( _response(pending.request, { 'MediaContainer': { 'Activity': [ for (var index = 0; index < titles.length; index++) { 'uuid': 'activity-$requestIndex-$index', 'type': 'library.update', 'title': titles[index], 'progress': 50, 'cancellable': cancellable, }, ], }, }), ); } http.StreamedResponse _response(http.BaseRequest request, Map body) { return http.StreamedResponse( Stream.value(utf8.encode(jsonEncode(body))), 200, headers: const {'content-type': 'application/json'}, request: request, ); } }