Files
ReelShort/test/drama_detail/drama_detail_view_test.dart

497 lines
17 KiB
Dart

// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.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 'package:fruitshorts/core/data/models/auth_session.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/download_license.dart';
import 'package:fruitshorts/core/data/models/download_policy.dart';
import 'package:fruitshorts/core/data/models/wallet.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/comment_repository.dart';
import 'package:fruitshorts/core/data/repositories/commerce_repository.dart';
import 'package:fruitshorts/core/data/repositories/download_repository.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/interaction_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/data/services/auth_session_store.dart';
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
import 'package:fruitshorts/core/data/services/download_controller.dart';
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
import 'package:fruitshorts/core/data/services/playback_position_cache.dart';
import 'package:fruitshorts/core/data/services/interaction_controller.dart';
import 'package:fruitshorts/core/data/services/playback_session.dart';
import 'package:fruitshorts/core/data/services/wallet_controller.dart';
import 'package:fruitshorts/core/platform/haptics.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/core/theme/app_theme.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart';
import 'package:fruitshorts/pages/player/player_view.dart';
import '../support/fake_catalog.dart';
import '../support/fake_haptics.dart';
import '../support/fake_http_adapter.dart';
import '../support/fake_interaction_repositories.dart';
import '../support/widget_player.dart';
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
testWidgets('should_render_new_detail_layout_and_existing_menu', (
tester,
) async {
await _pumpDetail(tester);
final overlay = tester.widget<AnnotatedRegion<SystemUiOverlayStyle>>(
find.byKey(const Key('detail-system-overlay')),
);
expect(overlay.value.systemNavigationBarColor, AppColors.ink);
expect(overlay.value.systemNavigationBarContrastEnforced, isFalse);
expect(find.byKey(const Key('detail-hero')), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('detail-poster'))),
const Size(96, 128),
);
expect(
tester.getSize(find.byKey(const Key('detail-back'))),
const Size(40, 40),
);
expect(
tester
.widget<Material>(
find.descendant(
of: find.byKey(const Key('detail-back')),
matching: find.byType(Material),
),
)
.color,
const Color(0x59000000),
);
expect(
tester
.widget<Text>(find.byKey(const Key('detail-title')))
.style
?.fontSize,
20,
);
expect(find.byKey(const Key('detail-episode-count')), findsOneWidget);
expect(tester.getSize(find.byKey(const Key('detail-favorite'))).height, 34);
final stats = tester.widget<Container>(
find.byKey(const Key('detail-stats')),
);
final statsDecoration = stats.decoration! as BoxDecoration;
expect(statsDecoration.color, AppColors.card);
expect(statsDecoration.borderRadius, BorderRadius.circular(12));
expect((statsDecoration.border! as Border).top.color, AppColors.border);
expect(find.byKey(const Key('detail-synopsis-section')), findsOneWidget);
expect(find.byKey(const Key('detail-tags')), findsOneWidget);
expect(find.byKey(const Key('detail-episodes-section')), findsOneWidget);
expect(tester.getSize(find.byKey(const Key('detail-cta'))).height, 46);
final bottomBar = tester.widget<Container>(
find.byKey(const Key('detail-bottom-bar')),
);
expect((bottomBar.decoration! as BoxDecoration).color, AppColors.ink);
expect(find.byKey(const Key('detail-share')), findsOneWidget);
expect(find.byKey(const Key('detail-more')), findsOneWidget);
for (final key in const [Key('detail-share'), Key('detail-more')]) {
expect(tester.getSize(find.byKey(key)), const Size(40, 40));
expect(
tester
.widget<Material>(
find.descendant(
of: find.byKey(key),
matching: find.byType(Material),
),
)
.color,
const Color(0x59000000),
);
}
await tester.tap(find.byKey(const Key('detail-more')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('detail-menu-favorite')), findsOneWidget);
expect(find.byKey(const Key('detail-download')), findsOneWidget);
expect(find.byKey(const Key('detail-menu-share')), findsOneWidget);
expect(find.byKey(const Key('detail-report')), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('should_switch_favorite_button_style_when_tapped', (
tester,
) async {
await _pumpDetail(tester);
BoxDecoration decoration() =>
tester
.widget<Container>(find.byKey(const Key('detail-favorite')))
.decoration!
as BoxDecoration;
expect(decoration().color, AppColors.card);
expect((decoration().border! as Border).top.color, AppColors.blue);
expect(
tester
.widget<Text>(find.byKey(const Key('detail-stat-favorite-value')))
.data,
'3200 Favorite',
);
await tester.tap(find.byKey(const Key('detail-favorite')));
await tester.pumpAndSettle();
expect(decoration().color, AppColors.blue);
expect(
tester
.widget<Text>(find.byKey(const Key('detail-stat-favorite-value')))
.data,
'3201 Favorite',
);
expect(
find.descendant(
of: find.byKey(const Key('detail-favorite')),
matching: find.byIcon(Icons.star_rounded),
),
findsOneWidget,
);
await tester.tap(find.byKey(const Key('detail-favorite')));
await tester.pumpAndSettle();
expect(
tester
.widget<Text>(find.byKey(const Key('detail-stat-favorite-value')))
.data,
'3200 Favorite',
);
});
testWidgets('should_expand_synopsis_when_it_exceeds_three_lines', (
tester,
) async {
await _pumpDetail(tester, drama: _detailDrama(longSynopsis: true));
Text synopsis() =>
tester.widget<Text>(find.byKey(const Key('detail-synopsis')));
expect(synopsis().maxLines, 3);
final toggle = find.byKey(const Key('detail-synopsis-toggle'));
expect(toggle, findsOneWidget);
await tester.ensureVisible(toggle);
await tester.tap(toggle);
await tester.pumpAndSettle();
expect(synopsis().maxLines, isNull);
expect(
tester.widget<Text>(find.byKey(const Key('detail-synopsis'))).data,
contains('final secret'),
);
});
testWidgets('should_render_stats_and_honest_empty_values', (tester) async {
await _pumpDetail(tester);
expect(find.text('480.0万 heat'), findsOneWidget);
expect(find.text('3200 Favorite'), findsOneWidget);
expect(find.text('1.2万 Like'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
await _pumpDetail(tester, drama: _detailDrama(withCounts: false));
expect(
tester
.widget<Text>(find.byKey(const Key('detail-stat-favorite-value')))
.data,
'8 episodes',
);
expect(
tester.widget<Text>(find.byKey(const Key('detail-stat-like-value'))).data,
'—',
);
});
testWidgets('should_render_six_column_grid_and_open_original_index', (
tester,
) async {
await _pumpDetail(tester, drama: _detailDrama(count: 80, descending: true));
await tester.ensureVisible(
find.byKey(const Key('detail-episodes-section')),
);
await tester.pumpAndSettle();
final grid = tester.widget<GridView>(
find.byKey(const Key('detail-episode-chips')),
);
final delegate =
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
expect(delegate.crossAxisCount, 6);
expect(delegate.mainAxisSpacing, 8);
expect(delegate.crossAxisSpacing, 8);
expect(delegate.mainAxisExtent, 44);
final chip1 = find.byKey(const Key('detail-episode-chip-1'));
final chip2 = find.byKey(const Key('detail-episode-chip-2'));
final chip6 = find.byKey(const Key('detail-episode-chip-6'));
final chip7 = find.byKey(const Key('detail-episode-chip-7'));
expect(tester.getTopLeft(chip1).dy, tester.getTopLeft(chip6).dy);
expect(
tester.getTopLeft(chip7).dy,
greaterThan(tester.getTopLeft(chip1).dy),
);
expect(tester.getTopLeft(chip1).dx, lessThan(tester.getTopLeft(chip2).dx));
expect(tester.getSize(chip1).height, 44);
expect(find.byKey(const Key('detail-episode-lock-2')), findsOneWidget);
expect(find.byKey(const Key('detail-episode-lock-1')), findsNothing);
await tester.tap(chip1);
await tester.pumpAndSettle();
final player = tester.widget<PlayerPage>(find.byType(PlayerPage));
expect(player.initialIndex, 79);
});
testWidgets('should_open_recent_cached_episode_when_detail_cta_is_tapped', (
tester,
) async {
final cache = PlaybackPositionCache();
cache.save(
episodeId: 'detail-k38-3',
dramaId: 'detail-k38',
position: const Duration(minutes: 1, seconds: 20),
duration: const Duration(minutes: 3),
);
await _pumpDetail(tester, positionCache: cache);
await tester.tap(find.byKey(const Key('detail-cta')));
await tester.pumpAndSettle();
final player = tester.widget<PlayerPage>(find.byType(PlayerPage));
expect(player.initialIndex, 2);
});
testWidgets('should_fit_compact_viewport_and_keep_cta_visible', (
tester,
) async {
await _pumpDetail(
tester,
drama: _detailDrama(count: 80),
size: const Size(320, 568),
);
expect(find.byKey(const Key('detail-cta')), findsOneWidget);
await tester.ensureVisible(
find.byKey(const Key('detail-episodes-section')),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('detail-cta')), findsOneWidget);
expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
Future<void> _pumpDetail(
WidgetTester tester, {
Drama? drama,
Size size = const Size(390, 844),
PlaybackPositionCache? positionCache,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final target = drama ?? _detailDrama();
final catalog = FakeCatalogRepository(dramas: [target]);
final interactions = FakeInteractionRepository();
final auth = await _signedInAuth();
final favorites = FavoriteController(
InMemoryFavoriteRepository(cards: [target]),
);
await favorites.load();
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<WatchHistoryRepository>.value(
value: InMemoryWatchHistoryRepository(),
),
Provider<PlaybackRepository>.value(value: FakePlaybackRepository()),
Provider<PlaybackPositionCache>.value(
value: positionCache ?? PlaybackPositionCache(),
),
Provider<PlayerFactory>.value(value: createWidgetPlayer),
Provider(create: (_) => PlaybackCoordinator()),
Provider<InteractionRepository>.value(value: interactions),
Provider<CommentRepository>.value(value: FakeCommentRepository()),
Provider<Haptics>.value(value: FakeHaptics()),
ChangeNotifierProvider<AuthController>.value(value: auth),
ChangeNotifierProvider<FavoriteController>.value(value: favorites),
ChangeNotifierProvider(
create: (_) => InteractionController(interactions),
),
ChangeNotifierProvider(
create: (_) => WalletController(_FakeCommerceRepository()),
),
ChangeNotifierProvider(
create: (_) => DownloadController(_FakeDownloadRepository()),
),
ChangeNotifierProvider(
create: (_) => CatalogController(catalog)..load(),
),
],
child: MaterialApp(
theme: AppTheme.light(),
locale: const Locale('en'),
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: DramaDetailPage(drama: target),
),
),
);
await tester.pumpAndSettle();
}
Future<AuthController> _signedInAuth() async {
final preferences = SharedPreferencesAsync();
final store = AuthSessionStore(preferences: preferences);
await store.save(
const AuthSession(
accessToken: 'access',
refreshToken: 'refresh',
user: AuthUser(uid: '9', nickname: 'Viewer'),
),
);
final auth = AuthController(
AuthApi(
ApiClient(
baseUrl: 'http://127.0.0.1:1',
adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)),
),
),
store,
);
await auth.load();
return auth;
}
class _FakeCommerceRepository implements CommerceRepository {
@override
Future<WalletSnapshot> fetchWallet() async => const WalletSnapshot(
wallet: CoinWallet(
coinBalance: 100,
totalRechargedCoin: 0,
totalConsumedCoin: 0,
totalRefundedCoin: 0,
),
);
@override
Future<UnlockResult> unlock(String episodeId) async => const UnlockResult(
unlocked: true,
accessType: 'UNLOCKED',
coinCost: 0,
coinBalance: 100,
);
}
class _FakeDownloadRepository implements DownloadRepository {
@override
Future<DownloadPolicy> fetchPolicy() async => const DownloadPolicy(
maxPerDevice: 20,
licenseDays: 30,
wifiOnly: false,
maxQuality: 'AUTO',
maxStorageBytes: 0,
used: 0,
remaining: 20,
expirePolicyText: '',
);
@override
Future<List<DownloadLicense>> list() async => const [];
@override
Future<DownloadLicense> grant(String episodeId, {String? quality}) async =>
throw UnimplementedError();
@override
Future<void> updateSettings({bool? wifiOnly, String? maxQuality}) async {}
@override
Future<DownloadLicense> refreshUrl(String episodeId) async =>
throw UnimplementedError();
@override
Future<DownloadLicense> reportProgress(
String episodeId, {
String? deviceId,
String? status,
int? progressPercent,
int? downloadedBytes,
int? totalBytes,
String? errorMsg,
}) async => throw UnimplementedError();
@override
Future<void> remove(String episodeId) async {}
@override
Future<int> clear() async => 0;
}
Drama _detailDrama({
int count = 8,
bool descending = false,
bool withCounts = true,
bool longSynopsis = false,
}) {
final numbers = List<int>.generate(
count,
(index) => descending ? count - index : index + 1,
);
final synopsis = longSynopsis
? 'She agreed to marry a mysterious stranger to save her family, '
'until she discovered his secret identity and the city he controls. '
'Every choice pulls her deeper into a dangerous game, and the '
'final secret changes everything she thought she knew about him.'
: 'She agreed to marry a mysterious stranger to save her family, '
'until she discovered his secret identity and the city he controls.';
return Drama(
id: 'detail-k38',
title: 'Detail K38',
synopsis: synopsis,
posterUrl: '/files/detail-k38.png',
genre: 'Urban Romance',
category: 'Romance',
language: 'id',
totalEpisodes: count,
viewCount: 4800000,
favoriteCount: withCounts ? 3200 : null,
likeCount: withCounts ? 12400 : null,
detailLoaded: true,
episodes: [
for (final number in numbers)
Episode(
id: 'detail-k38-$number',
number: number,
title: 'Episode $number',
isFree: number == 1,
durationSec: 135,
locked: number > 1,
videoUrl: number == 1 ? '/files/detail-k38-1.mp4' : '',
likeCount: withCounts ? 12400 : null,
commentCount: withCounts ? 96 : null,
),
],
);
}