Files
ReelShort/test/home/home_grid_layout_test.dart
T

400 lines
14 KiB
Dart

// 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/content_category.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/feed_page.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/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/theme/app_colors.dart';
import 'package:fruitshorts/pages/player/player_view.dart';
import 'package:fruitshorts/pages/recommend/recommend_types.dart';
import 'package:fruitshorts/pages/recommend/widgets/recommend_top_nav.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_category.dart';
import '../support/fake_haptics.dart';
import '../support/fake_interaction_repositories.dart';
import '../support/widget_player.dart';
const _categories = [
ContentCategory(id: 'costume', name: '古装'),
ContentCategory(id: 'community', name: '社群'),
ContentCategory(id: 'uplifting', name: '爽剧'),
ContentCategory(id: 'sweet', name: '甜宠'),
ContentCategory(id: 'face-slap', name: '打脸虐渣'),
ContentCategory(id: 'angst', name: '虐恋'),
];
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
testWidgets('should_render_inner_top_tabs_in_order', (tester) async {
final repository = _PagedCatalogRepository();
await _pumpApp(tester, repository);
final tabKeys = [
const Key('recommend-tab-recommend'),
const Key('recommend-tab-playlist'),
const Key('recommend-tab-all'),
];
final centers = tabKeys
.map((key) => tester.getCenter(find.byKey(key)).dx)
.toList();
expect(centers, orderedEquals(centers.toList()..sort()));
expect(find.byKey(const Key('recommend-section-hits')), findsNothing);
expect(find.text('Featured'), findsOneWidget);
expect(find.text('Categories'), findsNothing);
expect(find.text('Charts'), findsNothing);
expect(find.byKey(const Key('mine-mini-player')), findsNothing);
expect(find.byType(FloatingActionButton), findsNothing);
expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget);
final selectedUnderline = tester.widget<Container>(
find.byKey(const Key('recommend-tab-recommend-underline')),
);
expect(
(selectedUnderline.decoration! as BoxDecoration).color,
AppColors.brandRed,
);
final idleUnderline = tester.widget<Container>(
find.byKey(const Key('recommend-tab-playlist-underline')),
);
expect(
(idleUnderline.decoration! as BoxDecoration).color,
AppColors.transparent,
);
});
testWidgets('should_render_only_categories_returned_by_api', (tester) async {
final repository = _PagedCatalogRepository();
await _pumpApp(
tester,
repository,
categories: const [ContentCategory(id: 'romance', name: 'Romance')],
);
await tester.tap(find.byKey(const Key('tab-recommend')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('recommend-channel-nav')), findsOneWidget);
expect(find.byKey(const Key('recommend-tab-romance')), findsOneWidget);
expect(find.byKey(const Key('recommend-tab-costume')), findsNothing);
expect(find.byKey(const Key('recommend-tab-community')), findsNothing);
});
testWidgets('should_switch_home_sections_without_pushing_pages', (
tester,
) async {
final repository = _PagedCatalogRepository();
await _pumpApp(tester, repository);
expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget);
expect(find.byKey(const Key('recommend-section-hits')), findsNothing);
await tester.tap(find.byKey(const Key('recommend-tab-playlist')));
await tester.pumpAndSettle();
_drainLayoutExceptions(tester);
expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget);
expect(
Navigator.of(
tester.element(find.byKey(const Key('recommend-page'))),
).canPop(),
isFalse,
);
await _openAllDramasTab(tester);
expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget);
expect(find.byKey(const Key('filter-page')), findsNothing);
});
testWidgets('should_keep_scroll_position_when_switching_sections', (
tester,
) async {
final repository = _PagedCatalogRepository();
await _pumpApp(tester, repository);
await _openAllDramasTab(tester);
await tester.drag(
find.byKey(const Key('all-dramas-scroll')),
const Offset(0, -320),
);
await tester.pumpAndSettle();
final scrollable = find
.descendant(
of: find.byKey(const Key('all-dramas-scroll')),
matching: find.byType(Scrollable),
)
.first;
final before = tester.state<ScrollableState>(scrollable).position.pixels;
expect(before, greaterThan(0));
await tester.tap(find.byKey(const Key('recommend-tab-playlist')));
await tester.pumpAndSettle();
_drainLayoutExceptions(tester);
await _openAllDramasTab(tester);
final afterScrollable = find
.descendant(
of: find.byKey(const Key('all-dramas-scroll')),
matching: find.byType(Scrollable),
)
.first;
expect(
tester.state<ScrollableState>(afterScrollable).position.pixels,
closeTo(before, 0.1),
);
});
testWidgets('should_swipe_between_inner_sections', (tester) async {
final repository = _PagedCatalogRepository();
await _pumpApp(tester, repository);
final pages = find.byKey(const Key('recommend-tab-page-view'));
final topNavBefore = tester.getRect(
find.byKey(const Key('recommend-top-nav')),
);
await tester.drag(pages, const Offset(-320, 0));
await tester.pumpAndSettle();
_drainLayoutExceptions(tester);
expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget);
expect(find.byKey(const Key('recommend-feed-player')), findsNothing);
final selected = tester.widget<Text>(
find.descendant(
of: find.byKey(const Key('recommend-tab-playlist')),
matching: find.text('Playlists'),
),
);
expect(selected.style?.fontWeight, FontWeight.w400);
expect(
tester.getRect(find.byKey(const Key('recommend-top-nav'))),
topNavBefore,
);
await tester.drag(pages, const Offset(-320, 0));
await tester.pumpAndSettle();
_drainLayoutExceptions(tester);
expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget);
await tester.drag(pages, const Offset(640, 0));
await tester.pumpAndSettle();
_drainLayoutExceptions(tester);
expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget);
expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget);
});
testWidgets('should_follow_partial_swipe_before_selected_page_changes', (
tester,
) async {
await _pumpApp(tester, _PagedCatalogRepository());
final pages = find.byKey(const Key('recommend-tab-page-view'));
final gesture = await tester.startGesture(tester.getCenter(pages));
await gesture.moveBy(const Offset(-30, 0));
await tester.pump();
await gesture.moveBy(const Offset(-80, 0));
await tester.pump();
final nav = tester.widget<RecommendTopNav>(find.byType(RecommendTopNav));
expect(nav.pagePosition, inExclusiveRange(0.0, 0.5));
final underline = tester.widget<Container>(
find.byKey(const Key('recommend-tab-playlist-underline')),
);
final color = (underline.decoration! as BoxDecoration).color!;
expect(color.a, inExclusiveRange(0.0, 0.5));
await gesture.up();
await tester.pumpAndSettle();
_drainLayoutExceptions(tester);
});
testWidgets(
'should_keep_all_dramas_selected_during_direct_cross_page_click',
(tester) async {
await _pumpApp(tester, _PagedCatalogRepository());
await tester.ensureVisible(find.byKey(const Key('recommend-tab-all')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('recommend-tab-all')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 70));
final nav = tester.widget<RecommendTopNav>(find.byType(RecommendTopNav));
expect(nav.pagePosition, inExclusiveRange(0.5, 1.5));
expect(nav.selected, RecommendInnerTab.allDramas);
await tester.pumpAndSettle();
_drainLayoutExceptions(tester);
expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget);
},
);
testWidgets('should_load_next_page_without_expanding_page_size', (
tester,
) async {
final repository = _PagedCatalogRepository();
await _pumpApp(tester, repository);
await _openAllDramasTab(tester);
expect(find.byKey(const Key('home-feed-card-page1-0')), findsOneWidget);
for (var attempt = 0; attempt < 3; attempt++) {
await tester.drag(
find.byKey(const Key('all-dramas-scroll')),
const Offset(0, -800),
);
await tester.pumpAndSettle();
_drainLayoutExceptions(tester);
}
expect(repository.cursors, contains('page1'));
expect(repository.requestedSizes.every((size) => size <= 20), isTrue);
final gridScrollable = find
.descendant(
of: find.byKey(const Key('all-dramas-scroll')),
matching: find.byType(Scrollable),
)
.first;
final secondPageCard = find.byKey(const Key('home-feed-card-page2-0'));
final position = tester.state<ScrollableState>(gridScrollable).position;
await tester.pump();
position.jumpTo(position.maxScrollExtent);
await tester.pump();
position.jumpTo(position.maxScrollExtent);
await tester.pump();
_drainLayoutExceptions(tester);
expect(secondPageCard, findsOneWidget);
});
testWidgets('should_open_player_when_grid_card_is_tapped', (tester) async {
final repository = _PagedCatalogRepository();
await _pumpApp(tester, repository);
await _openAllDramasTab(tester);
await tester.tap(find.byKey(const Key('home-feed-card-page1-0')));
await tester.pumpAndSettle();
final player = tester.widget<PlayerPage>(find.byType(PlayerPage).last);
expect(player.homeMode, isFalse);
});
}
/// 打开首页内层「全部短剧」页;顺带消费测试字体(Ahem)造成的卡片溢出。
Future<void> _openAllDramasTab(WidgetTester tester) async {
await tester.ensureVisible(find.byKey(const Key('recommend-tab-all')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('recommend-tab-all')));
await tester.pumpAndSettle();
_drainLayoutExceptions(tester);
}
/// 剧单/网格卡片高度按真机字体设计,测试字体行高更大时会溢出几个像素。
void _drainLayoutExceptions(WidgetTester tester) {
while (tester.takeException() != null) {}
}
Future<void> _pumpApp(
WidgetTester tester,
_PagedCatalogRepository repository, {
List<ContentCategory> categories = _categories,
}) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
LumaDramaApp(
playerFactory: createWidgetPlayer,
catalogRepository: repository,
categoryRepository: FakeCategoryRepository(
catalog: repository,
categories: categories,
),
watchHistoryRepository: InMemoryWatchHistoryRepository(),
favoriteRepository: InMemoryFavoriteRepository(
cards: repository.allDramas,
),
playbackRepository: FakePlaybackRepository(),
commentRepository: FakeCommentRepository(),
interactionRepository: FakeInteractionRepository(),
haptics: FakeHaptics(),
),
);
await tester.pumpAndSettle();
}
class _PagedCatalogRepository implements CatalogRepository {
_PagedCatalogRepository()
: pageOne = List.generate(30, (index) => _drama('page1-$index')),
pageTwo = List.generate(2, (index) => _drama('page2-$index'));
final List<Drama> pageOne;
final List<Drama> pageTwo;
final List<String?> cursors = [];
final List<int> requestedSizes = [];
List<Drama> get allDramas => [...pageOne, ...pageTwo];
@override
Future<HomeFeed> fetchHomeFeed() async => _feed(pageOne);
@override
Future<HomeFeed> fetchDiscoverFeed() async => _feed(pageOne);
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
}) async {
requestedSizes.add(size);
if (types.isNotEmpty) {
return const FeedPage(dramas: []);
}
cursors.add(cursor);
if (cursor == 'page1') {
return FeedPage(dramas: pageTwo, hasMore: false);
}
return FeedPage(dramas: pageOne, nextCursor: 'page1', hasMore: true);
}
@override
Future<Drama> fetchDrama(String dramaId) async {
return allDramas.firstWhere((drama) => drama.id == dramaId);
}
HomeFeed _feed(List<Drama> dramas) {
return HomeFeed(
sections: [FeedSection(code: 'hot', title: 'Hot', dramas: dramas)],
);
}
}
Drama _drama(String id) {
final index = int.tryParse(id.split('-').last) ?? 0;
return Drama(
id: id,
title: 'Grid Drama $id',
synopsis: 'synopsis',
posterUrl: '',
genre: 'Romance',
category: index.isEven ? '古装' : 'Urban',
viewCount: (10 - index) * 10000,
createdAt: DateTime(2026, 10, 1).add(Duration(days: index)),
detailLoaded: true,
episodes: [
Episode(
id: '$id-1',
number: 1,
title: 'Episode 1',
isFree: true,
videoUrl: 'https://example.test/$id.mp4',
),
],
);
}