Several pairs of near-identical code paths differ in one load-bearing line. Each site now carries a comment naming the invariant that forces it apart, backed by a characterization test so a future deduplication fails loudly instead of silently changing behaviour. Pinned: focusable wrapper vs. chip D-pad activation policy, profile connection cleanup's raw-id vs. ServerId-typed server projections, live TV tab loaders, video player display matching and playback service wiring, track selection container ordering, tracker HTTP client status ladder, and the MediaServerHttpClient shutdown/cancellation contract versus ManagedHttpClient's closing guard. New tests: test/focus/dpad_activation_policy_test.dart test/services/track_selection_container_ordinal_test.dart test/services/trackers/tracker_status_ladder_test.dart test/utils/media_server_http_client_shutdown_test.dart
84 lines
2.8 KiB
Dart
84 lines
2.8 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:plezy/exceptions/media_server_exceptions.dart';
|
|
import 'package:plezy/utils/managed_http_client.dart';
|
|
import 'package:plezy/utils/media_server_http_client.dart';
|
|
|
|
void main() {
|
|
group('MediaServerHttpClient shutdown', () {
|
|
test('rejects new requests as a cancellation, not a transient failure', () async {
|
|
final client = MediaServerHttpClient(
|
|
client: ManagedHttpClient(_AbortAwareClient(), debugLabel: 'test'),
|
|
baseUrl: 'https://example.test/',
|
|
);
|
|
|
|
client.close();
|
|
|
|
await expectLater(
|
|
client.get('library/sections'),
|
|
throwsA(
|
|
isA<MediaServerHttpException>()
|
|
.having((e) => e.type, 'type', MediaServerHttpErrorType.cancelled)
|
|
.having((e) => e.isTransient, 'isTransient', isFalse),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('the layer beneath reports the same shutdown as a transient connection error', () async {
|
|
final managed = ManagedHttpClient(_AbortAwareClient(), debugLabel: 'test');
|
|
await managed.closeGracefully(drainTimeout: Duration.zero);
|
|
|
|
await expectLater(
|
|
managed.send(http.Request('GET', Uri.parse('https://example.test/library/sections'))),
|
|
throwsA(
|
|
isA<http.ClientException>()
|
|
.having(
|
|
(e) => MediaServerHttpException.from(e).type,
|
|
'mapped type',
|
|
MediaServerHttpErrorType.connectionError,
|
|
)
|
|
.having((e) => MediaServerHttpException.from(e).isTransient, 'mapped isTransient', isTrue),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('aborts requests already in flight at the transport', () async {
|
|
final transport = _AbortAwareClient();
|
|
final client = MediaServerHttpClient(client: transport, baseUrl: 'https://example.test/');
|
|
|
|
final pending = client.get('library/sections');
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
client.close();
|
|
|
|
await expectLater(transport.abortTrigger, completes);
|
|
await expectLater(
|
|
pending,
|
|
throwsA(isA<MediaServerHttpException>().having((e) => e.type, 'type', MediaServerHttpErrorType.cancelled)),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
class _AbortAwareClient extends http.BaseClient {
|
|
final _response = Completer<http.StreamedResponse>();
|
|
late final Future<void> abortTrigger;
|
|
|
|
@override
|
|
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
|
final trigger = (request as http.Abortable).abortTrigger!;
|
|
abortTrigger = trigger;
|
|
unawaited(
|
|
trigger.whenComplete(() {
|
|
if (!_response.isCompleted) _response.completeError(http.RequestAbortedException(request.url));
|
|
}),
|
|
);
|
|
return _response.future;
|
|
}
|
|
|
|
@override
|
|
void close() {}
|
|
}
|