// 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 licenses; ApiException? grantError; bool failSettings; bool failRemove; bool failClear; @override Future fetchPolicy() async => policy; @override Future> list() async => licenses; @override Future grant(String episodeId, {String? quality}) async { if (grantError != null) throw grantError!; return _license(episodeId); } @override Future updateSettings({bool? wifiOnly, String? maxQuality}) async { if (failSettings) throw StateError('settings unavailable'); } @override Future refreshUrl(String episodeId) async => _license(episodeId); @override Future reportProgress( String episodeId, { String? deviceId, String? status, int? progressPercent, int? downloadedBytes, int? totalBytes, String? errorMsg, }) async => _license(episodeId); @override Future remove(String episodeId) async { if (failRemove) throw StateError('remove unavailable'); licenses = licenses.where((item) => item.episodeId != episodeId).toList(); } @override Future clear() async { if (failClear) throw StateError('clear unavailable'); final count = licenses.length; licenses = const []; return count; } }