feat(mine): 重做我的页会员卡与功能宫格布局
This commit is contained in:
1 parent
e7399fa3fd
commit
53e8c924a6
12 files changed
+1969
-513
No files matched your search
@@ -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 _) {}
|
||||
@@ -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++,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user