Collapse duplicated setup across the suite into six shared helpers under test/test_helpers/ and rewrite the 28 suites that were open-coding it: http_fixtures.dart jsonResponse() for http.Response JSON stubs library_tab_scaffold.dart pumps library tabs under their required ancestors multi_server_fixtures.dart MultiServerProvider wiring for widget tests playback_report_fakes.dart PlaybackReportCall + fake report sinks profile_stack.dart production-shaped profile dependency graph theme.dart testMonoTokens for fast-settling widget tests Net -1245 lines with no change in coverage or assertions.
274 lines
9.3 KiB
Dart
274 lines
9.3 KiB
Dart
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<ServerActivitiesButtonState>();
|
|
|
|
await tester.pumpWidget(
|
|
TranslationProvider(
|
|
child: ChangeNotifierProvider<MultiServerProvider>.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<ServerActivitiesButtonState> 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<ServerActivitiesButtonState>();
|
|
|
|
await tester.pumpWidget(
|
|
TranslationProvider(
|
|
child: ChangeNotifierProvider<MultiServerProvider>.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<http.StreamedResponse>();
|
|
final abortObserved = Completer<void>();
|
|
}
|
|
|
|
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<http.StreamedResponse> 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<String> 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<String, dynamic> body) {
|
|
return http.StreamedResponse(
|
|
Stream.value(utf8.encode(jsonEncode(body))),
|
|
200,
|
|
headers: const {'content-type': 'application/json'},
|
|
request: request,
|
|
);
|
|
}
|
|
}
|