feat(flutter): 接入分类记录收藏与下载授权

This commit is contained in:
Zhou Weidong committed 2026-10-17 14:49:16 +08:00
1 parent 00280ca0da
commit cc4d7c5a9d
52 files changed
+4440 -171

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/download_api.dart';
import '../support/fake_http_adapter.dart';
void main() {
String envelope(Object? data) =>
jsonEncode({'code': 0, 'message': 'ok', 'data': data});
Map<String, dynamic> licenseJson({int progress = 35}) => {
'licenseId': '2111012753262120962',
'dramaId': '2111012753262120963',
'dramaTitle': 'The Signal',
'episodeId': '2111012753262120964',
'episodeNo': 2,
'deviceId': 'device-9',
'quality': '720P',
'playUrl': '/files/episode.mp4?e=1&s=sig',
'licenseKey': 'license-key',
'status': 'ACTIVE',
'grantedAt': '2026-10-17T12:00:00',
'expireAt': '2026-11-16T12:00:00',
'taskStatus': 'DOWNLOADING',
'progressPercent': progress,
'downloadedBytes': '1024',
'totalBytes': '4096',
'errorMsg': '',
};
test(
'should_call_all_download_contracts_and_parse_string_ids_when_api_is_used',
() async {
final adapter = FakeHttpAdapter((options) async {
final path = options.uri.path;
if (path == '/app/downloads/policy') {
return FakeHttpResponse(
envelope({
'maxPerDevice': 20,
'licenseDays': 30,
'wifiOnly': true,
'maxQuality': 'AUTO',
'maxStorageBytes': '0',
'used': 1,
'remaining': 19,
'expirePolicyText': '30 days',
}),
);
}
if (path == '/app/downloads/settings') {
return FakeHttpResponse(envelope(null));
}
if (path == '/app/downloads' && options.method == 'DELETE') {
return FakeHttpResponse(envelope(2));
}
if (path == '/app/downloads') {
return FakeHttpResponse(
envelope(options.method == 'GET' ? [licenseJson()] : licenseJson()),
);
}
return FakeHttpResponse(envelope(licenseJson(progress: 80)));
});
final client = ApiClient(
baseUrl: 'http://127.0.0.1:8081',
adapter: adapter,
);
final api = DownloadApi(client);
final policy = await api.policy();
await api.updateSettings(wifiOnly: false, maxQuality: '1080P');
final granted = await api.grant('2111012753262120964');
final listed = await api.list();
final refreshed = await api.refreshUrl('2111012753262120964');
final progress = await api.reportProgress(
'2111012753262120964',
status: 'DOWNLOADING',
progressPercent: 80,
);
await api.remove('2111012753262120964');
final cleared = await api.clear();
expect(policy.remaining, 19);
expect(policy.expirePolicyText, '30 days');
expect(granted.licenseId, '2111012753262120962');
expect(granted.dramaId, '2111012753262120963');
expect(granted.episodeId, '2111012753262120964');
expect(listed.single.taskStatus, 'DOWNLOADING');
expect(refreshed.taskStatus, 'DOWNLOADING');
expect(progress.progressPercent, 80);
expect(cleared, 2);
expect(
adapter.requests.map(
(request) => '${request.method} ${request.uri.path}',
),
[
'GET /app/downloads/policy',
'PUT /app/downloads/settings',
'POST /app/downloads',
'GET /app/downloads',
'GET /app/downloads/2111012753262120964/url',
'POST /app/downloads/2111012753262120964/progress',
'DELETE /app/downloads/2111012753262120964',
'DELETE /app/downloads',
],
);
expect(adapter.requests[2].data, {'episodeId': '2111012753262120964'});
expect(adapter.requests[1].data, {
'wifiOnly': false,
'maxQuality': '1080P',
});
},
);
}
+178
View File
@@ -0,0 +1,178 @@
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/download_license.dart';
import 'package:fruitshorts/core/data/models/download_policy.dart';
import 'package:fruitshorts/core/data/remote/api_exception.dart';
import 'package:fruitshorts/core/data/repositories/download_repository.dart';
import 'package:fruitshorts/core/data/services/download_controller.dart';
DownloadPolicy _policy({bool wifiOnly = true, int remaining = 19}) =>
DownloadPolicy(
maxPerDevice: 20,
licenseDays: 30,
wifiOnly: wifiOnly,
maxQuality: 'AUTO',
maxStorageBytes: 0,
used: 20 - remaining,
remaining: remaining,
expirePolicyText: '30 days',
);
DownloadLicense _license(String episodeId) => DownloadLicense(
licenseId: 'license-$episodeId',
dramaId: 'drama-1',
dramaTitle: 'The Signal',
episodeId: episodeId,
episodeNo: 1,
deviceId: 'device-9',
quality: '720P',
playUrl: '',
licenseKey: '',
status: 'ACTIVE',
grantedAt: DateTime(2026, 10, 17),
expireAt: DateTime(2026, 11, 16),
taskStatus: 'QUEUED',
progressPercent: 0,
downloadedBytes: 0,
totalBytes: 100,
errorMsg: '',
);
void main() {
test('should_load_policy_and_licenses_when_page_opens', () async {
final repository = ScriptedDownloadRepository(
policy: _policy(),
licenses: [_license('1')],
);
final controller = DownloadController(repository);
await controller.load();
expect(controller.loading, isFalse);
expect(controller.error, isNull);
expect(controller.policy?.remaining, 19);
expect(controller.licenses.single.taskStatus, 'QUEUED');
});
test('should_show_backend_message_when_grant_is_rejected', () async {
final repository = ScriptedDownloadRepository(
policy: _policy(),
grantError: const ApiException(200003, 'download quota is full'),
);
final controller = DownloadController(repository);
final result = await controller.grant('episode-1');
expect(result.ok, isFalse);
expect(result.message, 'download quota is full');
});
test('should_rollback_wifi_setting_when_update_fails', () async {
final repository = ScriptedDownloadRepository(
policy: _policy(wifiOnly: true),
failSettings: true,
);
final controller = DownloadController(repository);
await controller.load();
final ok = await controller.updateSettings(wifiOnly: false);
expect(ok, isFalse);
expect(controller.policy?.wifiOnly, isTrue);
});
test('should_restore_license_when_delete_fails', () async {
final repository = ScriptedDownloadRepository(
policy: _policy(),
licenses: [_license('1'), _license('2')],
failRemove: true,
);
final controller = DownloadController(repository);
await controller.load();
final ok = await controller.remove('1');
expect(ok, isFalse);
expect(controller.licenses.map((item) => item.episodeId), ['1', '2']);
});
test('should_restore_licenses_when_clear_fails', () async {
final repository = ScriptedDownloadRepository(
policy: _policy(),
licenses: [_license('1'), _license('2')],
failClear: true,
);
final controller = DownloadController(repository);
await controller.load();
final ok = await controller.clear();
expect(ok, isFalse);
expect(controller.licenses, hasLength(2));
});
}
class ScriptedDownloadRepository implements DownloadRepository {
ScriptedDownloadRepository({
required this.policy,
this.licenses = const [],
this.grantError,
this.failSettings = false,
this.failRemove = false,
this.failClear = false,
});
DownloadPolicy policy;
List<DownloadLicense> licenses;
ApiException? grantError;
bool failSettings;
bool failRemove;
bool failClear;
@override
Future<DownloadPolicy> fetchPolicy() async => policy;
@override
Future<List<DownloadLicense>> list() async => licenses;
@override
Future<DownloadLicense> grant(String episodeId, {String? quality}) async {
if (grantError != null) throw grantError!;
return _license(episodeId);
}
@override
Future<void> updateSettings({bool? wifiOnly, String? maxQuality}) async {
if (failSettings) throw StateError('settings unavailable');
}
@override
Future<DownloadLicense> refreshUrl(String episodeId) async =>
_license(episodeId);
@override
Future<DownloadLicense> reportProgress(
String episodeId, {
String? deviceId,
String? status,
int? progressPercent,
int? downloadedBytes,
int? totalBytes,
String? errorMsg,
}) async => _license(episodeId);
@override
Future<void> remove(String episodeId) async {
if (failRemove) throw StateError('remove unavailable');
licenses = licenses.where((item) => item.episodeId != episodeId).toList();
}
@override
Future<int> clear() async {
if (failClear) throw StateError('clear unavailable');
final count = licenses.length;
licenses = const [];
return count;
}
}
+284
View File
@@ -0,0 +1,284 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/download_license.dart';
import 'package:fruitshorts/core/data/models/download_policy.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/auth_api.dart';
import 'package:fruitshorts/core/data/repositories/download_repository.dart';
import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/core/data/services/download_controller.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/downloads/downloads_page.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import '../support/fake_http_adapter.dart';
DownloadPolicy _policy({
bool wifiOnly = true,
String maxQuality = 'AUTO',
}) => DownloadPolicy(
maxPerDevice: 20,
licenseDays: 30,
wifiOnly: wifiOnly,
maxQuality: maxQuality,
maxStorageBytes: 0,
used: 1,
remaining: 19,
expirePolicyText: 'Licenses last 30 days',
);
DownloadLicense _license(String episodeId) => DownloadLicense(
licenseId: 'license-$episodeId',
dramaId: 'drama-1',
dramaTitle: 'The Signal',
episodeId: episodeId,
episodeNo: 2,
deviceId: 'device-9',
quality: '720P',
playUrl: '',
licenseKey: '',
status: 'ACTIVE',
grantedAt: DateTime(2026, 10, 17, 12),
expireAt: DateTime(2026, 11, 16, 12),
taskStatus: 'DOWNLOADING',
progressPercent: 35,
downloadedBytes: 1024,
totalBytes: 4096,
errorMsg: '',
);
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
testWidgets(
'should_render_policy_and_backend_status_values_when_page_loads',
(tester) async {
final auth = await _auth(signedIn: true);
final repository = FakeDownloadRepository(
policy: _policy(),
licenses: [_license('1')],
);
final controller = DownloadController(repository);
await _pumpPage(tester, auth: auth, controller: controller);
expect(find.byKey(const Key('downloads-page')), findsOneWidget);
expect(find.byKey(const Key('download-policy')), findsOneWidget);
expect(find.text('Licenses last 30 days'), findsOneWidget);
expect(find.byKey(const Key('download-license-1')), findsOneWidget);
expect(find.textContaining('ACTIVE'), findsOneWidget);
expect(find.textContaining('DOWNLOADING'), findsOneWidget);
expect(find.textContaining('35%'), findsOneWidget);
},
);
testWidgets('should_show_empty_state_when_license_list_is_empty', (
tester,
) async {
final auth = await _auth(signedIn: true);
final repository = FakeDownloadRepository(policy: _policy());
final controller = DownloadController(repository);
await _pumpPage(tester, auth: auth, controller: controller);
expect(find.byKey(const Key('downloads-empty')), findsOneWidget);
});
testWidgets('should_show_retry_when_policy_or_list_load_fails', (
tester,
) async {
final auth = await _auth(signedIn: true);
final repository = FakeDownloadRepository(
policy: _policy(),
failLoad: true,
);
final controller = DownloadController(repository);
await _pumpPage(tester, auth: auth, controller: controller);
expect(find.byKey(const Key('downloads-error')), findsOneWidget);
expect(find.byKey(const Key('downloads-retry')), findsOneWidget);
});
testWidgets('should_show_auth_gate_without_loading_when_signed_out', (
tester,
) async {
final auth = await _auth(signedIn: false);
final repository = FakeDownloadRepository(policy: _policy());
final controller = DownloadController(repository);
await _pumpPage(tester, auth: auth, controller: controller);
expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget);
expect(repository.loadCalls, 0);
});
testWidgets('should_update_wifi_preference_when_switch_is_tapped', (
tester,
) async {
final auth = await _auth(signedIn: true);
final repository = FakeDownloadRepository(policy: _policy());
final controller = DownloadController(repository);
await _pumpPage(tester, auth: auth, controller: controller);
await tester.tap(find.byKey(const Key('download-wifi-switch')));
await tester.pumpAndSettle();
expect(repository.settingsCalls, 1);
expect(repository.lastWifiOnly, isFalse);
});
testWidgets('should_clear_authorizations_only_after_confirmation', (
tester,
) async {
final auth = await _auth(signedIn: true);
final repository = FakeDownloadRepository(
policy: _policy(),
licenses: [_license('1')],
);
final controller = DownloadController(repository);
await _pumpPage(tester, auth: auth, controller: controller);
await tester.tap(find.byKey(const Key('download-clear')));
await tester.pumpAndSettle();
expect(repository.clearCalls, 0);
await tester.tap(find.widgetWithText(FilledButton, 'Clear'));
await tester.pumpAndSettle();
expect(repository.clearCalls, 1);
expect(find.byKey(const Key('downloads-empty')), findsOneWidget);
});
testWidgets('should_render_unique_quality_options_when_policy_uses_a_preset', (
tester,
) async {
final auth = await _auth(signedIn: true);
final repository = FakeDownloadRepository(
policy: _policy(maxQuality: '720P'),
);
final controller = DownloadController(repository);
await _pumpPage(tester, auth: auth, controller: controller);
expect(tester.takeException(), isNull);
expect(find.byKey(const Key('download-quality')), findsOneWidget);
});
}
Future<AuthController> _auth({required bool signedIn}) async {
if (signedIn) {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.withData({
'auth.accessToken': 'test-access',
'auth.refreshToken': 'test-refresh',
'auth.user': '{"uid":"1","nickname":"Viewer","avatarUrl":""}',
});
}
final client = ApiClient(
baseUrl: 'http://127.0.0.1:8081',
adapter: FakeHttpAdapter(
(_) async => FakeHttpResponse('{"code":0,"message":"ok","data":null}'),
),
);
final auth = AuthController(AuthApi(client), AuthSessionStore());
await auth.load();
return auth;
}
Future<void> _pumpPage(
WidgetTester tester, {
required AuthController auth,
required DownloadController controller,
}) async {
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<AuthController>.value(value: auth),
ChangeNotifierProvider<DownloadController>.value(value: controller),
],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const DownloadsPage(),
),
),
);
await tester.pumpAndSettle();
}
class FakeDownloadRepository implements DownloadRepository {
FakeDownloadRepository({
required this.policy,
this.licenses = const [],
this.failLoad = false,
});
DownloadPolicy policy;
List<DownloadLicense> licenses;
bool failLoad;
int loadCalls = 0;
int settingsCalls = 0;
int clearCalls = 0;
bool? lastWifiOnly;
@override
Future<DownloadPolicy> fetchPolicy() async {
loadCalls++;
if (failLoad) throw StateError('offline');
return policy;
}
@override
Future<List<DownloadLicense>> list() async {
if (failLoad) throw StateError('offline');
return licenses;
}
@override
Future<DownloadLicense> grant(String episodeId, {String? quality}) async =>
_license(episodeId);
@override
Future<void> updateSettings({bool? wifiOnly, String? maxQuality}) async {
settingsCalls++;
lastWifiOnly = wifiOnly;
}
@override
Future<DownloadLicense> refreshUrl(String episodeId) async =>
_license(episodeId);
@override
Future<DownloadLicense> reportProgress(
String episodeId, {
String? deviceId,
String? status,
int? progressPercent,
int? downloadedBytes,
int? totalBytes,
String? errorMsg,
}) async => _license(episodeId);
@override
Future<void> remove(String episodeId) async {
licenses = licenses.where((item) => item.episodeId != episodeId).toList();
}
@override
Future<int> clear() async {
clearCalls++;
final count = licenses.length;
licenses = const [];
return count;
}
}