mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-14 12:23:43 +00:00
refactor: remove proxy API (#1596)
This commit is contained in:
98 files changed
+5958
-1225
No files matched your search
@@ -8,71 +8,136 @@ import 'package:gopeed/api/gopeed_site_api.dart';
|
||||
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
|
||||
|
||||
void main() {
|
||||
test('summary requests are shared and cached; explicit refresh requests fresh data', () async {
|
||||
final pending = Completer<Response<String>>();
|
||||
final transport = _Transport()..onProxy = (_) => pending.future;
|
||||
late Dio client;
|
||||
late _SiteAdapter adapter;
|
||||
late GopeedSiteApi site;
|
||||
late _Transport transport;
|
||||
|
||||
setUp(() {
|
||||
client = Dio();
|
||||
adapter = _SiteAdapter();
|
||||
client.httpClientAdapter = adapter;
|
||||
site = GopeedSiteApi(client: client);
|
||||
transport = _Transport();
|
||||
api.setTransportForTesting(transport);
|
||||
final site = GopeedSiteApi.instance;
|
||||
});
|
||||
tearDown(() {
|
||||
client.close(force: true);
|
||||
expect(transport.calls, 0);
|
||||
for (final request in adapter.requests) {
|
||||
expect(request.uri.origin, 'https://gopeed.com');
|
||||
expect(request.headers.keys.map((key) => key.toLowerCase()), isNot(contains('x-api-token')));
|
||||
}
|
||||
});
|
||||
|
||||
test('install counts are reported directly with JSON and failures remain observable', () async {
|
||||
await site.reportExtensionInstall('开发者@name');
|
||||
final request = adapter.requests.single;
|
||||
expect(request.uri.toString(), 'https://gopeed.com/api/extensions/install');
|
||||
expect(request.method, 'POST');
|
||||
expect(request.contentType, Headers.jsonContentType);
|
||||
expect(request.data, {'id': '开发者@name'});
|
||||
adapter.statusCode = 503;
|
||||
await expectLater(site.reportExtensionInstall('test'), throwsA(isA<DioException>()));
|
||||
});
|
||||
|
||||
test('summary requests go directly to the website and remain shared and cached', () async {
|
||||
final pending = Completer<Map<String, dynamic>>();
|
||||
final started = Completer<void>();
|
||||
adapter.onRequest = (_) {
|
||||
if (!started.isCompleted) started.complete();
|
||||
return pending.future;
|
||||
};
|
||||
final first = site.getExtensions(query: 'cache-test', forceRefresh: true);
|
||||
final second = site.getExtensions(query: 'cache-test');
|
||||
expect(transport.urls.length, 1);
|
||||
expect(Uri.parse(transport.urls.single).queryParameters['view'], 'summary');
|
||||
pending.complete(
|
||||
_response({
|
||||
'data': [
|
||||
{'id': 'test'},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await started.future;
|
||||
expect(adapter.requests.length, 1);
|
||||
final request = adapter.requests.single;
|
||||
expect(request.method, 'GET');
|
||||
expect(request.uri.path, '/api/extensions');
|
||||
expect(request.uri.queryParameters, {
|
||||
'view': 'summary',
|
||||
'page': '1',
|
||||
'limit': '20',
|
||||
'sort': 'stars',
|
||||
'order': 'desc',
|
||||
'q': 'cache-test',
|
||||
});
|
||||
pending.complete({
|
||||
'data': [
|
||||
{'id': 'test'},
|
||||
],
|
||||
});
|
||||
expect((await first).data.single.hasDetails, isFalse);
|
||||
await second;
|
||||
await site.getExtensions(query: 'cache-test');
|
||||
expect(transport.urls.length, 1);
|
||||
expect(adapter.requests.length, 1);
|
||||
await site.getExtensions(query: 'cache-test', forceRefresh: true);
|
||||
expect(transport.urls.length, 2);
|
||||
expect(adapter.requests.length, 2);
|
||||
});
|
||||
|
||||
test('failed lists are not cached', () async {
|
||||
final transport = _Transport()..onProxy = (_) => Future.error(StateError('offline'));
|
||||
api.setTransportForTesting(transport);
|
||||
final site = GopeedSiteApi.instance;
|
||||
await expectLater(site.getExtensions(query: 'error-test'), throwsStateError);
|
||||
transport.onProxy = (_) async => _response({
|
||||
adapter.statusCode = 503;
|
||||
await expectLater(site.getExtensions(query: 'error-test'), throwsA(isA<DioException>()));
|
||||
adapter.statusCode = 200;
|
||||
adapter.onRequest = (_) async => {
|
||||
'data': [
|
||||
{'id': 'recovered'},
|
||||
],
|
||||
});
|
||||
};
|
||||
expect((await site.getExtensions(query: 'error-test')).data.single.id, 'recovered');
|
||||
expect(transport.urls.length, 2);
|
||||
expect(adapter.requests.length, 2);
|
||||
});
|
||||
|
||||
test('detail IDs are encoded once and a null README is still loaded detail', () async {
|
||||
final transport = _Transport()..onProxy = (_) async => _response({'id': '开发者@name', 'readme': null});
|
||||
api.setTransportForTesting(transport);
|
||||
final result = await GopeedSiteApi.instance.getExtension('开发者@name', version: '1.2.3');
|
||||
final uri = Uri.parse(transport.urls.single);
|
||||
test('detail IDs are encoded once and version remains a query parameter', () async {
|
||||
adapter.onRequest = (_) async => {'id': '开发者@name', 'readme': null};
|
||||
final result = await site.getExtension('开发者@name', version: '1.2.3');
|
||||
final uri = adapter.requests.single.uri;
|
||||
expect(uri.pathSegments, ['api', 'extensions', '开发者@name']);
|
||||
expect(uri.queryParameters['version'], '1.2.3');
|
||||
expect(result.hasDetails, isTrue);
|
||||
await site.getExtension('literal%2Fname');
|
||||
expect(adapter.requests.last.uri.pathSegments.last, 'literal%2Fname');
|
||||
});
|
||||
}
|
||||
|
||||
Response<String> _response(Map<String, dynamic> json) => Response(
|
||||
requestOptions: RequestOptions(path: '/test'),
|
||||
data: jsonEncode(json),
|
||||
statusCode: 200,
|
||||
);
|
||||
|
||||
class _Transport implements GopeedTransport {
|
||||
final urls = <String>[];
|
||||
Future<Response<String>> Function(String) onProxy = (_) async => _response({'data': []});
|
||||
|
||||
int calls = 0;
|
||||
@override
|
||||
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
|
||||
urls.add(uri);
|
||||
return onProxy(uri);
|
||||
Future<dynamic> request(
|
||||
String path, {
|
||||
String method = 'GET',
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) async {
|
||||
calls++;
|
||||
throw StateError('Store requests must not use the Downloader transport');
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
String join(String path) => path;
|
||||
}
|
||||
|
||||
class _SiteAdapter implements HttpClientAdapter {
|
||||
final requests = <RequestOptions>[];
|
||||
int statusCode = 200;
|
||||
Future<Map<String, dynamic>> Function(RequestOptions) onRequest = (_) async => {'data': []};
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<List<int>>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async {
|
||||
requests.add(options);
|
||||
return ResponseBody.fromString(
|
||||
jsonEncode(await onRequest(options)),
|
||||
statusCode,
|
||||
headers: {
|
||||
Headers.contentTypeHeader: [Headers.jsonContentType],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/api.dart' as api;
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
||||
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
|
||||
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
|
||||
|
||||
void main() {
|
||||
test('protocol jobs use the common protocol and job name API', () async {
|
||||
final transport = _Transport();
|
||||
api.setTransportForTesting(transport);
|
||||
await LocalAppCapabilities.instance.capabilities.gopeed.triggerProtocolJob('bt', 'update-trackers');
|
||||
expect(transport.path, 'api/v1/protocols/bt/jobs/update-trackers/trigger');
|
||||
expect(transport.method, 'POST');
|
||||
});
|
||||
|
||||
test('mirror settings and tracker settings serialize at their backend-owned locations', () {
|
||||
final config = DownloaderConfig();
|
||||
config.githubMirror = GithubMirrorConfig(enabled: false, mirrors: []);
|
||||
config.protocolConfig.bt.customTrackers = ['udp://example.com:80/announce'];
|
||||
config.protocolConfig.bt.state.trackerSubscriptionStatuses = {
|
||||
'https://example.com/list': TrackerSubscriptionStatus(
|
||||
success: true,
|
||||
trackerCount: 12,
|
||||
lastFetchTime: DateTime.utc(2026, 10, 19),
|
||||
),
|
||||
};
|
||||
final json = config.toJson();
|
||||
expect(json['githubMirror'], {'enabled': false, 'mirrors': []});
|
||||
expect(json['extra'], isNot(contains('githubMirror')));
|
||||
expect(json['extra'], isNot(contains('bt')));
|
||||
expect((json['protocolConfig'] as Map)['bt']['customTrackers'], ['udp://example.com:80/announce']);
|
||||
final restored = DownloaderConfig.fromJson(
|
||||
json,
|
||||
).protocolConfig.bt.state.trackerSubscriptionStatuses['https://example.com/list']!;
|
||||
expect(restored.success, isTrue);
|
||||
expect(restored.trackerCount, 12);
|
||||
expect(restored.lastFetchTime, DateTime.utc(2026, 10, 19));
|
||||
expect(BtState.fromJson({'lastTrackerUpdateTime': '0001-01-01T00:00:00Z'}).lastTrackerUpdateTime, isNull);
|
||||
});
|
||||
|
||||
test('protocol operations survive the shared capability codec', () {
|
||||
final codecs = createAppCapabilityCodecs();
|
||||
final payload = {'protocol': 'bt', 'name': 'update-trackers'};
|
||||
expect(codecs.decode<Map<String, dynamic>>(codecs.encode(payload)), payload);
|
||||
expect(codecs.decode<Map<String, dynamic>?>(null), isNull);
|
||||
expect(GopeedMethods.triggerProtocolJob.name, 'gopeed.protocol.job.trigger');
|
||||
expect(const RpcUnit(), isA<RpcUnit>());
|
||||
});
|
||||
|
||||
test('mirror candidates use provider dispatch and the shared capability codec', () async {
|
||||
final transport = _Transport();
|
||||
api.setTransportForTesting(transport);
|
||||
const url = 'https://github.com/GopeedLab/gopeed/releases/download/v2.0.0/package.zip';
|
||||
final result = await LocalAppCapabilities.instance.capabilities.gopeed.getMirrorCandidates('github', url);
|
||||
expect(transport.path, 'api/v1/mirrors/github/candidates');
|
||||
expect(transport.method, 'POST');
|
||||
expect(transport.data, {'url': url});
|
||||
expect(result, ['https://example.com/package.dmg']);
|
||||
final codecs = createAppCapabilityCodecs();
|
||||
expect(codecs.decode<List<String>>(codecs.encode(result)), result);
|
||||
expect(GopeedMethods.getMirrorCandidates.name, 'gopeed.mirrors.candidates');
|
||||
final request = {'provider': 'another-resource', 'url': 'https://example.com/resource'};
|
||||
expect(codecs.decode<Map<String, dynamic>>(codecs.encode(request)), request);
|
||||
await LocalAppCapabilities.instance.capabilities.gopeed.getMirrorCandidates('another-resource', request['url']!);
|
||||
expect(transport.path, 'api/v1/mirrors/another-resource/candidates');
|
||||
expect(transport.data, {'url': request['url']});
|
||||
});
|
||||
}
|
||||
|
||||
class _Transport implements GopeedTransport {
|
||||
String? path;
|
||||
String? method;
|
||||
dynamic data;
|
||||
@override
|
||||
Future<dynamic> request(
|
||||
String path, {
|
||||
String method = 'GET',
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) async {
|
||||
this.path = path;
|
||||
this.method = method;
|
||||
this.data = data;
|
||||
return {
|
||||
'code': 0,
|
||||
'data': path.endsWith('/trigger')
|
||||
? null
|
||||
: {
|
||||
'urls': ['https://example.com/package.dmg'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String join(String path) => path;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart' as dio;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/api.dart' as api;
|
||||
import 'package:gopeed/api/model/create_task.dart';
|
||||
@@ -77,9 +76,4 @@ class _RecordingTransport implements GopeedTransport {
|
||||
|
||||
@override
|
||||
String join(String path) => path;
|
||||
|
||||
@override
|
||||
Future<dio.Response<String>> proxyRequest(String uri, {dynamic data, dio.Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/api.dart' as api;
|
||||
import 'package:gopeed/api/gopeed_site_api.dart';
|
||||
import 'package:gopeed/core/network/app_update_client.dart';
|
||||
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
|
||||
|
||||
void main() {
|
||||
late Dio dio;
|
||||
late _Adapter adapter;
|
||||
late AppUpdateClient updates;
|
||||
late _RejectTransport transport;
|
||||
setUp(() {
|
||||
transport = _RejectTransport();
|
||||
api.setTransportForTesting(transport);
|
||||
dio = Dio();
|
||||
adapter = _Adapter();
|
||||
dio.httpClientAdapter = adapter;
|
||||
updates = AppUpdateClient(
|
||||
client: dio,
|
||||
site: GopeedSiteApi(client: dio),
|
||||
);
|
||||
});
|
||||
tearDown(() {
|
||||
dio.close(force: true);
|
||||
expect(transport.calls, 0);
|
||||
for (final request in adapter.requests) {
|
||||
expect(request.headers.keys.map((key) => key.toLowerCase()), isNot(contains('x-api-token')));
|
||||
}
|
||||
});
|
||||
|
||||
test('update metadata comes directly from the official GitHub API without mirror lookup', () async {
|
||||
adapter.reply = (_, _) async => _json([
|
||||
{'tag_name': 'v2.0.0', 'body': 'notes'},
|
||||
]);
|
||||
final info = await updates.check('1.0.0');
|
||||
expect(info!.version, '2.0.0');
|
||||
expect(info.changeLog, 'notes');
|
||||
expect(info.releaseUrl, 'https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0');
|
||||
expect(adapter.requests.single.uri.toString(), AppUpdateClient.releasesUrl);
|
||||
});
|
||||
|
||||
test('release asset hashes are bound to the selected release and exact file name', () async {
|
||||
final good = 'aB' * 32;
|
||||
adapter.reply = (_, _) async => _json([
|
||||
{
|
||||
'tag_name': 'v1.9.9',
|
||||
'assets': [
|
||||
{'name': 'old.zip', 'digest': 'sha256:${'c' * 64}'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'tag_name': 'v2.0.0',
|
||||
'assets': [
|
||||
{'name': 'package.zip', 'digest': 'sha256:$good'},
|
||||
{'name': 'wrong-algorithm.zip', 'digest': 'md5:${'a' * 32}'},
|
||||
{'name': 'missing.zip', 'digest': null},
|
||||
{'name': 'bad-hex.zip', 'digest': 'sha256:${'g' * 64}'},
|
||||
{'name': 'short.zip', 'digest': 'sha256:1234'},
|
||||
],
|
||||
},
|
||||
]);
|
||||
final info = (await updates.check('1.0.0'))!;
|
||||
expect(info.assetSha256, {'package.zip': good.toLowerCase()});
|
||||
expect(() => info.assetSha256['package.zip'] = 'd' * 64, throwsUnsupportedError);
|
||||
expect(adapter.requests.single.followRedirects, isFalse);
|
||||
});
|
||||
|
||||
test('the official website fallback preserves asset digests', () async {
|
||||
final checksum = 'e' * 64;
|
||||
adapter.reply = (request, _) async => request.uri.host == 'api.github.com'
|
||||
? _json({'message': 'rate limited'}, status: 403)
|
||||
: _json([
|
||||
{
|
||||
'tag_name': 'v2.0.0',
|
||||
'assets': [
|
||||
{'name': 'package.dmg', 'digest': 'sha256:$checksum'},
|
||||
],
|
||||
},
|
||||
]);
|
||||
final info = (await updates.check('1.0.0'))!;
|
||||
expect(info.assetSha256, {'package.dmg': checksum});
|
||||
expect(adapter.requests.map((request) => request.uri.host), ['api.github.com', 'gopeed.com']);
|
||||
expect(adapter.requests.every((request) => !request.followRedirects), isTrue);
|
||||
});
|
||||
|
||||
test('API redirects fail instead of obtaining metadata from another host', () async {
|
||||
adapter.reply = (_, _) async => ResponseBody.fromString(
|
||||
'',
|
||||
302,
|
||||
headers: {
|
||||
'location': ['https://mirror.example/releases'],
|
||||
},
|
||||
);
|
||||
await expectLater(updates.check('1.0.0'), throwsA(isA<DioException>()));
|
||||
expect(adapter.requests.map((request) => request.uri.host), ['api.github.com', 'gopeed.com']);
|
||||
expect(adapter.requests.every((request) => !request.followRedirects), isTrue);
|
||||
});
|
||||
|
||||
test('website fallback runs once after the official GitHub request fails', () async {
|
||||
adapter.reply = (request, _) async => switch (request.uri.host) {
|
||||
'api.github.com' => _json({'message': 'rate limited'}, status: 403),
|
||||
_ => _json([
|
||||
{'tag_name': 'v2.0.0'},
|
||||
]),
|
||||
};
|
||||
expect((await updates.check('1.0.0'))!.version, '2.0.0');
|
||||
expect(adapter.requests.map((request) => request.uri.host), ['api.github.com', 'gopeed.com']);
|
||||
final website = adapter.requests.where((r) => r.uri.host == 'gopeed.com').single;
|
||||
expect(website.method, 'GET');
|
||||
expect(website.uri.toString(), 'https://gopeed.com/api/releases?per_page=10');
|
||||
});
|
||||
|
||||
test('invalid GitHub metadata falls back to valid website metadata', () async {
|
||||
for (final invalid in [
|
||||
null,
|
||||
'<html>error</html>',
|
||||
{'message': 'error'},
|
||||
[
|
||||
{'tag_name': 'invalid'},
|
||||
],
|
||||
]) {
|
||||
adapter.requests.clear();
|
||||
adapter.reply = (request, _) async => request.uri.host == 'api.github.com'
|
||||
? _json(invalid)
|
||||
: _json([
|
||||
{'tag_name': 'v2.0.0'},
|
||||
]);
|
||||
expect((await updates.check('1.0.0'))!.version, '2.0.0');
|
||||
expect(adapter.requests.map((request) => request.uri.host), ['api.github.com', 'gopeed.com']);
|
||||
}
|
||||
});
|
||||
|
||||
test('valid empty and no-update lists do not fall back', () async {
|
||||
for (final releases in [
|
||||
[],
|
||||
[
|
||||
{'tag_name': 'v1.0.0'},
|
||||
],
|
||||
]) {
|
||||
adapter.requests.clear();
|
||||
adapter.reply = (_, _) async => _json(releases);
|
||||
expect(await updates.check('1.0.0'), isNull);
|
||||
expect(adapter.requests.single.uri.host, 'api.github.com');
|
||||
}
|
||||
});
|
||||
|
||||
test('null, objects, invalid releases and failed website responses propagate', () async {
|
||||
for (final invalid in [
|
||||
null,
|
||||
{'message': 'error'},
|
||||
[
|
||||
{'tag_name': 'not a version'},
|
||||
],
|
||||
]) {
|
||||
adapter.requests.clear();
|
||||
adapter.reply = (_, _) async => _json(invalid);
|
||||
await expectLater(updates.check('1.0.0'), throwsFormatException);
|
||||
expect(adapter.requests.map((r) => r.uri.host), ['api.github.com', 'gopeed.com']);
|
||||
}
|
||||
adapter.reply = (_, _) async => _json({}, status: 503);
|
||||
await expectLater(updates.check('1.0.0'), throwsA(isA<DioException>()));
|
||||
});
|
||||
|
||||
test('SemVer selection preserves stable and same-series preview rules', () async {
|
||||
var releases = [
|
||||
{'tag_name': 'v2.1.0-beta.1', 'prerelease': true},
|
||||
{'tag_name': 'v2.0.0-beta.2', 'prerelease': true},
|
||||
{'tag_name': 'v2.0.0-rc.1', 'prerelease': true},
|
||||
{'tag_name': 'v1.9.9'},
|
||||
{'tag_name': 'v2.0.0', 'draft': true},
|
||||
];
|
||||
adapter.reply = (_, _) async => _json(releases);
|
||||
expect((await updates.check('2.0.0-beta.1'))!.version, '2.0.0-rc.1');
|
||||
expect((await updates.check('1.9.8'))!.version, '1.9.9');
|
||||
releases = [
|
||||
{'tag_name': 'v2.0.0-beta.10'},
|
||||
{'tag_name': 'v2.0.0-beta.2'},
|
||||
];
|
||||
expect((await updates.check('2.0.0-beta.1'))!.version, '2.0.0-beta.10');
|
||||
releases = [
|
||||
{'tag_name': 'v2.0.0'},
|
||||
{'tag_name': 'v2.0.1'},
|
||||
];
|
||||
expect((await updates.check('2.0.0-beta.1'))!.version, '2.0.1');
|
||||
releases = [
|
||||
{'tag_name': 'v2.0.0+build.2'},
|
||||
];
|
||||
expect(await updates.check('2.0.0+build.1'), isNull);
|
||||
await expectLater(updates.check('invalid'), throwsFormatException);
|
||||
});
|
||||
}
|
||||
|
||||
ResponseBody _json(dynamic body, {int status = 200}) => ResponseBody.fromString(
|
||||
jsonEncode(body),
|
||||
status,
|
||||
headers: {
|
||||
Headers.contentTypeHeader: [Headers.jsonContentType],
|
||||
},
|
||||
);
|
||||
|
||||
class _Adapter implements HttpClientAdapter {
|
||||
final requests = <RequestOptions>[];
|
||||
late Future<ResponseBody> Function(RequestOptions, Future<void>?) reply;
|
||||
@override
|
||||
Future<ResponseBody> fetch(RequestOptions options, Stream<List<int>>? requestStream, Future<void>? cancelFuture) {
|
||||
requests.add(options);
|
||||
return reply(options, cancelFuture);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
|
||||
class _RejectTransport implements GopeedTransport {
|
||||
int calls = 0;
|
||||
@override
|
||||
Future<dynamic> request(
|
||||
String path, {
|
||||
String method = 'GET',
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) async {
|
||||
calls++;
|
||||
throw StateError('Update checks must not use the Downloader or mirror API');
|
||||
}
|
||||
|
||||
@override
|
||||
String join(String path) => path;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:gopeed/core/network/candidate_download.dart';
|
||||
|
||||
void main() {
|
||||
final expectedSha256 = sha256.convert([for (var i = 1; i <= 3; i++) ...List.filled(32768, i)]).toString();
|
||||
late HttpServer server;
|
||||
var requests = 0;
|
||||
late Directory directory;
|
||||
late String savePath;
|
||||
late List<String> downloads;
|
||||
late Future<void> Function(HttpRequest request) handle;
|
||||
|
||||
setUp(() async {
|
||||
directory = await Directory.systemTemp.createTemp('gopeed-update-test-');
|
||||
savePath = '${directory.path}/package.zip';
|
||||
downloads = [];
|
||||
requests = 0;
|
||||
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
server.listen((request) async {
|
||||
requests++;
|
||||
if (request.method == 'GET') downloads.add(request.uri.path);
|
||||
await handle(request);
|
||||
await request.response.close();
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await server.close(force: true);
|
||||
await directory.delete(recursive: true);
|
||||
});
|
||||
|
||||
String url(String path) => 'http://127.0.0.1:${server.port}$path';
|
||||
|
||||
Future<void> package(HttpRequest request) async {
|
||||
const chunkSize = 32768;
|
||||
request.response.headers.contentType = ContentType.binary;
|
||||
request.response.contentLength = chunkSize * 3;
|
||||
if (request.method == 'HEAD') return;
|
||||
for (var i = 0; i < 3; i++) {
|
||||
request.response.add(List.filled(chunkSize, i + 1));
|
||||
await request.response.flush();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
}
|
||||
|
||||
test('downloads the fastest successful probe and reports intermediate byte progress', () async {
|
||||
handle = (request) async {
|
||||
if (request.uri.path == '/slow' && request.method == 'HEAD') {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 120));
|
||||
}
|
||||
await package(request);
|
||||
};
|
||||
final progress = <(int, int)>[];
|
||||
await downloadFromCandidates(
|
||||
[url('/slow'), url('/fast'), url('/fast')],
|
||||
savePath,
|
||||
expectedSha256: expectedSha256,
|
||||
onReceiveProgress: (received, total) => progress.add((received, total)),
|
||||
);
|
||||
expect(downloads, ['/fast']);
|
||||
expect(await File(savePath).length(), 98304);
|
||||
expect(progress.any((p) => p.$1 > 0 && p.$1 < p.$2), isTrue);
|
||||
expect(progress.last, (98304, 98304));
|
||||
});
|
||||
|
||||
test('falls back when a successful HEAD is followed by a failed GET', () async {
|
||||
handle = (request) async {
|
||||
if (request.uri.path == '/broken') {
|
||||
request.response.headers.contentType = ContentType.binary;
|
||||
if (request.method == 'GET') {
|
||||
request.response.statusCode = HttpStatus.serviceUnavailable;
|
||||
request.response.write('unavailable');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (request.method == 'HEAD') {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 60));
|
||||
}
|
||||
await package(request);
|
||||
};
|
||||
await downloadFromCandidates(
|
||||
[url('/broken'), url('/working')],
|
||||
savePath,
|
||||
expectedSha256: expectedSha256,
|
||||
onReceiveProgress: (_, _) {},
|
||||
);
|
||||
expect(downloads, ['/broken', '/working']);
|
||||
final bytes = await File(savePath).readAsBytes();
|
||||
expect(bytes.length, 98304);
|
||||
expect(bytes.take(32768), everyElement(1));
|
||||
});
|
||||
|
||||
test('tries GET even when HEAD is unsupported', () async {
|
||||
handle = (request) async {
|
||||
if (request.method == 'HEAD') {
|
||||
request.response.statusCode = HttpStatus.methodNotAllowed;
|
||||
return;
|
||||
}
|
||||
await package(request);
|
||||
};
|
||||
await downloadFromCandidates(
|
||||
[url('/no-head')],
|
||||
savePath,
|
||||
expectedSha256: expectedSha256,
|
||||
onReceiveProgress: (_, _) {},
|
||||
);
|
||||
expect(downloads, ['/no-head']);
|
||||
expect(await File(savePath).length(), 98304);
|
||||
});
|
||||
|
||||
test('bounds probe time and still allows a normal download', () async {
|
||||
handle = (request) async {
|
||||
if (request.method == 'HEAD') {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
return;
|
||||
}
|
||||
await package(request);
|
||||
};
|
||||
await downloadFromCandidates(
|
||||
[url('/head-timeout')],
|
||||
savePath,
|
||||
expectedSha256: expectedSha256,
|
||||
probeTimeout: const Duration(milliseconds: 20),
|
||||
onReceiveProgress: (_, _) {},
|
||||
);
|
||||
expect(downloads, ['/head-timeout']);
|
||||
expect(await File(savePath).length(), 98304);
|
||||
});
|
||||
|
||||
test('rejects mirror landing pages and removes an unsuccessful download', () async {
|
||||
handle = (request) async {
|
||||
request.response.headers.contentType = ContentType.html;
|
||||
if (request.method == 'GET') request.response.write('<html>mirror error</html>');
|
||||
};
|
||||
await expectLater(
|
||||
downloadFromCandidates([url('/landing')], savePath, expectedSha256: expectedSha256, onReceiveProgress: (_, _) {}),
|
||||
throwsStateError,
|
||||
);
|
||||
expect(await File(savePath).exists(), isFalse);
|
||||
});
|
||||
test('same-sized modified mirror bytes fail the official hash and fall back', () async {
|
||||
handle = (request) async {
|
||||
if (request.uri.path == '/tampered') {
|
||||
request.response.headers.contentType = ContentType.binary;
|
||||
// A mirror-provided hash must never replace the expected official hash.
|
||||
request.response.headers.set('x-checksum-sha256', sha256.convert(List.filled(98304, 9)).toString());
|
||||
request.response.contentLength = 98304;
|
||||
if (request.method == 'GET') request.response.add(List.filled(98304, 9));
|
||||
return;
|
||||
}
|
||||
if (request.method == 'HEAD') await Future<void>.delayed(const Duration(milliseconds: 60));
|
||||
await package(request);
|
||||
};
|
||||
await downloadFromCandidates(
|
||||
[url('/tampered'), url('/official')],
|
||||
savePath,
|
||||
expectedSha256: expectedSha256,
|
||||
onReceiveProgress: (_, _) {},
|
||||
);
|
||||
expect(downloads, ['/tampered', '/official']);
|
||||
expect((await sha256.bind(File(savePath).openRead()).first).toString(), expectedSha256);
|
||||
});
|
||||
|
||||
test('all checksum failures abort and delete the untrusted package', () async {
|
||||
handle = (request) async {
|
||||
request.response.headers.contentType = ContentType.binary;
|
||||
if (request.method == 'GET') request.response.add(List.filled(98304, 9));
|
||||
};
|
||||
await expectLater(
|
||||
downloadFromCandidates(
|
||||
[url('/bad-one'), url('/bad-two')],
|
||||
savePath,
|
||||
expectedSha256: expectedSha256,
|
||||
onReceiveProgress: (_, _) {},
|
||||
),
|
||||
throwsA(isA<DownloadChecksumException>()),
|
||||
);
|
||||
expect(downloads.toSet(), {'/bad-one', '/bad-two'});
|
||||
expect(await File(savePath).exists(), isFalse);
|
||||
});
|
||||
|
||||
test('missing or malformed official checksums cannot start a probe or download', () async {
|
||||
handle = package;
|
||||
for (final checksum in ['', 'sha256:$expectedSha256', '0' * 63, 'g' * 64, '$expectedSha256\n']) {
|
||||
await expectLater(
|
||||
downloadFromCandidates([url('/official')], savePath, expectedSha256: checksum, onReceiveProgress: (_, _) {}),
|
||||
throwsA(isA<DownloadChecksumException>()),
|
||||
);
|
||||
}
|
||||
expect(requests, 0);
|
||||
expect(await File(savePath).exists(), isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/api.dart' as api;
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/network/mirror_client.dart';
|
||||
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
|
||||
|
||||
void main() {
|
||||
late _CandidatesTransport transport;
|
||||
late Dio dio;
|
||||
late _Adapter adapter;
|
||||
late MirrorClient client;
|
||||
const resourceUrl = 'https://raw.githubusercontent.com/owner/repo/main/manifest.json';
|
||||
const mirror = 'https://mirror.example/https://raw.githubusercontent.com/owner/repo/main/manifest.json';
|
||||
setUp(() {
|
||||
transport = _CandidatesTransport([resourceUrl, mirror]);
|
||||
api.setTransportForTesting(transport);
|
||||
dio = Dio();
|
||||
adapter = _Adapter();
|
||||
dio.httpClientAdapter = adapter;
|
||||
client = MirrorClient(service: LocalAppCapabilities.instance.capabilities.gopeed, provider: 'github', client: dio);
|
||||
});
|
||||
tearDown(() {
|
||||
dio.close(force: true);
|
||||
for (final request in adapter.requests) {
|
||||
expect(request.headers.keys.map((key) => key.toLowerCase()), isNot(contains('x-api-token')));
|
||||
}
|
||||
});
|
||||
|
||||
test('an invalid fast mirror cannot hide a slower valid release list', () async {
|
||||
final official = Completer<ResponseBody>();
|
||||
final mirrorRead = Completer<void>();
|
||||
adapter.reply = (request, _) {
|
||||
if (request.uri.host == 'raw.githubusercontent.com') return official.future;
|
||||
mirrorRead.complete();
|
||||
return Future.value(_json('<html>mirror error</html>'));
|
||||
};
|
||||
final result = client.getJson(resourceUrl, decode: _decodeList);
|
||||
await mirrorRead.future;
|
||||
official.complete(
|
||||
_json([
|
||||
{'tag_name': 'v2.0.0', 'body': 'notes'},
|
||||
]),
|
||||
);
|
||||
final info = await result;
|
||||
expect(info.single['tag_name'], 'v2.0.0');
|
||||
expect(info.single['body'], 'notes');
|
||||
expect(adapter.requests.any((r) => r.uri.host == 'gopeed.com'), isFalse);
|
||||
expect(transport.requested, [resourceUrl]);
|
||||
});
|
||||
|
||||
test('a complete validated winner cancels a stalled candidate', () async {
|
||||
final officialStarted = Completer<void>();
|
||||
final canceled = Completer<void>();
|
||||
adapter.reply = (request, cancellation) async {
|
||||
if (request.uri.host == 'raw.githubusercontent.com') {
|
||||
officialStarted.complete();
|
||||
await cancellation;
|
||||
canceled.complete();
|
||||
throw StateError('canceled');
|
||||
}
|
||||
await officialStarted.future;
|
||||
return _json([
|
||||
{'tag_name': 'v2.0.0'},
|
||||
]);
|
||||
};
|
||||
expect((await client.getJson(resourceUrl, decode: _decodeList)).single['tag_name'], 'v2.0.0');
|
||||
await canceled.future;
|
||||
expect(adapter.requests.length, 2);
|
||||
});
|
||||
|
||||
test('the mirror client supports other providers and non-GitHub resources', () async {
|
||||
const resource = 'https://resources.example/manifest.json';
|
||||
transport.provider = 'resources';
|
||||
transport.urls = [resource, 'https://cdn.example/manifest.json'];
|
||||
adapter.reply = (request, _) async =>
|
||||
request.uri.host == 'resources.example' ? _json({}, status: 503) : _json({'revision': 42});
|
||||
final client = MirrorClient(
|
||||
service: LocalAppCapabilities.instance.capabilities.gopeed,
|
||||
provider: 'resources',
|
||||
client: dio,
|
||||
);
|
||||
final revision = await client.getJson<int>(resource, decode: (data) => (data as Map)['revision'] as int);
|
||||
expect(revision, 42);
|
||||
expect(transport.requested, [resource]);
|
||||
expect(adapter.requests.map((request) => request.uri.host).toSet(), {'resources.example', 'cdn.example'});
|
||||
});
|
||||
|
||||
test('metadata attempts have a total timeout even if the transport stalls', () async {
|
||||
final canceled = Completer<void>();
|
||||
adapter.reply = (_, cancellation) async {
|
||||
await cancellation;
|
||||
canceled.complete();
|
||||
throw StateError('canceled');
|
||||
};
|
||||
transport.urls = [resourceUrl];
|
||||
final github = MirrorClient(
|
||||
service: LocalAppCapabilities.instance.capabilities.gopeed,
|
||||
provider: 'github',
|
||||
client: dio,
|
||||
timeout: const Duration(milliseconds: 30),
|
||||
);
|
||||
await expectLater(github.getJson(resourceUrl, decode: (data) => data), throwsA(isA<TimeoutException>()));
|
||||
await canceled.future;
|
||||
});
|
||||
}
|
||||
|
||||
List<dynamic> _decodeList(dynamic data) {
|
||||
if (data is! List) throw const FormatException('Invalid manifest');
|
||||
return data;
|
||||
}
|
||||
|
||||
ResponseBody _json(dynamic body, {int status = 200}) => ResponseBody.fromString(
|
||||
jsonEncode(body),
|
||||
status,
|
||||
headers: {
|
||||
Headers.contentTypeHeader: [Headers.jsonContentType],
|
||||
},
|
||||
);
|
||||
|
||||
class _Adapter implements HttpClientAdapter {
|
||||
final requests = <RequestOptions>[];
|
||||
late Future<ResponseBody> Function(RequestOptions, Future<void>?) reply;
|
||||
@override
|
||||
Future<ResponseBody> fetch(RequestOptions options, Stream<List<int>>? requestStream, Future<void>? cancelFuture) {
|
||||
requests.add(options);
|
||||
return reply(options, cancelFuture);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
|
||||
class _CandidatesTransport implements GopeedTransport {
|
||||
_CandidatesTransport(this.urls);
|
||||
List<String> urls;
|
||||
String provider = 'github';
|
||||
bool fail = false;
|
||||
final requested = <String>[];
|
||||
@override
|
||||
Future<dynamic> request(
|
||||
String path, {
|
||||
String method = 'GET',
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) async {
|
||||
expect(path, 'api/v1/mirrors/$provider/candidates');
|
||||
expect(method, 'POST');
|
||||
requested.add((data as Map)['url'] as String);
|
||||
if (fail) throw StateError('Downloader unavailable');
|
||||
return {
|
||||
'code': 0,
|
||||
'data': {'urls': urls},
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String join(String path) => path;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/api.dart' as api;
|
||||
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
|
||||
@@ -31,11 +30,6 @@ class _LoginTransport implements GopeedTransport {
|
||||
return {'code': 0};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
String join(String path) => path;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
@@ -67,6 +69,106 @@ void main() {
|
||||
expect(saved?.extra.createHistory, ['https://example.com/existing.zip']);
|
||||
expect(saved?.extra.analyticsClientId, 'existing-client');
|
||||
});
|
||||
|
||||
test('protocol edits use a single full PUT and reload backend-owned tracker results', () async {
|
||||
final backend = DownloaderConfig();
|
||||
final puts = <DownloaderConfig>[];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig.fromJson(backend.toJson()))
|
||||
..bind(GopeedMethods.putConfig, (config) {
|
||||
puts.add(config);
|
||||
// Simulate the backend retaining its read-only results on a full PUT.
|
||||
backend.protocolConfig.bt.listenPort = config.protocolConfig.bt.listenPort;
|
||||
return const RpcUnit();
|
||||
});
|
||||
final container = _settingsContainer(registry);
|
||||
addTearDown(container.dispose);
|
||||
final initial = await container.read(settingsControllerProvider.future);
|
||||
final draft = DownloaderConfig.fromJson(initial.config.toJson())..protocolConfig.bt.listenPort = 6881;
|
||||
backend.protocolConfig.bt.state.trackerSources = {
|
||||
'https://example.com/list': ['udp://fresh.example:80/announce'],
|
||||
};
|
||||
backend.protocolConfig.bt.state.lastTrackerUpdateTime = DateTime.utc(2026, 10, 19);
|
||||
await container.read(settingsControllerProvider.notifier).save(draft);
|
||||
expect(puts, hasLength(1));
|
||||
expect(puts.single.protocolConfig.bt.listenPort, 6881);
|
||||
final saved = container.read(settingsControllerProvider).requireValue;
|
||||
expect(saved.config.protocolConfig.bt.state.trackerSources['https://example.com/list'], [
|
||||
'udp://fresh.example:80/announce',
|
||||
]);
|
||||
expect(saved.saving, isFalse);
|
||||
});
|
||||
|
||||
test('rapid full config saves run in order and keep the latest user edits', () async {
|
||||
final backend = DownloaderConfig();
|
||||
final entered = Completer<void>();
|
||||
final release = Completer<void>();
|
||||
final puts = <DownloaderConfig>[];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig.fromJson(backend.toJson()))
|
||||
..bind(GopeedMethods.putConfig, (config) async {
|
||||
puts.add(DownloaderConfig.fromJson(config.toJson()));
|
||||
if (puts.length == 1) {
|
||||
entered.complete();
|
||||
await release.future;
|
||||
}
|
||||
backend.protocolConfig = config.protocolConfig;
|
||||
return const RpcUnit();
|
||||
});
|
||||
final container = _settingsContainer(registry);
|
||||
addTearDown(container.dispose);
|
||||
final initial = await container.read(settingsControllerProvider.future);
|
||||
final controller = container.read(settingsControllerProvider.notifier);
|
||||
final first = DownloaderConfig.fromJson(initial.config.toJson())..protocolConfig.bt.listenPort = 6881;
|
||||
final savingFirst = controller.save(first);
|
||||
await entered.future;
|
||||
final second = DownloaderConfig.fromJson(first.toJson())
|
||||
..protocolConfig.bt.customTrackers = ['udp://custom.example:80/announce'];
|
||||
final savingSecond = controller.save(second);
|
||||
expect(puts.length, 1);
|
||||
release.complete();
|
||||
await Future.wait([savingFirst, savingSecond]);
|
||||
expect(puts, hasLength(2));
|
||||
expect(puts[0].protocolConfig.bt.listenPort, 6881);
|
||||
expect(puts[1].protocolConfig.bt.listenPort, 6881);
|
||||
expect(puts[1].protocolConfig.bt.customTrackers, ['udp://custom.example:80/announce']);
|
||||
expect(container.read(settingsControllerProvider).requireValue.config.protocolConfig.bt.customTrackers, [
|
||||
'udp://custom.example:80/announce',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a failed save clears saving state and the same change can be retried', () async {
|
||||
final backend = DownloaderConfig();
|
||||
var attempts = 0;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig.fromJson(backend.toJson()))
|
||||
..bind(GopeedMethods.putConfig, (config) {
|
||||
if (++attempts == 1) throw StateError('offline');
|
||||
backend.protocolConfig = config.protocolConfig;
|
||||
return const RpcUnit();
|
||||
});
|
||||
final container = _settingsContainer(registry);
|
||||
addTearDown(container.dispose);
|
||||
final initial = await container.read(settingsControllerProvider.future);
|
||||
final controller = container.read(settingsControllerProvider.notifier);
|
||||
final draft = DownloaderConfig.fromJson(initial.config.toJson())..protocolConfig.bt.listenPort = 6881;
|
||||
await expectLater(controller.save(draft), throwsStateError);
|
||||
expect(container.read(settingsControllerProvider).requireValue.saving, isFalse);
|
||||
await controller.save(draft);
|
||||
expect(attempts, 2);
|
||||
expect(backend.protocolConfig.bt.listenPort, 6881);
|
||||
});
|
||||
}
|
||||
|
||||
ProviderContainer _settingsContainer(CapabilityRegistry registry) {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))),
|
||||
appRuntimeControllerProvider.overrideWith(_TestRuntimeController.new),
|
||||
],
|
||||
);
|
||||
container.listen(settingsControllerProvider, (_, _) {});
|
||||
return container;
|
||||
}
|
||||
|
||||
class _TestRuntimeController extends AppRuntimeController {
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
import 'package:flutter/gestures.dart' show PointerDeviceKind;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:gopeed/features/settings/presentation/widgets/tracker_subscriptions_control.dart';
|
||||
import 'package:gopeed/features/settings/presentation/widgets/tracker_subscriptions_schedule.dart';
|
||||
import 'package:gopeed/features/settings/presentation/widgets/tracker_subscriptions_table.dart';
|
||||
import 'package:gopeed/l10n/l10n.dart';
|
||||
import 'package:gopeed/shared/theme/app_component_themes.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
for (final width in [360.0, 768.0, 1024.0]) {
|
||||
testWidgets('subscription results fit at width $width', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final time = DateTime.utc(2026, 10, 19);
|
||||
await tester.pumpWidget(
|
||||
ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: AppComponentThemes(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TrackerSubscriptionsControl(
|
||||
urls: const [
|
||||
'https://success.example/very/long/path/to/my/custom/list.txt',
|
||||
'https://failed.example/list',
|
||||
'https://new.example/list',
|
||||
],
|
||||
statuses: {
|
||||
'https://success.example/very/long/path/to/my/custom/list.txt': TrackerSubscriptionStatus(
|
||||
success: true,
|
||||
trackerCount: 7,
|
||||
lastFetchTime: time,
|
||||
),
|
||||
'https://failed.example/list': TrackerSubscriptionStatus(
|
||||
success: false,
|
||||
trackerCount: 0,
|
||||
lastFetchTime: time,
|
||||
),
|
||||
},
|
||||
autoUpdate: true,
|
||||
lastUpdated: time,
|
||||
onChanged: (_) {},
|
||||
onAutoUpdateChanged: (_) {},
|
||||
onUpdate: () async {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Update status'), findsOneWidget);
|
||||
expect(find.text('Tracker count'), findsOneWidget);
|
||||
expect(find.text('Actions'), findsOneWidget);
|
||||
for (final entry in const [
|
||||
('https://success.example/very/long/path/to/my/custom/list.txt', 'Succeeded', '7'),
|
||||
('https://failed.example/list', 'Failed', '0'),
|
||||
('https://new.example/list', 'Not updated', '—'),
|
||||
]) {
|
||||
final row = find.byKey(ValueKey('tracker-subscription-${entry.$1}'));
|
||||
expect(find.descendant(of: row, matching: find.text(entry.$2)), findsOneWidget);
|
||||
expect(find.descendant(of: row, matching: find.text(entry.$3)), findsOneWidget);
|
||||
}
|
||||
final table = find.byKey(const ValueKey('tracker-subscriptions-table'));
|
||||
expect(find.descendant(of: table, matching: find.textContaining('2026-')), findsNothing);
|
||||
expect(find.textContaining('Last updated: '), findsOneWidget);
|
||||
final updateRect = tester.getRect(find.byKey(const ValueKey('tracker-update-button')));
|
||||
final addRect = tester.getRect(find.byKey(const ValueKey('add-tracker-subscription')));
|
||||
final tableRect = tester.getRect(table);
|
||||
final autoUpdateRect = tester.getRect(find.byKey(const ValueKey('tracker-auto-update-control')));
|
||||
final timeRect = tester.getRect(find.byKey(const ValueKey('tracker-last-updated')));
|
||||
expect(addRect.right, closeTo(tableRect.right, 1));
|
||||
expect(updateRect.right, lessThan(addRect.left));
|
||||
expect(addRect.center.dy, closeTo(updateRect.center.dy, 1));
|
||||
expect(updateRect.top, greaterThan(tableRect.bottom));
|
||||
expect(autoUpdateRect.bottom, lessThan(tableRect.top));
|
||||
expect(autoUpdateRect.right, closeTo(tableRect.right, 1));
|
||||
expect(timeRect.top, greaterThan(autoUpdateRect.bottom));
|
||||
expect(timeRect.bottom, lessThan(tableRect.top));
|
||||
expect(timeRect.right, closeTo(tableRect.right, 1));
|
||||
expect(find.byKey(const ValueKey('add-tracker-subscription')), findsOneWidget);
|
||||
if (width < 672) {
|
||||
final headerPosition = tester.getTopLeft(find.text('Subscription URL'));
|
||||
await tester.dragFrom(tester.getTopLeft(table) + const Offset(100, 20), const Offset(-600, 0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getTopLeft(find.text('Subscription URL')).dx, lessThan(headerPosition.dx));
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
for (final locale in const [Locale('en'), Locale('zh')]) {
|
||||
for (final width in [320.0, 768.0, 1024.0]) {
|
||||
testWidgets('schedule hides missing update time at width $width in $locale', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
bool? autoUpdate;
|
||||
await tester.pumpWidget(
|
||||
ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (context) => Localizations.override(
|
||||
context: context,
|
||||
locale: locale,
|
||||
delegates: const [AppLocalizations.delegate],
|
||||
child: AppComponentThemes(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: TrackerSubscriptionsSchedule(
|
||||
lastUpdated: null,
|
||||
autoUpdate: false,
|
||||
onAutoUpdateChanged: (value) => autoUpdate = value,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('tracker-last-updated')), findsNothing);
|
||||
expect(find.text('Never updated'), findsNothing);
|
||||
expect(find.text('尚未更新'), findsNothing);
|
||||
final scheduleRect = tester.getRect(find.byKey(const ValueKey('tracker-subscriptions-schedule')));
|
||||
final autoRect = tester.getRect(find.byKey(const ValueKey('tracker-auto-update-control')));
|
||||
expect(find.byKey(const ValueKey('tracker-update-button')), findsNothing);
|
||||
expect(autoRect.right, closeTo(scheduleRect.right, 1));
|
||||
await tester.tap(find.byType(Switch));
|
||||
expect(autoUpdate, true);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
for (final width in [360.0, 768.0, 1024.0]) {
|
||||
testWidgets('long subscription table scrolls within its viewport at width $width', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final urls = List.generate(50, (index) => 'https://tracker-$index.example/list.txt');
|
||||
final deletions = <int>[];
|
||||
await tester.pumpWidget(
|
||||
ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: AppComponentThemes(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TrackerSubscriptionsTable(urls: urls, statuses: const {}, onDelete: deletions.add),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final table = find.byKey(const ValueKey('tracker-subscriptions-table'));
|
||||
final rows = find.byKey(const ValueKey('tracker-subscriptions-rows'));
|
||||
expect(tester.getSize(table).height, lessThan(300));
|
||||
expect(tester.getSize(table).width, width - 32);
|
||||
final headerPosition = tester.getTopLeft(find.text('Subscription URL'));
|
||||
expect(find.byKey(ValueKey('tracker-subscription-${urls.last}')), findsNothing);
|
||||
await tester.dragFrom(tester.getTopLeft(rows) + const Offset(40, 80), const Offset(0, -3000));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(ValueKey('tracker-subscription-${urls.last}')), findsOneWidget);
|
||||
expect(tester.getTopLeft(find.text('Subscription URL')), headerPosition);
|
||||
final delete = find.byKey(ValueKey('delete-settings-list-${urls.last}'));
|
||||
final deleteRect = tester.getRect(delete);
|
||||
final actionsHeader = tester.getTopLeft(find.byKey(const ValueKey('tracker-subscriptions-actions-header')));
|
||||
expect(deleteRect.right, lessThanOrEqualTo(tester.getRect(table).right));
|
||||
expect(deleteRect.left, greaterThan(tester.getRect(table).left));
|
||||
expect(delete.hitTestable(), findsOneWidget);
|
||||
// Data columns scroll, while the delete column remains visible and fixed.
|
||||
if (width < 672) {
|
||||
final headerPosition = tester.getTopLeft(find.text('Subscription URL'));
|
||||
await tester.dragFrom(tester.getTopLeft(table) + const Offset(100, 20), const Offset(-30, 0));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.dragFrom(
|
||||
tester.getBottomLeft(table) + const Offset(40, -5),
|
||||
const Offset(600, 0),
|
||||
kind: PointerDeviceKind.mouse,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getTopLeft(find.text('Subscription URL')).dx, lessThan(headerPosition.dx));
|
||||
}
|
||||
expect(tester.getRect(delete), deleteRect);
|
||||
expect(tester.getTopLeft(find.byKey(const ValueKey('tracker-subscriptions-actions-header'))), actionsHeader);
|
||||
expect(find.byIcon(Icons.edit_outlined), findsNothing);
|
||||
if (width < 672) {
|
||||
final rightInset = tester.getRect(table).right - deleteRect.right;
|
||||
tester.view.physicalSize = const Size(768, 900);
|
||||
await tester.pumpAndSettle();
|
||||
expect(delete.hitTestable(), findsOneWidget);
|
||||
expect(tester.getRect(table).right - tester.getRect(delete).right, closeTo(rightInset, 1));
|
||||
}
|
||||
await tester.tap(delete);
|
||||
expect(deletions, [49]);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:gopeed/features/settings/presentation/widgets/app_update_dialog.dart';
|
||||
import 'package:gopeed/features/settings/presentation/widgets/app_update_notes_viewport.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:gopeed/util/arch/arch.dart';
|
||||
import 'package:gopeed/util/github_mirror.dart';
|
||||
import 'package:gopeed/util/package_info.dart' as app_package;
|
||||
import 'package:gopeed/util/updater.dart';
|
||||
import 'package:gopeed/core/network/candidate_download.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
@@ -24,101 +22,29 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
group('version comparison', () {
|
||||
test('uses semantic version precedence', () {
|
||||
expect(isNewerVersion('1.10.0', '1.9.9'), isTrue);
|
||||
expect(isNewerVersion('2.0.0-beta.2', '2.0.0-beta.1'), isTrue);
|
||||
expect(isNewerVersion('2.0.0-rc.1', '2.0.0-beta.2'), isTrue);
|
||||
expect(isNewerVersion('2.0.0', '2.0.0-rc.1'), isTrue);
|
||||
expect(isNewerVersion('1.2.3', '1.2.3'), isFalse);
|
||||
expect(isNewerVersion('1.2.2', '1.2.3'), isFalse);
|
||||
expect(isNewerVersion('invalid', '1.2.3'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('release selection', () {
|
||||
Map<String, dynamic> release(String version, {bool prerelease = false, bool draft = false}) => {
|
||||
'tag_name': 'v$version',
|
||||
'prerelease': prerelease,
|
||||
'draft': draft,
|
||||
'body': '$version notes',
|
||||
'html_url': 'https://example.com/$version',
|
||||
};
|
||||
|
||||
test('stable builds ignore preview releases', () {
|
||||
final selected = selectUpdateRelease([release('2.1.0-beta.1', prerelease: true), release('2.0.1')], '2.0.0');
|
||||
|
||||
expect(selected?.version, '2.0.1');
|
||||
});
|
||||
|
||||
test('preview builds advance within the same preview line', () {
|
||||
final selected = selectUpdateRelease([
|
||||
release('2.0.0-beta.2', prerelease: true),
|
||||
release('1.9.9'),
|
||||
release('2.1.0-beta.1', prerelease: true),
|
||||
], '2.0.0-beta.1');
|
||||
|
||||
expect(selected?.version, '2.0.0-beta.2');
|
||||
});
|
||||
|
||||
test('four-part platform beta selects the next GitHub prerelease and matching asset', () {
|
||||
final currentVersion = app_package.normalizeAppVersion('2.0.0.1');
|
||||
final selected = selectUpdateRelease([release('2.0.0-beta.2', prerelease: true)], currentVersion);
|
||||
|
||||
expect(selected?.version, '2.0.0-beta.2');
|
||||
expect(
|
||||
updateAssetName(selected!.version, channel: UpdateChannel.macosDmg, architecture: Architecture.arm64),
|
||||
'Gopeed-v2.0.0-beta.2-macos-arm64.dmg',
|
||||
);
|
||||
});
|
||||
|
||||
test('preview builds graduate to the final stable release', () {
|
||||
final selected = selectUpdateRelease([
|
||||
release('2.0.0-beta.2', prerelease: true),
|
||||
release('2.0.0'),
|
||||
], '2.0.0-beta.1');
|
||||
|
||||
expect(selected?.version, '2.0.0');
|
||||
});
|
||||
|
||||
test('selects the greatest eligible version and ignores invalid releases', () {
|
||||
final selected = selectUpdateRelease([
|
||||
release('2.0.0-beta.3', prerelease: true),
|
||||
release('2.0.0-beta.4', prerelease: true, draft: true),
|
||||
{'tag_name': 'not-a-version'},
|
||||
release('2.0.0-beta.2', prerelease: true),
|
||||
], '2.0.0-beta.1');
|
||||
|
||||
expect(selected?.version, '2.0.0-beta.3');
|
||||
});
|
||||
});
|
||||
|
||||
group('release assets', () {
|
||||
test('uses the release naming convention for desktop packages', () {
|
||||
expect(
|
||||
updateAssetName('1.8.0', channel: UpdateChannel.windowsPortable, architecture: Architecture.x64),
|
||||
'Gopeed-v1.8.0-windows-amd64-portable.zip',
|
||||
);
|
||||
expect(
|
||||
updateAssetName('1.8.0', channel: UpdateChannel.macosDmg, architecture: Architecture.arm64),
|
||||
'Gopeed-v1.8.0-macos-arm64.dmg',
|
||||
);
|
||||
expect(
|
||||
updateAssetName('1.8.0', channel: UpdateChannel.linuxDeb, architecture: Architecture.ia32),
|
||||
'Gopeed-v1.8.0-linux-386.deb',
|
||||
);
|
||||
});
|
||||
|
||||
test('maps Android ABI names', () {
|
||||
expect(
|
||||
updateAssetName('1.8.0', channel: UpdateChannel.androidApk, architecture: Architecture.arm),
|
||||
'Gopeed-v1.8.0-android-armeabi-v7a.apk',
|
||||
);
|
||||
expect(
|
||||
updateAssetName('1.8.0', channel: UpdateChannel.androidApk, architecture: Architecture.arm64),
|
||||
'Gopeed-v1.8.0-android-arm64-v8a.apk',
|
||||
);
|
||||
});
|
||||
test('package names use the client channel and architecture', () {
|
||||
expect(appUpdateAssetName('2.0.0', UpdateChannel.windowsInstaller, 'ia32'), 'Gopeed-v2.0.0-windows-386.zip');
|
||||
expect(
|
||||
appUpdateAssetName('2.0.0-beta.3', UpdateChannel.windowsPortable, 'x64'),
|
||||
'Gopeed-v2.0.0-beta.3-windows-amd64-portable.zip',
|
||||
);
|
||||
expect(appUpdateAssetName('2.0.0', UpdateChannel.macosDmg, 'arm64'), 'Gopeed-v2.0.0-macos-arm64.dmg');
|
||||
expect(appUpdateAssetName('2.0.0', UpdateChannel.linuxDeb, 'x64'), 'Gopeed-v2.0.0-linux-amd64.deb');
|
||||
expect(appUpdateAssetName('2.0.0', UpdateChannel.androidApk, 'arm'), 'Gopeed-v2.0.0-android-armeabi-v7a.apk');
|
||||
expect(appUpdateAssetName('2.0.0', UpdateChannel.androidApk, 'arm64'), 'Gopeed-v2.0.0-android-arm64-v8a.apk');
|
||||
expect(appUpdateAssetName('2.0.0', UpdateChannel.androidApk, 'x64'), 'Gopeed-v2.0.0-android-x86_64.apk');
|
||||
expect(appUpdateAssetName('2.0.0', UpdateChannel.androidApk, 'riscv64'), 'Gopeed-v2.0.0-android.apk');
|
||||
for (final channel in [
|
||||
null,
|
||||
UpdateChannel.linuxFlathub,
|
||||
UpdateChannel.linuxSnap,
|
||||
UpdateChannel.linuxAppImage,
|
||||
UpdateChannel.linuxRpm,
|
||||
UpdateChannel.iosIpa,
|
||||
UpdateChannel.docker,
|
||||
]) {
|
||||
expect(appUpdateAssetName('2.0.0', channel, 'arm64'), isEmpty);
|
||||
}
|
||||
});
|
||||
|
||||
test('selects the matching bilingual release notes', () {
|
||||
@@ -127,23 +53,6 @@ void main() {
|
||||
expect(localizedReleaseNotes(notes, 'zh-CN'), '# 更新日志\n\n- 中文更新');
|
||||
});
|
||||
|
||||
test('builds enabled GitHub proxy URLs and ignores deleted mirrors', () {
|
||||
final config = ExtraConfigGithubMirror(
|
||||
mirrors: [
|
||||
GithubMirror(type: GithubMirrorType.ghProxy, url: 'https://mirror.example'),
|
||||
GithubMirror(type: GithubMirrorType.ghProxy, url: 'https://deleted.example', isDeleted: true),
|
||||
],
|
||||
);
|
||||
expect(
|
||||
githubMirrorUrls(
|
||||
'https://github.com/GopeedLab/gopeed/releases/download/v1.8.0/app.zip',
|
||||
MirrorType.githubRelease,
|
||||
config: config,
|
||||
),
|
||||
['https://mirror.example/https://github.com/GopeedLab/gopeed/releases/download/v1.8.0/app.zip'],
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('update dialog shows beta versions without tag prefixes', (tester) async {
|
||||
tester.view.physicalSize = const Size(390, 760);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
@@ -194,7 +103,7 @@ void main() {
|
||||
expect(find.text('Faster downloads'), findsOneWidget);
|
||||
expect(find.text('下载更快'), findsNothing);
|
||||
expect(find.text('Later'), findsOneWidget);
|
||||
expect(find.text('Update Now'), findsOneWidget);
|
||||
expect(find.text('Update'), findsOneWidget);
|
||||
expect(find.text("What's New"), findsNothing);
|
||||
final versionText = tester.widget<Text>(find.text('2.0.0-beta.1 → 2.0.0-beta.2'));
|
||||
expect(versionText.style?.decoration, TextDecoration.none);
|
||||
@@ -207,6 +116,50 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final width in [390.0, 1024.0]) {
|
||||
testWidgets('checksum failures prevent success and show a localized error at $width', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 760);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
var attempts = 0;
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Builder(
|
||||
builder: (context) => shad.SecondaryButton(
|
||||
onPressed: () => showAppUpdateDialog(
|
||||
context,
|
||||
versionInfo: const VersionInfo(
|
||||
version: '2.0.0',
|
||||
changeLog: '',
|
||||
releaseUrl: 'https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0',
|
||||
),
|
||||
onUpdate: (_, progress) async {
|
||||
attempts++;
|
||||
progress(100, 100);
|
||||
throw const DownloadChecksumException();
|
||||
},
|
||||
),
|
||||
child: const Text('Open update'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open update'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Update'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(attempts, 1);
|
||||
expect(find.text('Update verification failed. Installation stopped.'), findsOneWidget);
|
||||
expect(find.text('Update failed. Please try again later'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('release notes keep the last line above the fade when scrolled to the end', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
|
||||
@@ -2274,10 +2274,8 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('tracker subscriptions support selecting and clearing all entries', (WidgetTester tester) async {
|
||||
testWidgets('tracker subscriptions allow adding and deleting a custom URL', (WidgetTester tester) async {
|
||||
await _setTestSize(tester, const Size(1024, 900));
|
||||
tester.platformDispatcher.textScaleFactorTestValue = 1.2;
|
||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
final settingsController = FakeSettingsController();
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
@@ -2292,35 +2290,31 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Downloads'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final selectAll = find.byKey(const ValueKey('tracker-select-all'));
|
||||
expect(selectAll, findsOneWidget);
|
||||
expect(find.descendant(of: selectAll, matching: find.text('Select All')), findsOneWidget);
|
||||
|
||||
await tester.ensureVisible(selectAll);
|
||||
final add = find.byKey(const ValueKey('add-tracker-subscription'));
|
||||
await tester.ensureVisible(add);
|
||||
await tester.pumpAndSettle();
|
||||
final horizontalScrollbar = tester.widget<Scrollbar>(find.byKey(const ValueKey('tracker-horizontal-scrollbar')));
|
||||
expect(horizontalScrollbar.thumbVisibility, isTrue);
|
||||
expect(horizontalScrollbar.controller?.hasClients, isTrue);
|
||||
expect(horizontalScrollbar.controller!.position.maxScrollExtent, greaterThan(0));
|
||||
final verticalScrollbar = tester.widget<Scrollbar>(find.byKey(const ValueKey('tracker-vertical-scrollbar')));
|
||||
expect(verticalScrollbar.controller?.hasClients, isTrue);
|
||||
expect(verticalScrollbar.controller!.position.maxScrollExtent, greaterThan(0));
|
||||
final scrollbarRect = tester.getRect(find.byKey(const ValueKey('tracker-horizontal-scrollbar')));
|
||||
final scrollViewRect = tester.getRect(find.byKey(const ValueKey('tracker-horizontal-scroll-view')));
|
||||
expect(scrollbarRect.bottom - scrollViewRect.bottom, AppDesignTokens.space12);
|
||||
expect(find.text(allTrackerSubscribeUrls.first), findsOneWidget);
|
||||
horizontalScrollbar.controller!.jumpTo(horizontalScrollbar.controller!.position.maxScrollExtent);
|
||||
await tester.pump();
|
||||
expect(horizontalScrollbar.controller!.offset, greaterThan(0));
|
||||
|
||||
await tester.tap(selectAll);
|
||||
await tester.tap(add);
|
||||
await tester.pumpAndSettle();
|
||||
expect(settingsController.state.requireValue.config.extra.bt.trackerSubscribeUrls, allTrackerSubscribeUrls);
|
||||
|
||||
await tester.tap(selectAll);
|
||||
final dialog = find.byType(shad.AlertDialog);
|
||||
await tester.enterText(
|
||||
find.descendant(of: dialog, matching: find.byType(AppTextField)),
|
||||
'https://my.example/trackers.txt',
|
||||
);
|
||||
await tester.tap(find.descendant(of: dialog, matching: find.text('Save')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(settingsController.state.requireValue.config.extra.bt.trackerSubscribeUrls, isEmpty);
|
||||
expect(settingsController.state.requireValue.config.protocolConfig.bt.trackerSubscribeUrls, [
|
||||
'https://my.example/trackers.txt',
|
||||
]);
|
||||
expect(find.text('https://my.example/trackers.txt'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('edit-settings-list-https://my.example/trackers.txt')), findsNothing);
|
||||
final delete = find.byKey(const ValueKey('delete-settings-list-https://my.example/trackers.txt'));
|
||||
await tester.ensureVisible(delete);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(delete);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.descendant(of: find.byType(shad.AlertDialog), matching: find.text('Delete')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(settingsController.state.requireValue.config.protocolConfig.bt.trackerSubscribeUrls, isEmpty);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
@@ -2399,7 +2393,9 @@ void main() {
|
||||
expect(find.descendant(of: button, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('failed tracker update does not reload or reset the settings page', (WidgetTester tester) async {
|
||||
testWidgets('failed tracker update reloads subscription statuses without resetting the settings page', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await _setTestSize(tester, const Size(1024, 900));
|
||||
final runtimeController = FailingTrackerRuntimeController();
|
||||
final settingsController = StaticSettingsController();
|
||||
@@ -2428,7 +2424,7 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(settingsController.reloadCalls, 0);
|
||||
expect(settingsController.reloadCalls, 1);
|
||||
expect(tester.state<ScrollableState>(sectionScrollable).position.pixels, closeTo(offsetBeforeUpdate, 0.01));
|
||||
expect(find.descendant(of: updateButton, matching: find.byType(shad.CircularProgressIndicator)), findsNothing);
|
||||
expect(find.byKey(const ValueKey('app-toast-content')), findsOneWidget);
|
||||
@@ -5967,14 +5963,14 @@ class PendingTrackerRuntimeController extends FakeRuntimeController {
|
||||
final _trackerUpdateCompleter = Completer<void>();
|
||||
|
||||
@override
|
||||
Future<void> updateTrackers(DownloaderConfig config) => _trackerUpdateCompleter.future;
|
||||
Future<void> updateTrackers() => _trackerUpdateCompleter.future;
|
||||
|
||||
void completeTrackerUpdate() => _trackerUpdateCompleter.complete();
|
||||
}
|
||||
|
||||
class FailingTrackerRuntimeController extends FakeRuntimeController {
|
||||
@override
|
||||
Future<void> updateTrackers(DownloaderConfig config) async {
|
||||
Future<void> updateTrackers() async {
|
||||
throw StateError('tracker update failed');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user