feat(flutter): 接入分类记录收藏与下载授权
This commit is contained in:
1 parent
00280ca0da
commit
cc4d7c5a9d
52 files changed
+4440
-171
No files matched your search
@@ -0,0 +1,179 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/content_category.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/remote/category_api.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/category_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/category_controller.dart';
|
||||
|
||||
import '../support/fake_http_adapter.dart';
|
||||
|
||||
Drama _drama(String id) => Drama(
|
||||
id: id,
|
||||
title: 'Drama $id',
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '/files/$id.png',
|
||||
);
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'should_keep_snowflake_category_id_as_string_when_api_returns_list',
|
||||
() async {
|
||||
const id = '2111012753262120962';
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': [
|
||||
{'id': id, 'name': 'Romance'},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
final client = ApiClient(
|
||||
baseUrl: 'http://127.0.0.1:8081',
|
||||
adapter: adapter,
|
||||
);
|
||||
|
||||
final categories = await CategoryApi(client).list();
|
||||
|
||||
expect(categories.single.id, id);
|
||||
expect(categories.single.name, 'Romance');
|
||||
expect(adapter.requests.single.uri.path, '/app/categories');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should_map_category_id_to_discover_section_when_category_is_selected',
|
||||
() async {
|
||||
final repository = ScriptedCategoryRepository(
|
||||
categories: const [
|
||||
ContentCategory(id: '7', name: 'Romance'),
|
||||
ContentCategory(id: '8', name: 'Action'),
|
||||
],
|
||||
feed: HomeFeed(
|
||||
sections: [
|
||||
FeedSection(code: 'hot', title: 'Hot', dramas: [_drama('hot-1')]),
|
||||
FeedSection(code: 'new', title: 'New', dramas: [_drama('new-1')]),
|
||||
FeedSection(
|
||||
code: 'category-7',
|
||||
title: 'Romance',
|
||||
dramas: [_drama('romance-1')],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final controller = CategoryController(repository);
|
||||
|
||||
await controller.load();
|
||||
controller.selectCategory('7');
|
||||
|
||||
expect(controller.selectedCode, 'category-7');
|
||||
expect(controller.visibleDramas.map((drama) => drama.id), ['romance-1']);
|
||||
expect(controller.selectedSectionMissing, isFalse);
|
||||
|
||||
controller.selectCategory('8');
|
||||
expect(controller.selectedCode, 'category-8');
|
||||
expect(controller.visibleDramas, isEmpty);
|
||||
expect(controller.selectedSectionMissing, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should_keep_hot_and_new_entries_when_discover_feed_provides_them',
|
||||
() async {
|
||||
final repository = ScriptedCategoryRepository(
|
||||
feed: HomeFeed(
|
||||
sections: [
|
||||
FeedSection(code: 'hot', title: 'Hot', dramas: [_drama('hot-1')]),
|
||||
FeedSection(code: 'new', title: 'New', dramas: [_drama('new-1')]),
|
||||
FeedSection(
|
||||
code: 'category-7',
|
||||
title: 'Romance',
|
||||
dramas: [_drama('romance-1')],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final controller = CategoryController(repository);
|
||||
|
||||
await controller.load();
|
||||
controller.selectCode(CategoryController.hotCode);
|
||||
expect(controller.visibleDramas.map((drama) => drama.id), ['hot-1']);
|
||||
|
||||
controller.selectCode(CategoryController.newCode);
|
||||
expect(controller.visibleDramas.map((drama) => drama.id), ['new-1']);
|
||||
},
|
||||
);
|
||||
|
||||
test('should_dedupe_dramas_when_all_categories_are_selected', () async {
|
||||
final repository = ScriptedCategoryRepository(
|
||||
feed: HomeFeed(
|
||||
sections: [
|
||||
FeedSection(code: 'hot', title: 'Hot', dramas: [_drama('same')]),
|
||||
FeedSection(
|
||||
code: 'category-7',
|
||||
title: 'Romance',
|
||||
dramas: [_drama('same'), _drama('other')],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final controller = CategoryController(repository);
|
||||
|
||||
await controller.load();
|
||||
|
||||
expect(controller.selectedCode, CategoryController.allCode);
|
||||
expect(controller.visibleDramas.map((drama) => drama.id), [
|
||||
'same',
|
||||
'other',
|
||||
]);
|
||||
});
|
||||
|
||||
test('should_clear_error_and_retry_when_initial_load_fails', () async {
|
||||
final repository = ScriptedCategoryRepository(fail: true);
|
||||
final controller = CategoryController(repository);
|
||||
|
||||
await controller.load();
|
||||
expect(controller.error, 'categoriesError');
|
||||
expect(controller.loading, isFalse);
|
||||
|
||||
repository.fail = false;
|
||||
await controller.load();
|
||||
|
||||
expect(controller.error, isNull);
|
||||
expect(repository.loads, 2);
|
||||
});
|
||||
}
|
||||
|
||||
class ScriptedCategoryRepository implements CategoryRepository {
|
||||
ScriptedCategoryRepository({
|
||||
this.categories = const [],
|
||||
this.feed = const HomeFeed(),
|
||||
this.fail = false,
|
||||
});
|
||||
|
||||
List<ContentCategory> categories;
|
||||
HomeFeed feed;
|
||||
bool fail;
|
||||
int loads = 0;
|
||||
|
||||
@override
|
||||
Future<List<ContentCategory>> fetchCategories() async {
|
||||
loads++;
|
||||
if (fail) throw StateError('offline');
|
||||
return categories;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() async {
|
||||
if (fail) throw StateError('offline');
|
||||
return feed;
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,6 @@ void main() {
|
||||
|
||||
expect(await logic.position, const Duration(seconds: 4));
|
||||
expect(history.reads, ['signal']);
|
||||
expect(logic.category, 0);
|
||||
expect(logic.searchOpen, isFalse);
|
||||
logic.dispose();
|
||||
});
|
||||
@@ -78,18 +77,6 @@ void main() {
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_store_index_and_notify_once_when_category_is_selected', () {
|
||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
||||
var notifications = 0;
|
||||
logic.addListener(() => notifications++);
|
||||
|
||||
logic.selectCategory(3);
|
||||
|
||||
expect(logic.category, 3);
|
||||
expect(notifications, 1);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_show_search_when_flag_is_on_or_text_is_typed', () {
|
||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -77,18 +77,33 @@ void main() {
|
||||
final repository = InMemoryFavoriteRepository(
|
||||
saved: {'signal'},
|
||||
cards: [_card('signal')],
|
||||
);
|
||||
)..failRemove = true;
|
||||
final controller = FavoriteController(repository);
|
||||
await controller.load();
|
||||
|
||||
final ok = await controller.remove('signal');
|
||||
|
||||
// InMemory 实现的 remove 不会失败,这里断言正常路径:状态与列表一起清掉
|
||||
expect(ok, isTrue);
|
||||
expect(controller.isSaved('signal'), isFalse);
|
||||
expect(controller.cards, isEmpty);
|
||||
expect(ok, isFalse);
|
||||
expect(controller.isSaved('signal'), isTrue);
|
||||
expect(controller.cards.map((drama) => drama.id), ['signal']);
|
||||
});
|
||||
|
||||
test(
|
||||
'should_restore_removed_card_at_original_position_when_remove_fails',
|
||||
() async {
|
||||
final repository = InMemoryFavoriteRepository(
|
||||
saved: {'signal', 'city'},
|
||||
cards: [_card('signal'), _card('city')],
|
||||
)..failRemove = true;
|
||||
final controller = FavoriteController(repository);
|
||||
await controller.load();
|
||||
|
||||
await controller.remove('signal');
|
||||
|
||||
expect(controller.cards.map((drama) => drama.id), ['signal', 'city']);
|
||||
},
|
||||
);
|
||||
|
||||
test('should_clear_state_when_signing_out', () async {
|
||||
final controller = FavoriteController(
|
||||
InMemoryFavoriteRepository(saved: {'signal'}, cards: [_card('signal')]),
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.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/favorite_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/favorite_controller.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/favorites/favorites_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';
|
||||
|
||||
Drama _card(String id) => Drama(
|
||||
id: id,
|
||||
title: 'Drama $id',
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '/files/$id.png',
|
||||
totalEpisodes: 12,
|
||||
);
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
});
|
||||
|
||||
testWidgets('should_render_favorite_cards_and_visible_remove_action', (
|
||||
tester,
|
||||
) async {
|
||||
final auth = await _auth(signedIn: true);
|
||||
final favorites = FavoriteController(
|
||||
InMemoryFavoriteRepository(
|
||||
saved: {'signal', 'city'},
|
||||
cards: [_card('signal'), _card('city')],
|
||||
),
|
||||
);
|
||||
|
||||
await _pumpPage(tester, auth: auth, favorites: favorites);
|
||||
|
||||
expect(find.text('Drama signal'), findsOneWidget);
|
||||
expect(find.text('Drama city'), findsOneWidget);
|
||||
expect(find.byKey(const Key('favorite-remove-signal')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_keep_card_and_show_failure_when_remove_fails', (
|
||||
tester,
|
||||
) async {
|
||||
final auth = await _auth(signedIn: true);
|
||||
final repository = InMemoryFavoriteRepository(
|
||||
saved: {'signal'},
|
||||
cards: [_card('signal')],
|
||||
)..failRemove = true;
|
||||
final favorites = FavoriteController(repository);
|
||||
|
||||
await _pumpPage(tester, auth: auth, favorites: favorites);
|
||||
await tester.tap(find.byKey(const Key('favorite-remove-signal')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Drama signal'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Could not update favorites. Please retry.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('should_show_empty_state_when_favorite_list_is_empty', (
|
||||
tester,
|
||||
) async {
|
||||
final auth = await _auth(signedIn: true);
|
||||
final favorites = FavoriteController(InMemoryFavoriteRepository());
|
||||
|
||||
await _pumpPage(tester, auth: auth, favorites: favorites);
|
||||
|
||||
expect(find.byKey(const Key('favorites-empty')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_show_retry_when_favorite_list_fails', (tester) async {
|
||||
final auth = await _auth(signedIn: true);
|
||||
final repository = InMemoryFavoriteRepository()..failList = true;
|
||||
final favorites = FavoriteController(repository);
|
||||
|
||||
await _pumpPage(tester, auth: auth, favorites: favorites);
|
||||
|
||||
expect(find.byKey(const Key('favorites-error')), findsOneWidget);
|
||||
expect(find.byKey(const Key('favorites-retry')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'should_show_auth_gate_without_loading_favorites_when_signed_out',
|
||||
(tester) async {
|
||||
final auth = await _auth(signedIn: false);
|
||||
final repository = InMemoryFavoriteRepository(
|
||||
saved: {'signal'},
|
||||
cards: [_card('signal')],
|
||||
);
|
||||
final favorites = FavoriteController(repository);
|
||||
|
||||
await _pumpPage(tester, auth: auth, favorites: favorites);
|
||||
|
||||
expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget);
|
||||
expect(repository.listCalls, 0);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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 FavoriteController favorites,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<AuthController>.value(value: auth),
|
||||
ChangeNotifierProvider<FavoriteController>.value(value: favorites),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const FavoritesPage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:fruitshorts/core/data/models/content_category.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/category_repository.dart';
|
||||
|
||||
/// 分类测试仓库:分类清单固定,发现 feed 复用注入的目录仓库。
|
||||
class FakeCategoryRepository implements CategoryRepository {
|
||||
FakeCategoryRepository({
|
||||
required this.catalog,
|
||||
this.categories = const [ContentCategory(id: '7', name: 'Romance')],
|
||||
});
|
||||
|
||||
final CatalogRepository catalog;
|
||||
final List<ContentCategory> categories;
|
||||
|
||||
@override
|
||||
Future<List<ContentCategory>> fetchCategories() async => categories;
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() => catalog.fetchDiscoverFeed();
|
||||
}
|
||||
@@ -64,4 +64,10 @@ class FakeWatchHistory implements WatchHistoryRepository {
|
||||
|
||||
@override
|
||||
Future<void> remove(String dramaId) async => records.remove(dramaId);
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
records.clear();
|
||||
recentRecords = const [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/data/models/watch_progress.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/watch_records_controller.dart';
|
||||
|
||||
WatchProgress _progress(
|
||||
String dramaId, {
|
||||
String episodeId = 'episode-1',
|
||||
int positionMs = 12000,
|
||||
int durationMs = 60000,
|
||||
}) => WatchProgress(
|
||||
dramaId: dramaId,
|
||||
episodeId: episodeId,
|
||||
position: Duration(milliseconds: positionMs),
|
||||
duration: Duration(milliseconds: durationMs),
|
||||
updatedAt: DateTime(2026, 10, 17, 12),
|
||||
);
|
||||
|
||||
Drama _detail(String id) => Drama(
|
||||
id: id,
|
||||
title: 'Drama $id',
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '/files/$id.png',
|
||||
detailLoaded: true,
|
||||
episodes: const [
|
||||
Episode(id: 'episode-1', number: 1, title: 'Episode 1', isFree: true),
|
||||
],
|
||||
);
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'should_parse_updated_time_and_keep_string_ids_when_recent_json_arrives',
|
||||
() {
|
||||
final progress = WatchProgress.fromJson({
|
||||
'dramaId': '2111012753262120962',
|
||||
'episodeId': '2111012753262120963',
|
||||
'positionMs': '12000',
|
||||
'durationMs': '60000',
|
||||
'updatedAt': '2026-10-17T12:34:56',
|
||||
});
|
||||
|
||||
expect(progress.dramaId, '2111012753262120962');
|
||||
expect(progress.episodeId, '2111012753262120963');
|
||||
expect(progress.updatedAt, DateTime(2026, 10, 17, 12, 34, 56));
|
||||
},
|
||||
);
|
||||
|
||||
test('should_dedupe_detail_requests_and_cap_concurrency_at_four', () async {
|
||||
final history = ControlledWatchHistory([
|
||||
for (var index = 0; index < 6; index++) _progress('$index'),
|
||||
_progress('0'),
|
||||
]);
|
||||
final catalog = ScriptedWatchCatalog();
|
||||
final controller = WatchRecordsController(
|
||||
history: history,
|
||||
catalog: catalog,
|
||||
);
|
||||
|
||||
await controller.load();
|
||||
|
||||
expect(controller.records, hasLength(6));
|
||||
expect(catalog.calls, hasLength(6));
|
||||
expect(catalog.maxActive, 4);
|
||||
expect(controller.error, isNull);
|
||||
});
|
||||
|
||||
test('should_keep_other_records_when_one_detail_request_fails', () async {
|
||||
final history = ControlledWatchHistory([_progress('1'), _progress('2')]);
|
||||
final catalog = ScriptedWatchCatalog(failedIds: {'1'});
|
||||
final controller = WatchRecordsController(
|
||||
history: history,
|
||||
catalog: catalog,
|
||||
);
|
||||
|
||||
await controller.load();
|
||||
|
||||
expect(controller.records, hasLength(2));
|
||||
expect(controller.records.first.detailFailed, isTrue);
|
||||
expect(controller.records.first.drama, isNull);
|
||||
expect(controller.records.last.drama?.id, '2');
|
||||
|
||||
catalog.failedIds.clear();
|
||||
await controller.retryDetail('1');
|
||||
expect(controller.records.first.detailFailed, isFalse);
|
||||
expect(controller.records.first.drama?.id, '1');
|
||||
});
|
||||
|
||||
test(
|
||||
'should_remove_record_optimistically_and_restore_it_when_delete_fails',
|
||||
() async {
|
||||
final history = ControlledWatchHistory([_progress('1'), _progress('2')]);
|
||||
final controller = WatchRecordsController(
|
||||
history: history,
|
||||
catalog: ScriptedWatchCatalog(),
|
||||
);
|
||||
await controller.load();
|
||||
final gate = Completer<void>();
|
||||
history.removeGate = gate;
|
||||
history.failRemove = true;
|
||||
|
||||
final pending = controller.remove('1');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(controller.records.map((record) => record.dramaId), ['2']);
|
||||
|
||||
gate.complete();
|
||||
expect(await pending, isFalse);
|
||||
expect(controller.records.map((record) => record.dramaId), ['1', '2']);
|
||||
expect(controller.actionError, 'recordDeleteFailed');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should_clear_records_optimistically_and_restore_them_when_clear_fails',
|
||||
() async {
|
||||
final history = ControlledWatchHistory([_progress('1'), _progress('2')]);
|
||||
final controller = WatchRecordsController(
|
||||
history: history,
|
||||
catalog: ScriptedWatchCatalog(),
|
||||
);
|
||||
await controller.load();
|
||||
final gate = Completer<void>();
|
||||
history.clearGate = gate;
|
||||
history.failClear = true;
|
||||
|
||||
final pending = controller.clear();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(controller.records, isEmpty);
|
||||
|
||||
gate.complete();
|
||||
expect(await pending, isFalse);
|
||||
expect(controller.records.map((record) => record.dramaId), ['1', '2']);
|
||||
expect(controller.actionError, 'recordClearFailed');
|
||||
},
|
||||
);
|
||||
|
||||
test('should_expose_retryable_error_when_recent_request_fails', () async {
|
||||
final history = ControlledWatchHistory([])..failRecent = true;
|
||||
final controller = WatchRecordsController(
|
||||
history: history,
|
||||
catalog: ScriptedWatchCatalog(),
|
||||
);
|
||||
|
||||
await controller.load();
|
||||
|
||||
expect(controller.loading, isFalse);
|
||||
expect(controller.error, 'progressError');
|
||||
expect(controller.records, isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
class ControlledWatchHistory implements WatchHistoryRepository {
|
||||
ControlledWatchHistory(this.items);
|
||||
|
||||
final List<WatchProgress> items;
|
||||
bool failRecent = false;
|
||||
bool failRemove = false;
|
||||
bool failClear = false;
|
||||
Completer<void>? removeGate;
|
||||
Completer<void>? clearGate;
|
||||
|
||||
@override
|
||||
Future<WatchProgress?> progressFor(String dramaId) async => null;
|
||||
|
||||
@override
|
||||
Future<WatchProgress?> save({
|
||||
required String episodeId,
|
||||
required Duration position,
|
||||
Duration? duration,
|
||||
bool finished = false,
|
||||
}) async => null;
|
||||
|
||||
@override
|
||||
Future<List<WatchProgress>> recent({int limit = 20}) async {
|
||||
if (failRecent) throw StateError('recent unavailable');
|
||||
return items.take(limit).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove(String dramaId) async {
|
||||
if (removeGate != null) await removeGate!.future;
|
||||
if (failRemove) throw StateError('remove unavailable');
|
||||
items.removeWhere((item) => item.dramaId == dramaId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
if (clearGate != null) await clearGate!.future;
|
||||
if (failClear) throw StateError('clear unavailable');
|
||||
items.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class ScriptedWatchCatalog implements CatalogRepository {
|
||||
ScriptedWatchCatalog({Set<String>? failedIds})
|
||||
: failedIds = failedIds ?? <String>{};
|
||||
|
||||
final Set<String> failedIds;
|
||||
final List<String> calls = [];
|
||||
int _active = 0;
|
||||
int maxActive = 0;
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchHomeFeed() async => const HomeFeed();
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() async => const HomeFeed();
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) async {
|
||||
calls.add(dramaId);
|
||||
_active++;
|
||||
if (_active > maxActive) maxActive = _active;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
_active--;
|
||||
if (failedIds.contains(dramaId)) throw StateError('detail unavailable');
|
||||
return _detail(dramaId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/data/models/watch_progress.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/catalog_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/auth_controller.dart';
|
||||
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/watch_records/watch_records_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_catalog.dart';
|
||||
import '../support/fake_http_adapter.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
});
|
||||
|
||||
testWidgets('should_render_real_record_details_when_recent_history_loads', (
|
||||
tester,
|
||||
) async {
|
||||
final auth = await _auth(signedIn: true);
|
||||
final catalog = FakeCatalogRepository();
|
||||
final history = CountingWatchHistory([
|
||||
WatchProgress(
|
||||
dramaId: 'the-signal-demo',
|
||||
episodeId: 'the-signal-demo-1',
|
||||
position: const Duration(seconds: 12),
|
||||
duration: const Duration(seconds: 60),
|
||||
updatedAt: DateTime(2026, 10, 17, 12, 34),
|
||||
),
|
||||
]);
|
||||
|
||||
await _pumpPage(tester, auth: auth, history: history, catalog: catalog);
|
||||
|
||||
expect(find.byKey(const Key('watch-records-page')), findsOneWidget);
|
||||
expect(find.text('The Signal'), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('watch-record-episode-the-signal-demo')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'should_show_failed_placeholder_and_keep_other_cards_when_detail_fails',
|
||||
(tester) async {
|
||||
final auth = await _auth(signedIn: true);
|
||||
final history = CountingWatchHistory([
|
||||
_progress('broken'),
|
||||
_progress('the-signal-demo'),
|
||||
]);
|
||||
|
||||
await _pumpPage(
|
||||
tester,
|
||||
auth: auth,
|
||||
history: history,
|
||||
catalog: _FailingWatchCatalog(),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.byKey(const Key('watch-record-failed-broken')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('The Signal'), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('watch-record-retry-broken')),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('should_show_auth_gate_without_loading_records_when_signed_out', (
|
||||
tester,
|
||||
) async {
|
||||
final auth = await _auth(signedIn: false);
|
||||
final history = CountingWatchHistory([_progress('the-signal-demo')]);
|
||||
|
||||
await _pumpPage(
|
||||
tester,
|
||||
auth: auth,
|
||||
history: history,
|
||||
catalog: FakeCatalogRepository(),
|
||||
);
|
||||
|
||||
expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget);
|
||||
expect(history.recentCalls, 0);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'should_clear_records_only_after_confirmation_when_clear_is_tapped',
|
||||
(tester) async {
|
||||
final auth = await _auth(signedIn: true);
|
||||
final history = CountingWatchHistory([_progress('the-signal-demo')]);
|
||||
|
||||
await _pumpPage(
|
||||
tester,
|
||||
auth: auth,
|
||||
history: history,
|
||||
catalog: FakeCatalogRepository(),
|
||||
);
|
||||
await tester.tap(find.byKey(const Key('watch-records-clear')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('watch-records-clear')), findsOneWidget);
|
||||
expect(history.clearCalls, 0);
|
||||
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Clear'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(history.clearCalls, 1);
|
||||
expect(find.text('No watch history yet.'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
WatchProgress _progress(String dramaId) => WatchProgress(
|
||||
dramaId: dramaId,
|
||||
episodeId: '$dramaId-1',
|
||||
position: const Duration(seconds: 12),
|
||||
duration: const Duration(seconds: 60),
|
||||
updatedAt: DateTime(2026, 10, 17, 12),
|
||||
);
|
||||
|
||||
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 WatchHistoryRepository history,
|
||||
required CatalogRepository catalog,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<AuthController>.value(value: auth),
|
||||
Provider<WatchHistoryRepository>.value(value: history),
|
||||
Provider<CatalogRepository>.value(value: catalog),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const WatchRecordsPage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
class CountingWatchHistory extends InMemoryWatchHistoryRepository {
|
||||
CountingWatchHistory(this.visibleRecords);
|
||||
|
||||
final List<WatchProgress> visibleRecords;
|
||||
int recentCalls = 0;
|
||||
int clearCalls = 0;
|
||||
|
||||
@override
|
||||
Future<List<WatchProgress>> recent({int limit = 20}) async {
|
||||
recentCalls++;
|
||||
return visibleRecords.take(limit).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
clearCalls++;
|
||||
await super.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class _FailingWatchCatalog implements CatalogRepository {
|
||||
final _delegate = FakeCatalogRepository();
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchHomeFeed() => _delegate.fetchHomeFeed();
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() => _delegate.fetchDiscoverFeed();
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) async {
|
||||
if (dramaId == 'broken') throw StateError('missing detail');
|
||||
return _delegate.fetchDrama(dramaId);
|
||||
}
|
||||
}
|
||||
+14
-9
@@ -9,6 +9,7 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
import 'support/fake_catalog.dart';
|
||||
import 'support/fake_category.dart';
|
||||
import 'support/widget_player.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
||||
@@ -39,15 +40,19 @@ void main() {
|
||||
Widget appUnderTest({
|
||||
PlayerFactory? playerFactory,
|
||||
CatalogRepository? catalogRepository,
|
||||
}) => LumaDramaApp(
|
||||
playerFactory: playerFactory ?? createWidgetPlayer,
|
||||
catalogRepository: catalogRepository ?? FakeCatalogRepository(),
|
||||
watchHistoryRepository: InMemoryWatchHistoryRepository(),
|
||||
favoriteRepository: InMemoryFavoriteRepository(
|
||||
cards: FakeCatalogRepository().dramas,
|
||||
),
|
||||
playbackRepository: FakePlaybackRepository(),
|
||||
);
|
||||
}) {
|
||||
final catalog = catalogRepository ?? FakeCatalogRepository();
|
||||
return LumaDramaApp(
|
||||
playerFactory: playerFactory ?? createWidgetPlayer,
|
||||
catalogRepository: catalog,
|
||||
categoryRepository: FakeCategoryRepository(catalog: catalog),
|
||||
watchHistoryRepository: InMemoryWatchHistoryRepository(),
|
||||
favoriteRepository: InMemoryFavoriteRepository(
|
||||
cards: FakeCatalogRepository().dramas,
|
||||
),
|
||||
playbackRepository: FakePlaybackRepository(),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'should_promote_next_without_recreating_it_or_showing_loading_when_home_is_swiped',
|
||||
|
||||
Reference in new issue
Block a user