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

@@ -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);
}
}
+212
View File
@@ -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);
}
}