Files
ReelShort/test/following/following_page_test.dart

696 lines
23 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/page_result.dart';
import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_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/theme/app_colors.dart';
import 'package:fruitshorts/core/theme/app_dimens.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.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_category.dart';
import '../support/fake_interaction_repositories.dart';
import '../support/widget_player.dart';
void main() {
testWidgets('should_defer_watch_history_until_following_tab_is_opened', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final catalog = FakeCatalogRepository();
final history = _IdentityWatchHistoryRepository(
device: [_historyProgress('the-signal-demo', 'Device history')],
account: const [],
);
await tester.pumpWidget(_app(catalog: catalog, history: history));
await tester.pumpAndSettle();
// 首页默认停在推荐 tab:追剧页在启动时还没被构建,不该提前拉观看历史。
expect(history.recentCalls, 0);
await tester.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle();
expect(history.recentCalls, greaterThan(0));
});
testWidgets('should_render_following_tabs_grid_and_edit_actions', (
tester,
) async {
_signIn();
final catalog = FakeCatalogRepository();
await tester.pumpWidget(
_app(
catalog: catalog,
favorites: InMemoryFavoriteRepository(
cards: catalog.dramas.take(1).toList(),
saved: {catalog.dramas.first.id},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('following-page')), findsOneWidget);
expect(find.byKey(const Key('following-tab-favorites')), findsOneWidget);
expect(find.byKey(const Key('following-tab-history')), findsOneWidget);
expect(find.byKey(const Key('following-tab-reserve')), findsNothing);
expect(find.byKey(const Key('following-favorites-grid')), findsOneWidget);
expect(find.byKey(const Key('following-add-card')), findsOneWidget);
expect(find.byType(CustomScrollView), findsWidgets);
final tab = tester.widget<AnimatedDefaultTextStyle>(
find.descendant(
of: find.byKey(const Key('following-tab-favorites')),
matching: find.byType(AnimatedDefaultTextStyle),
),
);
expect(tab.style.fontSize, AppDimens.followingSegmentFont);
expect(tab.style.fontWeight, FontWeight.w400);
expect(tab.style.color, AppColors.textPrimary);
// 新版头部右上角是「Manage」文字按钮,不再是编辑图标。
final manage = tester.widget<Text>(
find.descendant(
of: find.byKey(const Key('following-edit')),
matching: find.byType(Text),
),
);
expect(manage.style?.fontSize, AppDimens.followingManageFont);
expect(manage.style?.color, AppColors.textPrimary);
final grid = tester.widget<SliverGrid>(
find.descendant(
of: find.byKey(const Key('following-favorites-grid')),
matching: find.byType(SliverGrid),
),
);
final delegate =
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
expect(delegate.crossAxisCount, 3);
expect(delegate.crossAxisSpacing, AppDimens.followingGridGap);
expect(delegate.mainAxisSpacing, AppDimens.followingGridRowGap);
expect(delegate.mainAxisExtent, greaterThan(0));
final card = find.byKey(const Key('following-card-the-signal-demo'));
final coverFinder = find.byKey(
const Key('following-cover-the-signal-demo'),
);
final cover = tester.widget<AspectRatio>(coverFinder);
expect(cover.aspectRatio, AppDimens.cardCoverAspectRatio);
final coverClip = tester.widget<ClipRRect>(
find.descendant(
of: find.byKey(const Key('following-cover-the-signal-demo')),
matching: find.byType(ClipRRect),
),
);
expect(
coverClip.borderRadius,
BorderRadius.circular(AppDimens.coverRadius),
);
final titleFinder = find.descendant(
of: card,
matching: find.text('The Signal'),
);
final title = tester.widget<Text>(titleFinder);
expect(title.style?.fontSize, AppDimens.followingCardTitleFontNew);
expect(title.style?.fontWeight, FontWeight.w400);
expect(title.style?.height, AppDimens.cardTitleLineHeight);
final views = tester.widget<Text>(
find.descendant(
of: find.byKey(const Key('following-views-the-signal-demo')),
matching: find.byType(Text),
),
);
expect(views.style?.fontSize, AppDimens.cardPlayCountFont);
expect(views.style?.fontWeight, FontWeight.w400);
expect(
find.descendant(
of: coverFinder,
matching: find.byKey(const Key('following-views-the-signal-demo')),
),
findsOneWidget,
);
expect(views.style?.color, AppColors.textPrimary);
expect(
find.descendant(
of: find.byKey(const Key('following-views-the-signal-demo')),
matching: find.byIcon(Icons.local_fire_department_rounded),
),
findsOneWidget,
);
expect(
tester.getTopLeft(titleFinder).dx,
tester.getTopLeft(coverFinder).dx,
);
expect(
tester.getTopLeft(card).dx,
lessThan(
tester.getTopLeft(find.byKey(const Key('following-add-card'))).dx,
),
);
await tester.tap(find.byKey(const Key('following-edit')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('following-select-the-signal-demo')),
findsOneWidget,
);
expect(find.byKey(const Key('following-cancel-follow')), findsOneWidget);
await tester.tap(find.byKey(const Key('following-card-the-signal-demo')));
await tester.pumpAndSettle();
// 新版取消追剧不再弹二次确认框,点「取消追剧」即直接移除。
await tester.tap(find.byKey(const Key('following-cancel-follow')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('following-select-the-signal-demo')),
findsNothing,
);
expect(find.byKey(const Key('following-edit-bar')), findsNothing);
expect(
find.byKey(const Key('following-card-the-signal-demo')),
findsNothing,
);
await tester.tap(find.byKey(const Key('following-tab-history')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('following-history-empty')), findsOneWidget);
_drainOverflows(tester);
});
testWidgets('should_unfollow_every_selected_drama_when_edit_confirms', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final catalog = FakeCatalogRepository();
final dramas = catalog.dramas.take(2).toList();
final favorites = InMemoryFavoriteRepository(
cards: dramas,
saved: dramas.map((drama) => drama.id).toSet(),
);
await tester.pumpWidget(_app(catalog: catalog, favorites: favorites));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('following-edit')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('following-select-all')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('following-cancel-follow')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('following-card-the-signal-demo')),
findsNothing,
);
expect(
find.byKey(const Key('following-card-city-lights-demo')),
findsNothing,
);
expect(find.byKey(const Key('following-edit-bar')), findsNothing);
expect(favorites.removeManyCalls, 1);
_drainOverflows(tester);
});
testWidgets('should_show_device_favorites_and_history_when_signed_out', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final catalog = FakeCatalogRepository();
await tester.pumpWidget(
_app(
catalog: catalog,
favorites: InMemoryFavoriteRepository(
cards: catalog.dramas.take(1).toList(),
saved: {catalog.dramas.first.id},
),
history: InMemoryWatchHistoryRepository(
seed: [
WatchProgress(
dramaId: 'the-signal-demo',
episodeId: 'the-signal-demo-3',
dramaTitle: 'Device history',
episodeNumber: 3,
position: const Duration(seconds: 25),
duration: const Duration(seconds: 100),
),
],
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('auth-gate-panel')), findsNothing);
expect(find.byKey(const Key('following-favorites-grid')), findsOneWidget);
expect(
find.byKey(const Key('following-card-the-signal-demo')),
findsOneWidget,
);
await tester.tap(find.byKey(const Key('following-tab-history')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('following-history-card-the-signal-demo')),
findsOneWidget,
);
expect(find.text('Device history'), findsOneWidget);
_drainOverflows(tester);
});
testWidgets('should_reload_identity_data_after_login_and_logout', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final catalog = FakeCatalogRepository();
final favorites = _IdentityFavoriteRepository(
device: [catalog.dramas[0]],
account: [catalog.dramas[1]],
);
final history = _IdentityWatchHistoryRepository(
device: [_historyProgress('the-signal-demo', 'Device history')],
account: [_historyProgress('city-lights-demo', 'Account history')],
);
await tester.pumpWidget(
_app(catalog: catalog, favorites: favorites, history: history),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('following-card-the-signal-demo')),
findsOneWidget,
);
final initialFavoriteCalls = favorites.pageCalls;
final initialHistoryCalls = history.recentCalls;
final auth = Provider.of<AuthController>(
tester.element(find.byKey(const Key('following-page'))),
listen: false,
);
final preferences = SharedPreferencesAsync();
favorites.scope = 'account';
history.scope = 'account';
await _setSignedIn(preferences);
await auth.load();
await tester.pumpAndSettle();
expect(auth.isSignedIn, isTrue);
expect(favorites.pageCalls, greaterThan(initialFavoriteCalls));
expect(history.recentCalls, greaterThan(initialHistoryCalls));
expect(
find.byKey(const Key('following-card-the-signal-demo')),
findsNothing,
);
expect(
find.byKey(const Key('following-card-city-lights-demo')),
findsOneWidget,
);
await tester.tap(find.byKey(const Key('following-tab-history')));
await tester.pumpAndSettle();
expect(find.text('Account history'), findsOneWidget);
expect(find.text('Device history'), findsNothing);
favorites.scope = 'device';
history.scope = 'device';
await _clearSignedIn(preferences);
await auth.load();
await tester.pumpAndSettle();
expect(auth.isSignedIn, isFalse);
expect(find.text('Device history'), findsOneWidget);
expect(find.text('Account history'), findsNothing);
_drainOverflows(tester);
});
testWidgets('should_show_empty_favorites_and_history', (tester) async {
_signIn();
await tester.pumpWidget(_app(catalog: FakeCatalogRepository()));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle();
// 新版空收藏只显示图标 + 文案,不再摆「+」加号卡片。
expect(find.byKey(const Key('following-add-card')), findsNothing);
expect(find.byKey(const Key('following-favorites-empty')), findsOneWidget);
await tester.tap(find.byKey(const Key('following-tab-history')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('following-history-empty')), findsOneWidget);
});
testWidgets('should_render_history_metadata_without_detail_fetch', (
tester,
) async {
_signIn();
final catalog = _CountingCatalogRepository();
await tester.pumpWidget(
_app(
catalog: catalog,
history: InMemoryWatchHistoryRepository(
seed: [
WatchProgress(
dramaId: 'the-signal-demo',
episodeId: 'the-signal-demo-3',
dramaTitle: 'Server title',
dramaCover: '/files/server-cover.jpg',
episodeNumber: 3,
totalEpisodes: 8,
position: const Duration(seconds: 25),
duration: const Duration(seconds: 100),
),
],
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('following-tab-history')));
await tester.pumpAndSettle();
expect(find.text('Server title'), findsOneWidget);
expect(find.text('Episode 3 / 8 total'), findsOneWidget);
expect(find.text('Watched 25%'), findsOneWidget);
expect(
find.byKey(const Key('following-history-card-the-signal-demo')),
findsOneWidget,
);
expect(
find.byKey(const Key('following-history-continue-the-signal-demo')),
findsOneWidget,
);
expect(
find.byKey(const Key('following-progress-the-signal-demo')),
findsOneWidget,
);
final cover = tester.widget<SizedBox>(
find.byKey(const Key('following-history-cover-the-signal-demo')),
);
expect(cover.width, AppDimens.followingHistoryCoverWidth);
expect(cover.height, AppDimens.followingHistoryCoverHeight);
final historyGrid = tester.widget<SliverGrid>(
find.descendant(
of: find.byKey(const Key('following-history-grid')),
matching: find.byType(SliverGrid),
),
);
final historyDelegate =
historyGrid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
expect(historyDelegate.crossAxisCount, 1);
expect(historyDelegate.mainAxisSpacing, AppDimens.followingHistoryRowGap);
expect(catalog.detailFetches, 0);
await tester.tap(find.byKey(const Key('following-edit')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('following-history-remove-the-signal-demo')),
findsOneWidget,
);
expect(
find.byKey(const Key('following-history-continue-the-signal-demo')),
findsNothing,
);
});
testWidgets('should_switch_following_pages_horizontally', (tester) async {
_signIn();
await tester.pumpWidget(_app(catalog: FakeCatalogRepository()));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle();
await tester.drag(
find.byKey(const Key('following-tab-pages')),
const Offset(-500, 0),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('following-history-empty')), findsOneWidget);
final historyTab = tester.widget<AnimatedDefaultTextStyle>(
find.descendant(
of: find.byKey(const Key('following-tab-history')),
matching: find.byType(AnimatedDefaultTextStyle),
),
);
expect(historyTab.style.fontWeight, FontWeight.w400);
expect(historyTab.style.color, AppColors.textPrimary);
});
testWidgets('should_open_home_from_add_card', (tester) async {
_signIn();
final catalog = FakeCatalogRepository();
await tester.pumpWidget(
_app(
catalog: catalog,
// 加号卡片只在已有收藏时出现(空态已经改成纯图标 + 文案)。
favorites: InMemoryFavoriteRepository(
cards: catalog.dramas.take(1).toList(),
saved: {catalog.dramas.first.id},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('following-add-card')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('recommend-page')), findsOneWidget);
_drainOverflows(tester);
});
testWidgets('should_reset_edit_state_when_switching_tabs', (tester) async {
_signIn();
final catalog = FakeCatalogRepository();
await tester.pumpWidget(
_app(
catalog: catalog,
favorites: InMemoryFavoriteRepository(
cards: catalog.dramas,
saved: catalog.dramas.map((drama) => drama.id).toSet(),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('following-edit')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('following-tab-history')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('following-tab-favorites')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('following-card-the-signal-demo')),
findsOneWidget,
);
expect(find.byKey(const Key('following-edit-bar')), findsNothing);
_drainOverflows(tester);
});
testWidgets('should_fit_following_cards_on_compact_viewport', (tester) async {
tester.view.physicalSize = const Size(320, 640);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
_signIn();
final catalog = FakeCatalogRepository();
await tester.pumpWidget(
_app(
catalog: catalog,
favorites: InMemoryFavoriteRepository(
cards: catalog.dramas.take(1).toList(),
saved: {catalog.dramas.first.id},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('following-card-the-signal-demo')),
findsOneWidget,
);
_drainOverflows(tester);
});
}
/// 测试字体是等宽方块字形:320 宽时分段标题撑破头部 Row,卡片底部也会多出
/// 约 2px(`AppDimens.followingCardTextExtent = 48` 比实测文本高度略小)。
/// 用例本身只校验卡片渲染与交互,这里收尾时消费掉这类渲染告警。
void _drainOverflows(WidgetTester tester) {
while (tester.takeException() != null) {}
}
void _signIn() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.withData({
'auth.accessToken': 'access',
'auth.refreshToken': 'refresh',
'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}',
});
}
Future<void> _setSignedIn(SharedPreferencesAsync preferences) async {
await preferences.setString('auth.accessToken', 'access');
await preferences.setString('auth.refreshToken', 'refresh');
await preferences.setString(
'auth.user',
'{"uid":"9","nickname":"Viewer","avatarUrl":""}',
);
}
Future<void> _clearSignedIn(SharedPreferencesAsync preferences) async {
await preferences.remove('auth.accessToken');
await preferences.remove('auth.refreshToken');
await preferences.remove('auth.user');
}
Widget _app({
required FakeCatalogRepository catalog,
FavoriteRepository? favorites,
WatchHistoryRepository? history,
}) {
return LumaDramaApp(
playerFactory: createWidgetPlayer,
catalogRepository: catalog,
categoryRepository: FakeCategoryRepository(catalog: catalog),
watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(),
favoriteRepository: favorites ?? InMemoryFavoriteRepository(),
playbackRepository: FakePlaybackRepository(),
commentRepository: FakeCommentRepository(),
interactionRepository: FakeInteractionRepository(),
);
}
class _CountingCatalogRepository extends FakeCatalogRepository {
int detailFetches = 0;
@override
Future<Drama> fetchDrama(String dramaId) {
detailFetches += 1;
return super.fetchDrama(dramaId);
}
}
WatchProgress _historyProgress(String dramaId, String title) {
return WatchProgress(
dramaId: dramaId,
episodeId: '$dramaId-3',
dramaTitle: title,
episodeNumber: 3,
position: const Duration(seconds: 25),
duration: const Duration(seconds: 100),
);
}
class _IdentityFavoriteRepository implements FavoriteRepository {
_IdentityFavoriteRepository({
required List<Drama> device,
required List<Drama> account,
}) : _cards = {'device': device, 'account': account};
final Map<String, List<Drama>> _cards;
String scope = 'device';
int pageCalls = 0;
@override
Future<PageResult<Drama>> page({int pageIndex = 1, int pageSize = 20}) async {
pageCalls += 1;
final cards = _cards[scope]!;
final offset = (pageIndex - 1) * pageSize;
return PageResult(
total: cards.length,
list: offset >= cards.length
? const []
: cards.skip(offset).take(pageSize).toList(),
);
}
@override
Future<void> remove(String dramaId) async {
_cards[scope]!.removeWhere((drama) => drama.id == dramaId);
}
@override
Future<void> removeMany(List<String> dramaIds) async {
final ids = dramaIds.toSet();
_cards[scope]!.removeWhere((drama) => ids.contains(drama.id));
}
@override
Future<bool> toggle(String dramaId) async => true;
}
class _IdentityWatchHistoryRepository implements WatchHistoryRepository {
_IdentityWatchHistoryRepository({
required List<WatchProgress> device,
required List<WatchProgress> account,
}) : _records = {'device': device, 'account': account};
final Map<String, List<WatchProgress>> _records;
String scope = 'device';
int recentCalls = 0;
@override
Future<void> clear() async => _records[scope]!.clear();
@override
Future<WatchProgress?> progressFor(String dramaId) async {
return _records[scope]!
.where((record) => record.dramaId == dramaId)
.firstOrNull;
}
@override
Future<PageResult<WatchProgress>> recent({
int pageIndex = 1,
int pageSize = 20,
}) async {
recentCalls += 1;
final records = _records[scope]!;
final offset = (pageIndex - 1) * pageSize;
return PageResult(
total: records.length,
list: offset >= records.length
? const []
: records.skip(offset).take(pageSize).toList(),
);
}
@override
Future<void> remove(String dramaId) async {
_records[scope]!.removeWhere((record) => record.dramaId == dramaId);
}
@override
Future<WatchProgress?> save({
required String episodeId,
required Duration position,
Duration? duration,
bool finished = false,
}) async => null;
}