feat(mine): 重做我的页会员卡与功能宫格布局

This commit is contained in:
Vicky authored and trae-solo-agent committed 2026-10-30 17:14:32 +08:00
1 parent e7399fa3fd
commit 53e8c924a6
12 files changed
+1969 -513

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
// Author: fruit.com.cn
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/core/theme/app_dimens.dart';
import 'package:fruitshorts/core/theme/app_theme.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/mine/mine_types.dart';
import 'package:fruitshorts/pages/mine/widgets/mine_action_grid.dart';
import 'package:fruitshorts/pages/mine/widgets/mine_menu.dart';
const _layoutTolerance = 0.1;
const _minimumVipHeight = 190.0;
const _previousVipPlaceholderHeight = 220.0;
void main() {
testWidgets('should_keep_equal_visible_vip_padding_and_reduce_height', (
tester,
) async {
await _pumpLocalized(
tester,
const SizedBox(width: 342, child: MineVipBanner(onOpen: _noop)),
);
final cardRect = tester.getRect(find.byKey(const Key('mine-vip')));
final emblemRect = tester.getRect(find.byKey(const Key('mine-vip-emblem')));
final benefitsRect = tester.getRect(
find.byKey(const Key('mine-vip-benefits')),
);
expect(cardRect.height, lessThan(_previousVipPlaceholderHeight));
expect(cardRect.height, greaterThanOrEqualTo(_minimumVipHeight));
expect(
emblemRect.top - cardRect.top,
closeTo(cardRect.bottom - benefitsRect.bottom, _layoutTolerance),
);
});
for (final localeTag in ['en', 'id']) {
testWidgets(
'should_center_visible_grid_content_when_locale_is_$localeTag',
(tester) async {
await _pumpLocalized(
tester,
const MineActionGrid(
primaryActions: MinePrimaryActions(
onWallet: _noop,
onFavorites: _noop,
onDownloads: _noop,
onHistory: _noop,
),
serviceActions: MineServiceActions(
onOpen: _ignoreDestination,
onLanguage: _noop,
),
),
locale: Locale(localeTag),
);
for (final action in _actions) {
_expectVisibleContentCentered(tester, action);
}
},
);
}
testWidgets('should_use_subtle_dividers_when_rendering_grid', (tester) async {
await _pumpLocalized(
tester,
const MineActionGrid(
primaryActions: MinePrimaryActions(
onWallet: _noop,
onFavorites: _noop,
onDownloads: _noop,
onHistory: _noop,
),
serviceActions: MineServiceActions(
onOpen: _ignoreDestination,
onLanguage: _noop,
),
),
);
final dividers = find.descendant(
of: find.byKey(const Key('mine-menu')),
matching: find.byWidgetPredicate(
(widget) =>
widget is Container && widget.color == AppColors.mineGridDivider,
),
);
expect(dividers, findsNWidgets(7));
expect(AppColors.mineGridDivider, const Color(0x10FFFFFF));
});
}
const _actions = [
(Key('mine-wallet'), Key('mine-wallet-icon-surface')),
(Key('mine-favorites'), Key('mine-favorites-icon-surface')),
(Key('mine-downloads'), Key('mine-downloads-icon-surface')),
(Key('mine-history'), Key('mine-history-icon-surface')),
(Key('mine-help'), Key('mine-help-icon-surface')),
(Key('mine-language'), Key('mine-language-icon-surface')),
(Key('mine-settings'), Key('mine-settings-icon-surface')),
(Key('mine-notices'), Key('mine-notices-icon-surface')),
];
void _expectVisibleContentCentered(WidgetTester tester, (Key, Key) action) {
final cellRect = tester.getRect(find.byKey(action.$1));
final iconRect = tester.getRect(find.byKey(action.$2));
final labelRect = tester.getRect(
find.descendant(of: find.byKey(action.$1), matching: find.byType(Text)),
);
final visibleRect = Rect.fromLTRB(
math.min(iconRect.left, labelRect.left),
iconRect.top,
math.max(iconRect.right, labelRect.right),
labelRect.bottom,
);
expect(visibleRect.center.dx, closeTo(cellRect.center.dx, _layoutTolerance));
expect(visibleRect.center.dy, closeTo(cellRect.center.dy, _layoutTolerance));
}
Future<void> _pumpLocalized(
WidgetTester tester,
Widget child, {
Locale locale = const Locale('en'),
}) async {
tester.view.physicalSize = const Size(390, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.dark(),
locale: locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: Scaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppDimens.pageHorizontal),
child: child,
),
),
),
),
);
await tester.pumpAndSettle();
}
void _noop() {}
void _ignoreDestination(MineDestination _) {}
+109 -27
View File
@@ -16,7 +16,7 @@ import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/auth/auth_view.dart';
import 'package:fruitshorts/pages/mine/about_page.dart';
import 'package:fruitshorts/pages/mine/mine_types.dart';
import 'package:fruitshorts/pages/mine/widgets/mine_menu.dart';
import 'package:fruitshorts/pages/mine/widgets/mine_action_grid.dart';
import 'package:fruitshorts/pages/store/vip_page.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';
@@ -78,11 +78,11 @@ void main() {
]);
expect(background.stops, const [0, 0.14, 0.3]);
// 快捷图标行:钱包 / 收藏 / 离线缓存 / 观看历史。
// 统一操作宫格第一排:钱包 / 收藏 / 离线缓存 / 观看历史。
const quickActions = [
(Key('mine-wallet'), Icons.wallet_outlined),
(Key('mine-favorites'), Icons.star_border_rounded),
(Key('mine-downloads'), Icons.download_outlined),
(Key('mine-wallet'), Icons.account_balance_wallet_outlined),
(Key('mine-favorites'), Icons.star_outline_rounded),
(Key('mine-downloads'), Icons.download_for_offline_outlined),
(Key('mine-history'), Icons.history_rounded),
];
for (final action in quickActions) {
@@ -92,44 +92,124 @@ void main() {
matching: find.byIcon(action.$2),
),
);
expect(icon.color, AppColors.textPrimary);
expect(icon.size, AppDimens.mineQuickActionIcon);
expect(icon.color, AppColors.mineGridIconAccent);
expect(icon.size, AppDimens.mineGridIcon);
expect(
find.descendant(
of: find.byKey(const Key('mine-menu')),
matching: find.byKey(action.$1),
),
findsOneWidget,
);
}
// 「更多服务」列表:联系客服 / 语言 / 设置 / 通知。
// 「更多服务」宫格:联系客服 / 语言 / 设置 / 通知。
const services = [
(Key('mine-help'), Icons.headset_outlined),
(Key('mine-language'), Icons.language_rounded),
(Key('mine-settings'), Icons.settings_outlined),
(Key('mine-notices'), Icons.notifications_none_rounded),
(
Key('mine-help'),
Key('mine-help-icon-surface'),
Icons.support_agent_outlined,
),
(
Key('mine-language'),
Key('mine-language-icon-surface'),
Icons.language_rounded,
),
(
Key('mine-settings'),
Key('mine-settings-icon-surface'),
Icons.settings_outlined,
),
(
Key('mine-notices'),
Key('mine-notices-icon-surface'),
Icons.notifications_none_rounded,
),
];
for (final service in services) {
expect(find.byKey(service.$1), findsOneWidget);
final icon = tester.widget<Icon>(
find.descendant(
of: find.byKey(service.$1),
matching: find.byIcon(service.$2),
matching: find.byIcon(service.$3),
),
);
expect(icon.color, AppColors.textPrimary);
expect(icon.size, AppDimens.mineServiceIcon);
expect(icon.color, AppColors.mineGridIconAccent);
expect(icon.size, AppDimens.mineGridIcon);
expect(
tester.getSize(find.byKey(service.$2)),
const Size.square(AppDimens.mineGridIconSurface),
);
}
expect(find.byKey(const Key('mine-menu')), findsOneWidget);
expect(tester.getTopLeft(find.byKey(const Key('mine-menu'))).dx, 14);
expect(
tester.getSize(find.byKey(const Key('mine-menu'))).height,
AppDimens.mineGridHeight,
);
final serviceLefts = services
.map((service) => tester.getTopLeft(find.byKey(service.$1)).dx)
.toList();
expect(serviceLefts, [...serviceLefts]..sort());
expect(serviceLefts.toSet(), hasLength(services.length));
expect(
tester.getTopLeft(find.byKey(const Key('mine-help'))).dy,
greaterThan(tester.getTopLeft(find.byKey(const Key('mine-wallet'))).dy),
);
final menu = tester.widget<DecoratedBox>(
find.byKey(const Key('mine-menu')),
);
final menuDecoration = menu.decoration as BoxDecoration;
expect(menuDecoration.color, AppColors.mineGridSurface);
expect(
menuDecoration.borderRadius,
BorderRadius.circular(AppDimens.mineGridRadius),
);
final vip = tester.widget<Container>(find.byKey(const Key('mine-vip')));
final vipDecoration = vip.decoration! as BoxDecoration;
expect((vipDecoration.gradient! as LinearGradient).colors, const [
AppColors.vipCardStart,
AppColors.vipCardMiddle,
AppColors.vipCardEnd,
]);
expect(vipDecoration.boxShadow, isNull);
expect(vipDecoration.border, isNotNull);
expect(vipDecoration.boxShadow, isNotEmpty);
// 卡片高度由内容撑开,上下内边距必须一致(边框各占 1pt)。
final vipRect = tester.getRect(find.byKey(const Key('mine-vip')));
final emblemRect = tester.getRect(find.byKey(const Key('mine-vip-emblem')));
final benefitsRect = tester.getRect(
find.byKey(const Key('mine-vip-benefits')),
);
final topInset = emblemRect.top - vipRect.top;
final bottomInset = vipRect.bottom - benefitsRect.bottom;
expect(topInset, closeTo(bottomInset, 0.01));
expect(topInset, greaterThanOrEqualTo(AppDimens.mineVipPadding));
expect(find.byKey(const Key('mine-vip-emblem')), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('mine-vip-open'))).height,
greaterThanOrEqualTo(AppDimens.tapTarget),
);
const benefitKeys = [
Key('mine-vip-benefit-0'),
Key('mine-vip-benefit-1'),
Key('mine-vip-benefit-2'),
Key('mine-vip-benefit-3'),
];
final benefitTops = benefitKeys
.map((key) => tester.getTopLeft(find.byKey(key)))
.toList();
expect(benefitTops.map((position) => position.dy).toSet(), hasLength(1));
expect(
benefitTops.map((position) => position.dx).toSet(),
hasLength(benefitKeys.length),
);
final order = [
tester.getTopLeft(find.byKey(const Key('mine-user-row'))).dy,
tester.getTopLeft(find.byKey(const Key('mine-vip'))).dy,
tester.getTopLeft(find.byKey(const Key('mine-wallet'))).dy,
tester.getTopLeft(find.byKey(const Key('mine-help'))).dy,
];
expect(order, [...order]..sort());
expect(tester.takeException(), isNull);
@@ -181,6 +261,7 @@ void main() {
expect(find.byType(MineAboutPage), findsOneWidget);
expect(find.byKey(const Key('mine-about-page')), findsOneWidget);
expect(find.text('Version 1.0.0'), findsOneWidget);
expect(find.text('Your mini business'), findsOneWidget);
expect(
find.text(
AppLocalizations.of(
@@ -245,16 +326,17 @@ void main() {
padding: const EdgeInsets.all(AppDimens.pageHorizontal),
child: Column(
children: [
MineQuickActions(
onWallet: () => opened.add(MineDestination.wallet),
onFavorites: () => favoritesOpened++,
onDownloads: () => opened.add(MineDestination.downloads),
onHistory: () => opened.add(MineDestination.history),
),
MineServiceList(
onOpen: opened.add,
onLanguage: () => languageOpened++,
languageName: 'English',
MineActionGrid(
primaryActions: MinePrimaryActions(
onWallet: () => opened.add(MineDestination.wallet),
onFavorites: () => favoritesOpened++,
onDownloads: () => opened.add(MineDestination.downloads),
onHistory: () => opened.add(MineDestination.history),
),
serviceActions: MineServiceActions(
onOpen: opened.add,
onLanguage: () => languageOpened++,
),
),
],
),
+324
View File
@@ -0,0 +1,324 @@
// Author: fruit.com.cn
// 验证我的页个人数据的真实总数、未知状态、导航与刷新。
import 'dart:async';
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/wallet.dart';
import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/core/data/repositories/commerce_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/data/services/auth_controller.dart';
import 'package:fruitshorts/pages/auth/auth_view.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_category.dart';
import '../support/fake_interaction_repositories.dart';
import '../support/fake_notice_repository.dart';
import '../support/widget_player.dart';
import '../support/widget_capture.dart';
const _coins = Key('mine-stat-coins');
const _history = Key('mine-stat-watch-records');
const _favorites = Key('mine-stat-favorites');
const _stats = Key('mine-stats');
const _screenSize = Size(390, 844);
const _initialBalance = 320;
const _historyTotal = 57;
const _favoritesTotal = 23;
const _updatedBalance = 270;
const _updatedHistoryTotal = 58;
const _updatedFavoritesTotal = 24;
const _unavailable = '—';
const _captureEnabled = bool.fromEnvironment('MINE_STATS_CAPTURE');
const _captureKey = Key('mine-stats-capture');
const _capturePath = '/tmp/fruitshorts-mine-personal-stats.png';
void main() {
testWidgets('should_place_three_live_totals_above_vip_when_mine_opens', (
tester,
) async {
if (_captureEnabled) await loadCaptureFonts(tester);
await _pumpFixture(tester, _StatsFixture());
expect(find.byKey(_stats), findsOneWidget);
expect(_value(tester, _coins), '320');
expect(_value(tester, _history), '57');
expect(_value(tester, _favorites), '23');
final order = [
tester.getBottomLeft(find.byKey(const Key('mine-user-row'))).dy,
tester.getTopLeft(find.byKey(_stats)).dy,
tester.getBottomLeft(find.byKey(_stats)).dy,
tester.getTopLeft(find.byKey(const Key('mine-vip'))).dy,
];
expect(order, orderedEquals([...order]..sort()));
final tops = [
_coins,
_history,
_favorites,
].map((key) => tester.getTopLeft(find.byKey(key)).dy);
expect(tops.toSet(), hasLength(1));
expect(tester.takeException(), isNull);
if (_captureEnabled) {
await saveWidgetCapture(tester, _captureKey, _capturePath);
}
});
testWidgets('should_keep_device_counts_but_hide_balance_when_guest', (
tester,
) async {
final fixture = _StatsFixture();
await _pumpFixture(tester, fixture, signedIn: false);
expect(_value(tester, _coins), _unavailable);
expect(_value(tester, _history), '57');
expect(_value(tester, _favorites), '23');
expect(fixture.commerce.loads, 0);
await tester.tap(find.byKey(_coins));
await tester.pumpAndSettle();
expect(find.byKey(const Key('wallet-page')), findsNothing);
await tester.tap(find.byType(FilledButton));
await tester.pumpAndSettle();
expect(find.byType(AuthPage), findsOneWidget);
expect(find.byKey(const Key('wallet-page')), findsNothing);
});
testWidgets('should_use_placeholders_until_actual_zero_totals_load', (
tester,
) async {
final gate = Completer<void>();
final fixture = _StatsFixture()
..history.gate = gate
..favorites.gate = gate
..commerce.gate = gate
..history.total = 0
..favorites.total = 0
..commerce.balance = 0;
await _pumpFixture(tester, fixture);
for (final key in [_coins, _history, _favorites]) {
expect(_value(tester, key), _unavailable);
}
gate.complete();
await tester.pumpAndSettle();
for (final key in [_coins, _history, _favorites]) {
expect(_value(tester, key), '0');
}
});
testWidgets('should_not_display_fake_zero_when_data_requests_fail', (
tester,
) async {
final fixture = _StatsFixture()
..history.failRecent = true
..favorites.failPage = true
..commerce.failLoad = true;
await _pumpFixture(tester, fixture);
for (final key in [_coins, _history, _favorites]) {
expect(_value(tester, key), _unavailable);
}
expect(tester.takeException(), isNull);
});
testWidgets('should_open_existing_destinations_when_statistics_are_tapped', (
tester,
) async {
await _pumpFixture(tester, _StatsFixture());
const destinations = [
(_coins, Key('wallet-page')),
(_history, Key('watch-records-page')),
(_favorites, Key('favorites-page')),
];
for (final destination in destinations) {
await tester.tap(find.byKey(destination.$1));
await tester.pumpAndSettle();
expect(find.byKey(destination.$2), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
}
expect(tester.takeException(), isNull);
});
testWidgets('should_refresh_totals_when_mine_becomes_visible_again', (
tester,
) async {
final fixture = _StatsFixture();
await _pumpFixture(tester, fixture);
await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle();
fixture.history.total = _updatedHistoryTotal;
fixture.favorites.total = _updatedFavoritesTotal;
fixture.commerce.balance = _updatedBalance;
await tester.tap(find.byKey(const Key('tab-mine')));
await tester.pumpAndSettle();
expect(_value(tester, _coins), '270');
expect(_value(tester, _history), '58');
expect(_value(tester, _favorites), '24');
});
testWidgets('should_refresh_favorites_when_returning_from_favorites_page', (
tester,
) async {
final fixture = _StatsFixture();
await _pumpFixture(tester, fixture);
await tester.tap(find.byKey(_favorites));
await tester.pumpAndSettle();
fixture.favorites.total = _updatedFavoritesTotal;
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(_value(tester, _favorites), '24');
});
testWidgets('should_hide_previous_balance_immediately_when_signed_out', (
tester,
) async {
await _pumpFixture(tester, _StatsFixture());
final auth = tester.element(find.byKey(_stats)).read<AuthController>();
await auth.clear();
await tester.pumpAndSettle();
expect(_value(tester, _coins), _unavailable);
expect(find.byKey(const Key('mine-login')), findsOneWidget);
});
}
String? _value(WidgetTester tester, Key key) {
final valueKey = Key('${(key as ValueKey<String>).value}-value');
return tester.widget<Text>(find.byKey(valueKey)).data;
}
Future<void> _pumpFixture(
WidgetTester tester,
_StatsFixture fixture, {
bool signedIn = true,
}) async {
tester.view.physicalSize = _screenSize;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
SharedPreferencesAsyncPlatform.instance = signedIn
? InMemorySharedPreferencesAsync.withData({
'auth.accessToken': 'access',
'auth.refreshToken': 'refresh',
'auth.user': '{"uid":"U-100","nickname":"Viewer","avatarUrl":""}',
})
: InMemorySharedPreferencesAsync.empty();
final catalog = FakeCatalogRepository();
await tester.pumpWidget(
RepaintBoundary(
key: _captureKey,
child: LumaDramaApp(
playerFactory: createWidgetPlayer,
catalogRepository: catalog,
categoryRepository: FakeCategoryRepository(catalog: catalog),
watchHistoryRepository: fixture.history,
favoriteRepository: fixture.favorites,
commerceRepository: fixture.commerce,
playbackRepository: FakePlaybackRepository(),
commentRepository: FakeCommentRepository(),
interactionRepository: FakeInteractionRepository(),
noticeRepository: FakeNoticeRepository(),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-mine')));
await tester.pumpAndSettle();
}
class _StatsFixture {
final history = _StatsHistory();
final favorites = _StatsFavorites();
final commerce = _StatsCommerce();
}
class _StatsHistory extends InMemoryWatchHistoryRepository {
int total = _historyTotal;
bool failRecent = false;
Completer<void>? gate;
@override
Future<PageResult<WatchProgress>> recent({
int pageIndex = 1,
int pageSize = 20,
}) async {
if (gate != null) await gate!.future;
if (failRecent) throw StateError('history unavailable');
return PageResult(
total: total,
list: total == 0
? const []
: const [
WatchProgress(
dramaId: 'the-signal-demo',
episodeId: 'the-signal-demo-1',
position: Duration.zero,
duration: Duration(minutes: 1),
),
],
);
}
}
class _StatsFavorites extends InMemoryFavoriteRepository {
int total = _favoritesTotal;
Completer<void>? gate;
@override
Future<PageResult<Drama>> page({int pageIndex = 1, int pageSize = 20}) async {
if (gate != null) await gate!.future;
if (failPage) throw StateError('favorites unavailable');
return PageResult(
total: total,
list: total == 0 ? const [] : demoDramas.take(1).toList(),
);
}
}
class _StatsCommerce implements CommerceRepository {
int balance = _initialBalance;
int loads = 0;
bool failLoad = false;
Completer<void>? gate;
@override
Future<WalletSnapshot> fetchWallet() async {
loads += 1;
if (gate != null) await gate!.future;
if (failLoad) throw StateError('wallet unavailable');
return WalletSnapshot(
wallet: CoinWallet(
coinBalance: balance,
totalRechargedCoin: balance,
totalConsumedCoin: 0,
totalRefundedCoin: 0,
),
);
}
@override
Future<UnlockResult> unlock(String episodeId) async {
return UnlockResult(
unlocked: true,
accessType: 'UNLOCKED',
coinCost: 0,
coinBalance: balance,
);
}
}
+168
View File
@@ -0,0 +1,168 @@
// Author: fruit.com.cn
// 验证个人数据图标、无障碍入口与多语言窄屏布局。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/theme/app_dimens.dart';
import 'package:fruitshorts/core/theme/app_theme.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/mine/widgets/mine_stat_icon.dart';
import 'package:fruitshorts/pages/mine/widgets/mine_stats_panel.dart';
const _coinsKey = Key('mine-stat-coins');
const _historyKey = Key('mine-stat-watch-records');
const _favoritesKey = Key('mine-stat-favorites');
const _locales = [Locale('zh'), Locale('en'), Locale('id')];
const _widths = [320.0, 390.0];
const _scales = [1.0, 2.0];
const _screenHeight = 600.0;
const _largeBalance = 123456789;
const _historyTotal = 57;
const _favoritesTotal = 23;
void main() {
_responsiveTests();
_interactionTests();
}
void _responsiveTests() {
for (final locale in _locales) {
for (final width in _widths) {
for (final scale in _scales) {
testWidgets(
'should_fit_three_columns_when_${locale.languageCode}_${width}_scale_$scale',
(tester) async {
await _pumpPanel(
tester,
locale: locale,
width: width,
scale: scale,
);
final cells = [_coinsKey, _historyKey, _favoritesKey];
final positions = cells
.map((key) => tester.getTopLeft(find.byKey(key)))
.toList();
expect(positions.map((point) => point.dy).toSet(), hasLength(1));
expect(positions.map((point) => point.dx).toSet(), hasLength(3));
for (final key in cells) {
final size = tester.getSize(find.byKey(key));
expect(size.width, greaterThanOrEqualTo(AppDimens.tapTarget));
expect(size.height, greaterThanOrEqualTo(AppDimens.tapTarget));
}
expect(tester.takeException(), isNull);
},
);
}
}
}
}
void _interactionTests() {
testWidgets(
'should_render_distinct_vector_icons_when_statistics_are_visible',
(tester) async {
await _pumpPanel(tester);
final icons = tester.widgetList<MineStatIcon>(find.byType(MineStatIcon));
expect(icons.map((icon) => icon.type), MineStatIconType.values);
for (final icon in icons) {
expect(
tester.getSize(find.byKey(icon.key!)),
const Size.square(AppDimens.mineStatsIcon),
);
}
},
);
testWidgets('should_forward_each_action_once_when_statistics_are_tapped', (
tester,
) async {
final opened = <MineStatIconType>[];
await _pumpPanel(
tester,
actions: MineStatsActions(
onWallet: () => opened.add(MineStatIconType.coins),
onHistory: () => opened.add(MineStatIconType.history),
onFavorites: () => opened.add(MineStatIconType.favorites),
),
);
for (final key in [_coinsKey, _historyKey, _favoritesKey]) {
await tester.tap(find.byKey(key));
await tester.pump();
}
expect(opened, MineStatIconType.values);
});
testWidgets(
'should_announce_label_value_and_action_when_using_screen_reader',
(tester) async {
await _pumpPanel(tester);
final semantics = tester.ensureSemantics();
final coinSemantics = find.byWidgetPredicate(
(widget) =>
widget is Semantics && widget.properties.label == 'Coin balance',
);
try {
expect(
tester.getSemantics(coinSemantics),
matchesSemantics(
label: 'Coin balance',
value: '123,456,789',
isButton: true,
hasTapAction: true,
),
);
} finally {
semantics.dispose();
}
},
);
}
Future<void> _pumpPanel(
WidgetTester tester, {
Locale locale = const Locale('en'),
double width = 390,
double scale = 1,
MineStatsActions? actions,
}) async {
tester.view.physicalSize = Size(width, _screenHeight);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.dark(),
locale: locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: TextScaler.linear(scale)),
child: child!,
),
home: Scaffold(
body: Padding(
padding: const EdgeInsets.all(AppDimens.pageHorizontal),
child: MineStatsPanel(
coinBalance: _largeBalance,
watchRecordCount: _historyTotal,
favoriteCount: _favoritesTotal,
actions:
actions ??
MineStatsActions(
onWallet: () {},
onHistory: () {},
onFavorites: () {},
),
),
),
),
),
);
await tester.pumpAndSettle();
}
+60
View File
@@ -0,0 +1,60 @@
// Author: fruit.com.cn
// 为界面视觉复核加载项目字体并导出测试渲染图。
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
const double _capturePixelRatio = 3;
/// 为可选截图使用实际字体,不影响普通测试执行。
Future<void> loadCaptureFonts(WidgetTester tester) async {
await tester.runAsync(() async {
final fonts = {
'Outfit': [
'Outfit-Regular.ttf',
'Outfit-SemiBold.ttf',
'Outfit-Bold.ttf',
],
'PlusJakartaSans': [
'PlusJakartaSans-Regular.ttf',
'PlusJakartaSans-Medium.ttf',
'PlusJakartaSans-SemiBold.ttf',
'PlusJakartaSans-Bold.ttf',
],
// 测试默认字体仅作占位,截图使用项目正文字体替换。
'Ahem': ['PlusJakartaSans-Regular.ttf'],
};
for (final family in fonts.entries) {
final loader = FontLoader(family.key);
for (final asset in family.value) {
loader.addFont(rootBundle.load('assets/fonts/$asset'));
}
await loader.load();
}
final icons = FontLoader('MaterialIcons');
icons.addFont(rootBundle.load('fonts/MaterialIcons-Regular.otf'));
await icons.load();
});
}
/// 导出指定边界的渲染结果,图片为测试产物而非界面资源。
Future<void> saveWidgetCapture(
WidgetTester tester,
Key boundaryKey,
String path,
) async {
final boundary = tester.renderObject<RenderRepaintBoundary>(
find.byKey(boundaryKey),
);
await tester.runAsync(() async {
final image = await boundary.toImage(pixelRatio: _capturePixelRatio);
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
if (bytes == null) throw StateError('Screenshot encoding failed');
await File(path).writeAsBytes(bytes.buffer.asUint8List());
image.dispose();
});
}