feat(player): 重做播放页信息层级与互动栏点击动效

This commit is contained in:
Vicky authored and trae-solo-agent committed 2026-10-30 22:53:18 +08:00
1 parent aed9ee2b01
commit fd2ba0bf43
15 files changed
+2074 -551

No files matched your search

+467
View File
@@ -0,0 +1,467 @@
// Author: fruit.com.cn
// 职责:验证推荐视频底部信息的无阴影、视觉层级与观看条留白。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.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/player/widgets/home_feed_overlay.dart';
const _drama = Drama(
id: 'info-hierarchy',
title: 'The Last Promise',
synopsis:
'A hidden promise brings two families together after years apart. '
'Every secret leads them closer to an unexpected truth.',
titleLocales: {'id': 'Janji Terakhir'},
synopsisLocales: {
'id':
'Sebuah janji tersembunyi mempertemukan kembali dua keluarga yang '
'telah lama terpisah. Setiap rahasia membawa mereka semakin dekat '
'pada kebenaran yang tidak pernah mereka bayangkan sebelumnya.',
},
posterUrl: '',
genre: 'Romance',
category: 'Urban',
types: [
{'zh': '甜宠', 'en': 'Romance', 'id': 'Romantis'},
{'zh': '都市', 'en': 'Urban', 'id': 'Perkotaan'},
],
viewCount: 120000,
totalEpisodes: 24,
);
const _episode = Episode(id: 'info-2', number: 2, title: '', isFree: true);
const _gap = 2.0;
const _metadataGap = 8.0;
const _epsilon = 0.1;
const _wideWidth = 500.0;
const _maximumTags = 2;
const _shortTag = 'A';
const _secondTag = 'B';
const _extraTag = 'C';
const _motionSample = Duration(milliseconds: 80);
const _watchRadius = 6.0;
Future<void> _pump(
WidgetTester tester, {
String language = 'en',
double width = 390,
double scale = 1,
Drama drama = _drama,
}) async {
tester.view.physicalSize = Size(width, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.dark(),
locale: Locale(language),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: TextScaler.linear(scale)),
child: Scaffold(
body: HomeFeedOverlay(
l10n: AppLocalizations.of(context)!,
drama: drama,
episode: _episode,
controller: null,
locked: false,
liked: false,
saved: false,
visible: true,
boosting: false,
onLike: () {},
onSave: () {},
onComments: () {},
onShare: () {},
onDetail: () {},
onOpenFullPlayer: () {},
onOpenSearch: () {},
onTogglePlay: () {},
onBoostStart: () {},
onBoostEnd: () {},
onMore: () {},
),
),
),
),
),
);
}
Iterable<TextStyle> _spanStyles(InlineSpan? span) sync* {
if (span is! TextSpan) return;
if (span.style != null) yield span.style!;
for (final child in span.children ?? const <InlineSpan>[]) {
yield* _spanStyles(child);
}
}
void _expectNoShadows(WidgetTester tester) {
final panel = find.byKey(const Key('home-feed-info-panel'));
for (final text in tester.widgetList<Text>(
find.descendant(of: panel, matching: find.byType(Text)),
)) {
final styles = [
if (text.style != null) text.style!,
..._spanStyles(text.textSpan),
];
for (final style in styles) {
expect(style.shadows ?? const <Shadow>[], isEmpty);
}
}
}
void _expectWatchGap(WidgetTester tester) {
final panel = tester.getRect(find.byKey(const Key('home-feed-info-panel')));
final watch = tester.getRect(find.byKey(const Key('home-feed-full-series')));
expect(watch.top - panel.bottom, closeTo(_gap, _epsilon));
}
void _expectSynopsisTopGap(WidgetTester tester, Finder synopsis) {
final metadata = tester.getRect(find.byKey(const Key('home-feed-metadata')));
expect(
tester.getRect(synopsis).top - metadata.bottom,
closeTo(_gap, _epsilon),
);
}
void _expectExpandedSynopsis(WidgetTester tester) {
final expanded = find.byKey(const Key('home-feed-synopsis-expanded'));
expect(
tester.widget<Text>(expanded).textSpan?.style?.color,
AppColors.overlaySecondary,
);
_expectNoShadows(tester);
_expectWatchGap(tester);
_expectSynopsisTopGap(tester, expanded);
}
void main() {
testWidgets('should_render_complete_chinese_toggle_when_theme_font_applies', (
tester,
) async {
await _pump(tester, language: 'zh', width: 320, scale: 1.3);
final toggle = find.byKey(const Key('home-feed-synopsis-more'));
final rect = tester.getRect(toggle);
for (final label in ['展开', '收起']) {
final rich = find.descendant(of: toggle, matching: find.byType(RichText));
final paragraph = tester.widget<RichText>(rich);
expect(paragraph.text.toPlainText(), label);
final painter = TextPainter(
text: paragraph.text,
textDirection: paragraph.textDirection ?? TextDirection.ltr,
textScaler: paragraph.textScaler,
locale: paragraph.locale,
maxLines: 1,
)..layout();
expect(
tester.getSize(rich).width + _epsilon,
greaterThanOrEqualTo(painter.width),
);
expect(
tester.getSize(rich).height + _epsilon,
greaterThanOrEqualTo(painter.height),
);
painter.dispose();
expect(tester.getRect(toggle), rect);
await tester.tap(toggle);
await tester.pumpAndSettle();
}
expect(tester.takeException(), isNull);
});
testWidgets('should_use_neutral_text_when_rendering_type_tag', (
tester,
) async {
await _pump(tester);
final tag = tester.widget<Text>(
find.descendant(
of: find.byKey(const Key('home-feed-tag-Romance')),
matching: find.byType(Text),
),
);
expect(tag.style?.color, AppColors.overlaySecondary);
});
testWidgets('should_skip_motion_when_system_disables_animations', (
tester,
) async {
tester.platformDispatcher.accessibilityFeaturesTestValue =
const FakeAccessibilityFeatures(disableAnimations: true);
addTearDown(tester.platformDispatcher.clearAccessibilityFeaturesTestValue);
await _pump(tester);
expect(find.byKey(const Key('home-feed-synopsis-size')), findsNothing);
await tester.tap(find.byKey(const Key('home-feed-synopsis-more')));
await tester.pumpAndSettle();
_expectExpandedSynopsis(tester);
expect(tester.takeException(), isNull);
});
testWidgets('should_reverse_cleanly_when_toggling_during_motion', (
tester,
) async {
await _pump(tester);
final panel = find.byKey(const Key('home-feed-info-panel'));
final height = tester.getSize(panel).height;
final toggle = find.byKey(const Key('home-feed-synopsis-more'));
final position = tester.getRect(toggle);
for (final _ in [false, true]) {
await tester.tap(toggle);
await tester.pump();
await tester.pump(_motionSample);
expect(tester.getRect(toggle), position);
}
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-feed-synopsis-expanded')), findsNothing);
expect(tester.getSize(panel).height, closeTo(height, _epsilon));
_expectWatchGap(tester);
expect(tester.takeException(), isNull);
});
testWidgets('should_omit_toggle_when_synopsis_fits_two_lines', (
tester,
) async {
await _pump(
tester,
drama: const Drama(
id: 'short-synopsis',
title: 'Short',
synopsis: 'A short story.',
posterUrl: '',
),
);
expect(find.byKey(const Key('home-feed-synopsis')), findsOneWidget);
expect(find.byKey(const Key('home-feed-synopsis-more')), findsNothing);
_expectWatchGap(tester);
expect(tester.takeException(), isNull);
});
testWidgets('should_remove_tag_surfaces_when_rendering_types', (
tester,
) async {
await _pump(tester);
for (final key in ['home-feed-tag-Urban', 'home-feed-tag-Romance']) {
expect(find.byKey(Key(key)), findsOneWidget);
expect(
find.descendant(
of: find.byKey(Key(key)),
matching: find.byType(DecoratedBox),
),
findsNothing,
);
}
_expectNoShadows(tester);
});
testWidgets('should_use_small_radius_and_brand_gradient_when_watching_all', (
tester,
) async {
await _pump(tester);
final background = find.byKey(const Key('home-feed-full-series-bg'));
final decorated = tester.widget<DecoratedBox>(
find.byKey(const Key('home-feed-full-series-gradient')),
);
final decoration = decorated.decoration as BoxDecoration;
expect((decoration.gradient! as LinearGradient).colors, [
AppColors.mineStatShade,
AppColors.brandRed,
AppColors.vipButtonEnd,
]);
final gradient = decoration.gradient! as LinearGradient;
expect(gradient.begin, Alignment.centerLeft);
expect(gradient.end, Alignment.centerRight);
expect(tester.widget<Text>(find.text('Watch all')).style?.fontSize, 16);
expect(
tester.widget<Text>(find.text('Watch all')).style?.fontWeight,
FontWeight.w400,
);
final clip = tester.widget<ClipRRect>(
find.ancestor(of: background, matching: find.byType(ClipRRect)).first,
);
expect(clip.borderRadius, BorderRadius.circular(_watchRadius));
});
for (final hot in [false, true]) {
testWidgets(
'should_limit_tags_and_follow_them_with_episode_when_hot_is_$hot',
(tester) async {
await _pump(
tester,
width: _wideWidth,
drama: Drama(
id: 'compact-tags',
title: 'Compact',
synopsis: '',
posterUrl: '',
types: const [
{'en': _shortTag},
{'en': _secondTag},
{'en': _extraTag},
],
isHot: hot,
totalEpisodes: _drama.totalEpisodes,
),
);
final strip = find.byKey(const Key('home-feed-tags'));
final row = tester.widget<Row>(
find.descendant(of: strip, matching: find.byType(Row)),
);
expect(row.children.length, _maximumTags * 2 - 1);
final lastTag = find.byKey(const Key('home-feed-tag-$_secondTag'));
expect(lastTag, findsOneWidget);
expect(find.byKey(const Key('home-feed-tag-$_extraTag')), findsNothing);
expect(find.byKey(const Key('home-feed-hot-badge')), findsNothing);
final episode = find.byKey(const Key('home-feed-episode-meta'));
expect(
tester.getRect(episode).left - tester.getRect(lastTag).right,
closeTo(_metadataGap, _epsilon),
);
_expectNoShadows(tester);
expect(tester.takeException(), isNull);
},
);
}
testWidgets(
'should_remove_text_shadows_and_quieten_metadata_when_collapsed',
(tester) async {
await _pump(tester);
_expectNoShadows(tester);
final title = tester.widget<Text>(
find.byKey(const Key('home-feed-series-title')),
);
expect(title.style?.fontWeight, FontWeight.w400);
expect(title.style?.fontSize, 18);
expect(title.style?.color, AppColors.textPrimary);
final synopsis = tester.widget<Text>(
find.byKey(const Key('home-feed-synopsis')),
);
expect(synopsis.style?.color, AppColors.overlaySecondary);
expect(synopsis.style?.fontSize, 15);
expect(synopsis.maxLines, 2);
final episode = tester.widget<Text>(
find.byKey(const Key('home-feed-episode-meta')),
);
expect(episode.style?.color, AppColors.textPrimary);
expect(episode.style?.fontSize, 14);
expect(episode.data, 'Episode 2 / 24');
final tag = tester.widget<Text>(find.text('Romance'));
expect(tag.style?.color, AppColors.overlaySecondary);
expect(tag.style?.color, synopsis.style?.color);
expect(tag.style?.fontSize, 13);
final hotSearch = tester.widget<Text>(
find.descendant(
of: find.byKey(const Key('home-feed-hot-search-pill')),
matching: find.byType(Text),
),
);
expect(hotSearch.style?.fontSize, 13);
_expectWatchGap(tester);
final titleRect = tester.getRect(
find.byKey(const Key('home-feed-series-name')),
);
final metadata = tester.getRect(
find.byKey(const Key('home-feed-metadata')),
);
final tags = tester.getRect(find.byKey(const Key('home-feed-tags')));
final episodeRect = tester.getRect(
find.byKey(const Key('home-feed-episode-meta')),
);
final description = tester.getRect(
find.byKey(const Key('home-feed-synopsis')),
);
expect(metadata.top - titleRect.bottom, closeTo(_gap, _epsilon));
expect(tags.center.dy, closeTo(episodeRect.center.dy, _epsilon));
expect(description.top - metadata.bottom, closeTo(_gap, _epsilon));
final panel = tester.getRect(
find.byKey(const Key('home-feed-info-panel')),
);
final watch = tester.getRect(
find.byKey(const Key('home-feed-full-series')),
);
final rail = tester.getRect(
find.byKey(const Key('home-feed-action-rail')),
);
expect(watch.left, panel.left);
expect(watch.right, panel.right);
expect(watch.right, lessThan(rail.left));
expect(
tester.getCenter(find.text('Watch all')).dx,
closeTo(watch.center.dx, _epsilon),
);
},
);
for (final language in ['zh', 'en', 'id']) {
testWidgets('should_keep_clear_gap_when_expanded_in_$language', (
tester,
) async {
await _pump(tester, language: language, width: 320, scale: 1.3);
final toggle = find.byKey(const Key('home-feed-synopsis-more'));
final toggleRect = tester.getRect(toggle);
final panel = find.byKey(const Key('home-feed-info-panel'));
final collapsedHeight = tester.getSize(panel).height;
final label = tester.widget<Text>(
find.descendant(of: toggle, matching: find.byType(Text)),
);
expect(label.style?.fontWeight, FontWeight.w400);
expect(label.style?.fontSize, 15);
_expectWatchGap(tester);
_expectSynopsisTopGap(
tester,
find.byKey(const Key('home-feed-synopsis-layout')),
);
await tester.tap(toggle);
await tester.pump();
await tester.pump(_motionSample);
final growingHeight = tester.getSize(panel).height;
expect(tester.getRect(toggle), toggleRect);
await tester.pumpAndSettle();
final expandedHeight = tester.getSize(panel).height;
expect(growingHeight, greaterThan(collapsedHeight));
expect(growingHeight, lessThan(expandedHeight));
_expectExpandedSynopsis(tester);
expect(tester.getRect(toggle), toggleRect);
await tester.tap(toggle);
await tester.pump();
await tester.pump(_motionSample);
final shrinkingHeight = tester.getSize(panel).height;
expect(shrinkingHeight, greaterThan(collapsedHeight));
expect(shrinkingHeight, lessThan(expandedHeight));
expect(tester.getRect(toggle), toggleRect);
await tester.pumpAndSettle();
expect(tester.getSize(panel).height, closeTo(collapsedHeight, _epsilon));
_expectWatchGap(tester);
expect(tester.takeException(), isNull);
});
}
testWidgets('should_keep_episode_without_empty_tags_or_unknown_total', (
tester,
) async {
await _pump(
tester,
drama: const Drama(
id: 'minimal',
title: 'Minimal',
synopsis: '',
posterUrl: '',
),
);
final episode = tester.widget<Text>(
find.byKey(const Key('home-feed-episode-meta')),
);
expect(episode.data, 'EP2');
expect(find.byKey(const Key('home-feed-tags')), findsNothing);
expect(find.byKey(const Key('home-feed-synopsis')), findsNothing);
_expectWatchGap(tester);
expect(tester.takeException(), isNull);
});
}
+254
View File
@@ -0,0 +1,254 @@
// Author: fruit.com.cn
// 职责:验证首页标签仅取 types 前两项,以及多语言/字号下与集数的文字基线对齐。
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/theme/app_theme.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/player/widgets/home_feed_recommend_panel.dart';
const _types = [
{'zh': '都市', 'en': 'Urban', 'id': 'Perkotaan'},
{'zh': '甜宠', 'en': 'Romance', 'id': 'Romantis'},
{'zh': '复仇', 'en': 'Revenge', 'id': 'Balas Dendam'},
];
const _typedDrama = Drama(
id: 'types-alignment',
title: 'Types',
synopsis: '',
posterUrl: '',
types: _types,
genre: 'Legacy genre',
category: 'Legacy category',
isHot: true,
totalEpisodes: 24,
);
const _episode = Episode(id: 'types-2', number: 2, title: '', isFree: true);
const _tolerance = 0.1;
const _narrowWidth = 320.0;
const _regularWidth = 390.0;
const _largeScale = 1.3;
const _tagCount = 2;
const _longType = 'A very long urban romance type description';
const _scrollDelta = Offset(-180, 0);
Future<void> _pump(
WidgetTester tester, {
Drama drama = _typedDrama,
String language = 'en',
double scale = 1,
double width = _narrowWidth,
}) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.dark(),
locale: Locale(language),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: TextScaler.linear(scale)),
child: Scaffold(
body: Align(
alignment: Alignment.bottomLeft,
child: SizedBox(
width: width,
child: HomeFeedInfoPanel(
drama: drama,
episode: _episode,
onDetail: () {},
onOpenFullPlayer: () {},
),
),
),
),
),
),
),
);
}
double _baseline(WidgetTester tester, Finder text) {
final rich = find.descendant(of: text, matching: find.byType(RichText));
final paragraph = tester.renderObject<RenderParagraph>(rich);
final widget = tester.widget<RichText>(rich);
final painter =
TextPainter(
text: widget.text,
textDirection: widget.textDirection ?? TextDirection.ltr,
textScaler: widget.textScaler,
strutStyle: widget.strutStyle,
textWidthBasis: widget.textWidthBasis,
textHeightBehavior: widget.textHeightBehavior,
locale: widget.locale,
maxLines: widget.maxLines,
)..layout(
minWidth: paragraph.constraints.minWidth,
maxWidth: paragraph.constraints.maxWidth,
);
final distance = painter.computeLineMetrics().first.baseline;
painter.dispose();
return tester.getTopLeft(rich).dy + distance;
}
Finder _tag(String label) => find.byKey(Key('home-feed-tag-$label'));
void main() {
testWidgets('should_use_first_two_types_when_drama_is_hot', (tester) async {
await _pump(tester);
final first = _tag('Urban');
final second = _tag('Romance');
expect(first, findsOneWidget);
expect(second, findsOneWidget);
expect(_tag('Revenge'), findsNothing);
expect(find.byKey(const Key('home-feed-hot-badge')), findsNothing);
expect(_tag(_typedDrama.genre), findsNothing);
expect(_tag(_typedDrama.category), findsNothing);
expect(tester.getRect(first).left, lessThan(tester.getRect(second).left));
});
testWidgets('should_omit_legacy_fallback_when_types_are_empty', (
tester,
) async {
await _pump(
tester,
drama: const Drama(
id: 'empty-types',
title: '',
synopsis: '',
posterUrl: '',
genre: 'Legacy genre',
category: 'Legacy category',
categoryLocales: {'zh': '旧分类', 'en': 'Old category'},
isHot: true,
),
);
expect(find.byKey(const Key('home-feed-tags')), findsNothing);
expect(find.byKey(const Key('home-feed-episode-meta')), findsOneWidget);
expect(find.byKey(const Key('home-feed-hot-badge')), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('should_keep_only_existing_type_when_types_have_one_item', (
tester,
) async {
await _pump(
tester,
drama: const Drama(
id: 'one-type',
title: '',
synopsis: '',
posterUrl: '',
types: [
{'zh': '都市', 'en': 'Urban'},
],
genre: 'Legacy genre',
isHot: true,
),
);
expect(_tag('Urban'), findsOneWidget);
expect(find.byKey(const Key('home-feed-hot-badge')), findsNothing);
expect(_tag('Legacy genre'), findsNothing);
});
testWidgets('should_not_fill_from_third_type_when_first_type_is_empty', (
tester,
) async {
await _pump(
tester,
drama: const Drama(
id: 'blank-type',
title: '',
synopsis: '',
posterUrl: '',
types: [
{'zh': '', 'en': ''},
{'zh': '都市', 'en': 'Urban'},
{'zh': '甜宠', 'en': 'Romance'},
],
),
);
expect(_tag('Urban'), findsOneWidget);
expect(_tag('Romance'), findsNothing);
});
testWidgets('should_use_chinese_fallback_when_type_translation_is_absent', (
tester,
) async {
await _pump(
tester,
language: 'id',
drama: const Drama(
id: 'type-fallback',
title: '',
synopsis: '',
posterUrl: '',
types: [
{'zh': '都市', 'en': 'Urban'},
{'zh': '甜宠', 'id': 'Romantis'},
],
),
);
expect(_tag('都市'), findsOneWidget);
expect(_tag('Romantis'), findsOneWidget);
});
testWidgets('should_keep_episode_aligned_when_long_types_scroll', (
tester,
) async {
await _pump(
tester,
scale: _largeScale,
drama: const Drama(
id: 'long-types',
title: '',
synopsis: '',
posterUrl: '',
totalEpisodes: 24,
types: [
{'en': _longType},
{'en': 'Romance'},
],
),
);
final strip = find.byKey(const Key('home-feed-tags'));
final episode = find.byKey(const Key('home-feed-episode-meta'));
final episodeRect = tester.getRect(episode);
final tagRect = tester.getRect(_tag(_longType));
expect(tagRect.right, greaterThan(tester.getRect(strip).right));
await tester.drag(strip, _scrollDelta);
await tester.pumpAndSettle();
expect(tester.getRect(_tag(_longType)).left, lessThan(tagRect.left));
expect(tester.getRect(episode), episodeRect);
expect(
_baseline(tester, _tag(_longType)),
closeTo(_baseline(tester, episode), _tolerance),
);
expect(tester.takeException(), isNull);
});
for (final language in ['zh', 'en', 'id']) {
for (final scale in [1.0, _largeScale]) {
for (final width in [_narrowWidth, _regularWidth]) {
testWidgets(
'should_align_type_baselines_when_${language}_${scale}_$width',
(tester) async {
await _pump(tester, language: language, scale: scale, width: width);
final episode = find.byKey(const Key('home-feed-episode-meta'));
final expected = _baseline(tester, episode);
for (final type in _types.take(_tagCount)) {
expect(
_baseline(tester, _tag(type[language]!)),
closeTo(expected, _tolerance),
);
}
expect(tester.takeException(), isNull);
},
);
}
}
}
}
@@ -0,0 +1,78 @@
// Author: fruit.com.cn
// 职责:检查观看全集按钮实际绘制像素,防止渐变只配置而不可见。
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/player/widgets/home_feed_recommend_panel.dart';
const _boundaryKey = Key('watch-gradient-boundary');
const _width = 280.0;
const _sampleInset = 16;
const _sampleY = 4;
const _rgbaChannels = 4;
const _minimumRedDelta = 50;
const _drama = Drama(
id: 'gradient',
title: 'Gradient',
synopsis: '',
posterUrl: '',
);
Future<void> _pumpButton(WidgetTester tester) => tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Center(
child: RepaintBoundary(
key: _boundaryKey,
child: SizedBox(
width: _width,
child: HomeFeedWatchAllBar(drama: _drama, onTap: () {}),
),
),
),
),
),
);
Color _sample(ByteData bytes, int width, int x) {
final offset = (_sampleY * width + x) * _rgbaChannels;
return Color.fromARGB(
bytes.getUint8(offset + 3),
bytes.getUint8(offset),
bytes.getUint8(offset + 1),
bytes.getUint8(offset + 2),
);
}
void main() {
testWidgets('should_paint_visible_red_gradient_instead_of_flat_fill', (
tester,
) async {
await _pumpButton(tester);
final boundary = tester.renderObject<RenderRepaintBoundary>(
find.byKey(_boundaryKey),
);
await tester.runAsync(() async {
final image = await boundary.toImage();
final bytes = (await image.toByteData(
format: ui.ImageByteFormat.rawRgba,
))!;
final left = _sample(bytes, image.width, _sampleInset);
final middle = _sample(bytes, image.width, image.width ~/ 2);
final right = _sample(bytes, image.width, image.width - _sampleInset);
image.dispose();
expect(left.a, 1);
expect(right.a, 1);
expect((right.r - left.r) * 255, greaterThan(_minimumRedDelta));
expect(left.g, lessThan(middle.g));
expect(middle.g, lessThan(right.g));
});
});
}
+118
View File
@@ -0,0 +1,118 @@
// Author: fruit.com.cn
// 职责:验证共用简介的短文案、展开声明、内容更新与减少动画行为。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/theme/app_theme.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/player/widgets/expandable_synopsis.dart';
const _width = 300.0;
const _shortCopy = 'A short story.';
const _longCopy =
'A hidden promise brings two families together after years apart. '
'Every secret leads them closer to an unexpected truth. '
'Their journey through the city changes everything they once believed.';
const _toggle = Key('player-synopsis-more');
const _expanded = Key('player-synopsis-expanded');
const _collapsed = Key('player-synopsis');
const _footer = Text('Author declaration', key: Key('test-author-note'));
const _motionSample = Duration(milliseconds: 80);
Future<void> _pump(
WidgetTester tester, {
String synopsis = _longCopy,
bool disableAnimations = false,
}) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.dark(),
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(disableAnimations: disableAnimations),
child: Scaffold(
body: Align(
alignment: Alignment.bottomLeft,
child: SizedBox(
width: _width,
child: ExpandableSynopsis(
synopsis: synopsis,
keyPrefix: 'player',
expandedFooter: _footer,
),
),
),
),
),
),
),
);
}
void main() {
testWidgets('should_hide_toggle_when_synopsis_fits_two_lines', (
tester,
) async {
await _pump(tester, synopsis: _shortCopy);
expect(find.byKey(_toggle), findsNothing);
expect(tester.widget<Text>(find.byKey(_collapsed)).maxLines, 2);
expect(find.byKey(_footer.key!), findsNothing);
});
testWidgets('should_render_no_copy_when_synopsis_is_empty', (tester) async {
await _pump(tester, synopsis: '');
expect(find.byKey(_collapsed), findsNothing);
expect(find.byKey(_toggle), findsNothing);
expect(find.byKey(_footer.key!), findsNothing);
});
testWidgets('should_only_show_author_note_when_synopsis_is_expanded', (
tester,
) async {
await _pump(tester);
final toggle = find.byKey(_toggle);
final toggleRect = tester.getRect(toggle);
expect(find.byKey(_footer.key!), findsNothing);
await tester.tap(toggle);
await tester.pumpAndSettle();
expect(find.byKey(_expanded), findsOneWidget);
expect(find.byKey(_footer.key!), findsOneWidget);
expect(tester.getRect(toggle), toggleRect);
await tester.tap(toggle);
await tester.pumpAndSettle();
expect(find.byKey(_collapsed), findsOneWidget);
expect(find.byKey(_footer.key!), findsNothing);
expect(tester.getRect(toggle), toggleRect);
});
testWidgets('should_collapse_again_when_synopsis_changes', (tester) async {
await _pump(tester);
await tester.tap(find.byKey(_toggle));
await tester.pumpAndSettle();
expect(find.byKey(_expanded), findsOneWidget);
await _pump(tester, synopsis: '$_longCopy $_shortCopy');
await tester.pumpAndSettle();
expect(find.byKey(_expanded), findsNothing);
expect(find.byKey(_collapsed), findsOneWidget);
expect(find.byKey(_footer.key!), findsNothing);
});
testWidgets('should_toggle_immediately_when_animations_are_disabled', (
tester,
) async {
await _pump(tester, disableAnimations: true);
await tester.pumpAndSettle();
expect(find.byKey(const Key('player-synopsis-size')), findsNothing);
await tester.tap(find.byKey(_toggle));
await tester.pump();
expect(find.byKey(_expanded), findsOneWidget);
expect(find.byKey(_footer.key!), findsOneWidget);
final expandedRect = tester.getRect(find.byKey(_expanded));
await tester.pump(_motionSample);
expect(tester.getRect(find.byKey(_expanded)), expandedRect);
});
}
@@ -0,0 +1,171 @@
// Author: fruit.com.cn
// 职责:验证真实主题及素材在同步状态刷新和保留状态重组后的点击绘制。
import 'dart:async';
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_theme.dart';
import 'package:fruitshorts/pages/player/widgets/interaction_rail.dart';
import 'package:fruitshorts/pages/player/widgets/interaction_rail_burst.dart';
const _itemKey = Key('asset-like');
const _burstKey = Key('rail-tap-burst');
const _buttonSize = 48.0;
const _iconSize = 36.0;
const _motionFrame = Duration(milliseconds: 130);
const _asset = 'assets/icon/rail/rail_like.png';
const _epsilon = 0.01;
const _rayLimit = 26.4;
const _rayLengthLimit = 3.5;
const _burstPhases = [0.2, 0.5, 0.8];
Finder get _button => find.descendant(
of: find.byKey(_itemKey),
matching: find.byType(IconButton),
);
Finder get _image =>
find.descendant(of: find.byKey(_itemKey), matching: find.byType(Image));
double _paintedWidth(WidgetTester tester) {
final icon = tester.renderObject<RenderBox>(_image);
final rail = tester.renderObject<RenderBox>(find.byKey(_itemKey));
return MatrixUtils.transformRect(
icon.getTransformTo(rail),
Offset.zero & icon.size,
).width;
}
Future<void> _pumpAssetRail(
WidgetTester tester,
ValueNotifier<bool> selected,
) => tester.pumpWidget(
MaterialApp(
theme: AppTheme.dark(),
home: Scaffold(
body: Center(
child: ValueListenableBuilder<bool>(
valueListenable: selected,
builder: (context, value, _) => InteractionRail(
buttonSize: _buttonSize,
iconSize: _iconSize,
items: [
InteractionRailItem(
key: _itemKey,
label: 'Like',
asset: _asset,
selected: value,
selectedColor: AppColors.brandRed,
animateTap: true,
onTap: () => selected.value = !selected.value,
),
],
),
),
),
),
),
);
void main() {
testWidgets('should_not_show_ink_or_shadow_when_button_is_pressed', (
tester,
) async {
final selected = ValueNotifier(false);
addTearDown(selected.dispose);
await _pumpAssetRail(tester, selected);
final button = tester.widget<IconButton>(_button);
const pressed = {WidgetState.pressed};
expect(button.style?.overlayColor?.resolve(pressed), AppColors.transparent);
expect(button.style?.shadowColor?.resolve(pressed), AppColors.transparent);
expect(button.style?.splashFactory, NoSplash.splashFactory);
expect(button.style?.elevation?.resolve(pressed), 0);
});
for (final contracting in [false, true]) {
testWidgets('should_keep_rays_compact_when_contracting_$contracting', (
tester,
) async {
for (final progress in _burstPhases) {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: SizedBox.square(
dimension: _buttonSize,
child: RailTapBurst(
progress: progress,
color: AppColors.brandRed,
contracting: contracting,
),
),
),
),
);
final paint = tester.renderObject(find.byType(CustomPaint));
expect(paint, paintsExactlyCountTimes(#drawCircle, 0));
expect(paint, paintsExactlyCountTimes(#drawLine, 8));
expect(paint, paints..everything(_isCompactRay));
}
});
}
testWidgets('should_paint_feedback_at_button_size_when_asset_is_tapped', (
tester,
) async {
final selected = ValueNotifier(false);
addTearDown(selected.dispose);
await _pumpAssetRail(tester, selected);
final touchRect = tester.getRect(_button);
await tester.tap(_button);
await tester.pump();
await tester.pump(_motionFrame);
expect(selected.value, isTrue);
expect(_paintedWidth(tester), greaterThan(_iconSize));
expect(
tester.getSize(find.byKey(_burstKey)),
const Size.square(_buttonSize),
);
expect(tester.getRect(_button), touchRect);
await tester.pumpAndSettle();
expect(_paintedWidth(tester), closeTo(_iconSize, _epsilon));
expect(tester.takeException(), isNull);
});
testWidgets(
'should_paint_opposite_directions_after_preserved_state_reassembly',
(tester) async {
final selected = ValueNotifier(false);
addTearDown(selected.dispose);
await _pumpAssetRail(tester, selected);
unawaited(tester.binding.reassembleApplication());
await tester.pump();
for (final cancelling in [false, true]) {
await tester.tap(_button);
await tester.pump();
await tester.pump(_motionFrame);
expect(
_paintedWidth(tester),
cancelling ? lessThan(_iconSize) : greaterThan(_iconSize),
);
final feedback = tester.widget<RailTapBurst>(find.byKey(_burstKey));
expect(feedback.contracting, cancelling);
expect(selected.value, !cancelling);
await tester.pumpAndSettle();
expect(_paintedWidth(tester), closeTo(_iconSize, _epsilon));
}
expect(tester.takeException(), isNull);
},
);
}
bool _isCompactRay(Symbol method, List<dynamic> arguments) {
if (method != #drawLine) return true;
const center = Offset(_buttonSize / 2, _buttonSize / 2);
final start = arguments[0] as Offset;
final end = arguments[1] as Offset;
return (start - center).distance <= _rayLimit &&
(end - center).distance <= _rayLimit &&
(end - start).distance <= _rayLengthLimit;
}
@@ -0,0 +1,233 @@
// Author: fruit.com.cn
// 职责:验证收藏点赞图标点击回弹、固定触控区与减少动画行为。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/pages/player/widgets/interaction_rail.dart';
import 'package:fruitshorts/pages/player/widgets/interaction_rail_burst.dart';
const _saveKey = Key('save');
const _likeKey = Key('like');
const _commentsKey = Key('comments');
const _pressFrame = Duration(milliseconds: 30);
const _popFrame = Duration(milliseconds: 100);
const _buttonSize = 48.0;
const _compactGap = 6.0;
const _epsilon = 0.01;
const _cancelledMaximum = 0.8;
const _popMinimum = 1.22;
const _burstKey = Key('rail-tap-burst');
const _directionFrame = Duration(milliseconds: 60);
const _directionFrames = 4;
Future<void> _pumpRail(
WidgetTester tester, {
required VoidCallback onTap,
bool selected = false,
bool reduceMotion = false,
}) => tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: MediaQueryData(disableAnimations: reduceMotion),
child: Scaffold(
body: Center(
child: InteractionRail(
itemGap: interactionRailCompactGap,
buttonSize: _buttonSize,
items: [
InteractionRailItem(
key: _saveKey,
label: 'Save',
icon: Icons.bookmark_outline,
selected: selected,
animateTap: true,
onTap: onTap,
),
InteractionRailItem(
key: _likeKey,
label: 'Like',
icon: Icons.favorite_outline,
selected: selected,
animateTap: true,
onTap: onTap,
),
InteractionRailItem(
key: _commentsKey,
label: 'Comments',
icon: Icons.chat_bubble_outline,
onTap: onTap,
),
],
),
),
),
),
),
);
Finder _button(Key key) =>
find.descendant(of: find.byKey(key), matching: find.byType(IconButton));
double _scale(WidgetTester tester, Key key) => tester
.widget<Transform>(
find
.descendant(of: find.byKey(key), matching: find.byType(Transform))
.first,
)
.transform
.entry(0, 0);
void _expectBurst(WidgetTester tester, Key key, bool cancelling) {
final burst = find.descendant(
of: find.byKey(key),
matching: find.byKey(_burstKey),
);
expect(burst, findsOneWidget);
final feedback = tester.widget<RailTapBurst>(burst);
expect(feedback.contracting, cancelling);
expect(feedback.color, cancelling ? AppColors.onPrimary : AppColors.brandRed);
final paint = find.descendant(of: burst, matching: find.byType(CustomPaint));
expect(tester.renderObject(paint), paints..line());
expect(tester.renderObject(paint), paintsExactlyCountTimes(#drawCircle, 0));
}
void main() {
testWidgets('should_keep_compact_spacing_when_rail_is_idle', (tester) async {
await _pumpRail(tester, onTap: () {});
final save = tester.getRect(find.byKey(_saveKey));
final like = tester.getRect(find.byKey(_likeKey));
expect(like.top - save.bottom, closeTo(_compactGap, _epsilon));
});
for (final key in [_saveKey, _likeKey]) {
for (final selected in [false, true]) {
testWidgets(
'should_pop_icon_once_when_tapped_${key}_selected_$selected',
(tester) async {
var calls = 0;
await _pumpRail(tester, selected: selected, onTap: () => calls++);
final rect = tester.getRect(_button(key));
final itemRect = tester.getRect(find.byKey(key));
await tester.tap(_button(key));
await tester.pump();
await tester.pump(_pressFrame);
expect(_scale(tester, key), selected ? lessThan(1) : greaterThan(1));
await tester.pump(_popFrame);
expect(
_scale(tester, key),
selected ? lessThan(_cancelledMaximum) : greaterThan(_popMinimum),
);
_expectBurst(tester, key, selected);
expect(tester.getRect(_button(key)), rect);
expect(tester.getRect(find.byKey(key)), itemRect);
expect(rect.size, const Size.square(_buttonSize));
expect(calls, 1);
for (var frame = 0; frame < _directionFrames; frame++) {
await tester.pump(_directionFrame);
expect(
_scale(tester, key),
selected ? lessThanOrEqualTo(1) : greaterThanOrEqualTo(1),
);
}
await tester.pumpAndSettle();
expect(find.byKey(_burstKey), findsNothing);
expect(_scale(tester, key), closeTo(1, _epsilon));
expect(tester.takeException(), isNull);
},
);
}
}
testWidgets(
'should_restart_feedback_without_duplicate_callbacks_when_rapid',
(tester) async {
var calls = 0;
await _pumpRail(tester, onTap: () => calls++);
for (final _ in [false, true]) {
await tester.tap(_button(_likeKey));
await tester.pump();
await tester.pump(_popFrame);
}
expect(calls, 2);
await tester.pumpAndSettle();
expect(_scale(tester, _likeKey), closeTo(1, _epsilon));
expect(tester.takeException(), isNull);
},
);
testWidgets('should_keep_callbacks_without_motion_when_reduced', (
tester,
) async {
var calls = 0;
await _pumpRail(tester, reduceMotion: true, onTap: () => calls++);
await tester.tap(_button(_saveKey));
await tester.pump(_pressFrame);
expect(_scale(tester, _saveKey), 1);
expect(find.byKey(_burstKey), findsNothing);
expect(calls, 1);
expect(tester.takeException(), isNull);
});
testWidgets('should_keep_burst_intent_when_selection_updates_during_motion', (
tester,
) async {
var calls = 0;
void onTap() => calls++;
await _pumpRail(tester, onTap: onTap);
await tester.tap(_button(_likeKey));
await tester.pump();
await tester.pump(_popFrame);
await _pumpRail(tester, selected: true, onTap: onTap);
expect(
tester.widget<RailTapBurst>(find.byKey(_burstKey)).contracting,
isFalse,
);
await tester.tap(_button(_likeKey));
await tester.pump();
await tester.pump(_popFrame);
final feedback = tester.widget<RailTapBurst>(find.byKey(_burstKey));
expect(feedback.contracting, isTrue);
expect(feedback.color, AppColors.onPrimary);
expect(calls, 2);
await tester.pumpAndSettle();
expect(find.byKey(_burstKey), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('should_stop_burst_when_reduced_motion_changes_during_feedback', (
tester,
) async {
await _pumpRail(tester, onTap: () {});
await tester.tap(_button(_saveKey));
await tester.pump();
await tester.pump(_popFrame);
expect(find.byKey(_burstKey), findsOneWidget);
await _pumpRail(tester, reduceMotion: true, onTap: () {});
expect(find.byKey(_burstKey), findsNothing);
expect(_scale(tester, _saveKey), 1);
expect(tester.takeException(), isNull);
});
testWidgets('should_not_pop_comments_when_motion_is_not_enabled', (
tester,
) async {
var calls = 0;
await _pumpRail(tester, onTap: () => calls++);
await tester.tap(_button(_commentsKey));
await tester.pump(_pressFrame);
expect(_scale(tester, _commentsKey), 1);
expect(find.byKey(_burstKey), findsNothing);
expect(calls, 1);
});
testWidgets('should_dispose_running_motion_when_rail_is_removed', (
tester,
) async {
await _pumpRail(tester, onTap: () {});
await tester.tap(_button(_likeKey));
await tester.pump(_pressFrame);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
}
+50 -39
View File
@@ -1271,6 +1271,22 @@ void main() {
expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget);
});
testWidgets('should_keep_episode_entry_regular_when_player_is_open', (
tester,
) async {
await _pumpApp(tester);
await _openSeriesPlayer(tester);
final entry = tester.widget<Text>(
find.descendant(
of: find.byKey(const Key('player-episodes-pill')),
matching: find.byType(Text),
),
);
expect(entry.style?.fontWeight, FontWeight.w400);
expect(entry.style?.fontSize, 13.5);
expect(entry.data, contains(' · '));
});
testWidgets('should_push_episodes_arrow_to_pill_right', (tester) async {
// 宽屏下文案不会撑满胶囊,才能验证箭头是否被推到最右侧。
await _pumpApp(tester, size: const Size(900, 844));
@@ -1371,9 +1387,9 @@ void main() {
final shareRect = tester.getRect(
find.byKey(const Key('player-rail-share')),
);
expect(likeRect.top - saveRect.bottom, closeTo(12, 0.1));
expect(commentsRect.top - likeRect.bottom, closeTo(12, 0.1));
expect(shareRect.top - commentsRect.bottom, closeTo(12, 0.1));
expect(likeRect.top - saveRect.bottom, closeTo(6, 0.1));
expect(commentsRect.top - likeRect.bottom, closeTo(6, 0.1));
expect(shareRect.top - commentsRect.bottom, closeTo(6, 0.1));
final saveButton = tester.widget<IconButton>(
find.descendant(
@@ -1405,7 +1421,9 @@ void main() {
);
});
testWidgets('should_toggle_synopsis_with_inline_text_button', (tester) async {
testWidgets('should_toggle_synopsis_at_fixed_corner_with_feed_motion', (
tester,
) async {
final longCopy = Drama(
id: 'long-copy-demo',
title: 'Long Title',
@@ -1428,7 +1446,7 @@ void main() {
await _pumpApp(tester, catalog: FakeCatalogRepository(dramas: [longCopy]));
await _openSeriesPlayer(tester, feedIndex: 0);
final toggle = find.byKey(const Key('player-synopsis-toggle'));
final toggle = find.byKey(const Key('player-synopsis-more'));
final synopsis = find.byKey(const Key('player-synopsis'));
expect(
find.descendant(of: toggle, matching: find.text('More')),
@@ -1443,17 +1461,15 @@ void main() {
?.color,
AppColors.textPrimary,
);
expect(tester.widget<Text>(synopsis).maxLines, 1);
expect(tester.widget<Text>(synopsis).maxLines, 2);
expect(tester.widget<Text>(synopsis).overflow, TextOverflow.ellipsis);
// 行尾「展开」与正文同一行,只留 6 的左边距。
expect(
tester
.widget<Padding>(
find.descendant(of: toggle, matching: find.byType(Padding)),
)
.padding,
const EdgeInsets.only(left: 6),
final motion = tester.widget<AnimatedSize>(
find.byKey(const Key('player-synopsis-size')),
);
expect(motion.duration, const Duration(milliseconds: 240));
expect(motion.curve, Curves.easeInOutCubic);
final blockFinder = find.byKey(const Key('player-episode-synopsis'));
final collapsedHeight = tester.getSize(blockFinder).height;
final synopsisRect = tester.getRect(synopsis);
final toggleRect = tester.getRect(toggle);
expect(toggleRect.left, greaterThan(synopsisRect.left));
@@ -1463,24 +1479,24 @@ void main() {
);
await tester.tap(toggle);
await tester.pump();
await tester.pump(const Duration(milliseconds: 80));
final growingHeight = tester.getSize(blockFinder).height;
expect(growingHeight, greaterThan(collapsedHeight));
expect(tester.getRect(toggle), toggleRect);
await tester.pumpAndSettle();
expect(
find.descendant(of: toggle, matching: find.text('Less')),
findsOneWidget,
);
expect(
tester.widget<Text>(find.byKey(const Key('player-synopsis'))).maxLines,
tester
.widget<Text>(find.byKey(const Key('player-synopsis-expanded')))
.maxLines,
isNull,
);
final lessRect = tester.getRect(toggle);
final textRect = tester.getRect(find.byKey(const Key('player-synopsis')));
final block = tester.getRect(
find.byKey(const Key('player-episode-synopsis')),
);
// 展开后「收起」内联在正文最后一行的行尾。
expect(lessRect.top, greaterThanOrEqualTo(textRect.top));
expect(lessRect.bottom, lessThanOrEqualTo(textRect.bottom + 1));
expect(lessRect.right - block.right, lessThanOrEqualTo(2));
expect(tester.getRect(toggle), toggleRect);
expect(tester.getSize(blockFinder).height, greaterThan(growingHeight));
await tester.tap(toggle);
await tester.pumpAndSettle();
@@ -1488,29 +1504,24 @@ void main() {
find.descendant(of: toggle, matching: find.text('More')),
findsOneWidget,
);
expect(tester.widget<Text>(synopsis).maxLines, 1);
expect(tester.widget<Text>(synopsis).maxLines, 2);
});
testWidgets('should_share_one_text_style_across_label_and_synopsis', (
testWidgets('should_match_feed_synopsis_without_episode_prefix', (
tester,
) async {
await _pumpApp(tester);
await _openSeriesPlayer(tester);
// 新 UI 把「第 n 集」与简介合并进同一个富文本,整行共用一套白字规格。
final line = tester.widget<Text>(find.byKey(const Key('player-synopsis')));
final style = line.style ?? const TextStyle();
expect(style.fontSize, 12);
expect(style.fontWeight, FontWeight.w500);
expect(style.color, AppColors.onPrimary);
final span = line.textSpan! as TextSpan;
final separator = span.children!.whereType<TextSpan>().firstWhere(
(child) => (child.text ?? '').contains('|'),
);
expect(separator.style?.fontSize, 12);
expect(separator.style?.fontWeight, FontWeight.w400);
expect(separator.style?.color, AppColors.onPrimary.withValues(alpha: 0.45));
expect(style.fontSize, 15);
expect(style.fontWeight, FontWeight.w400);
expect(style.color, AppColors.overlaySecondary);
expect(style.shadows ?? <Shadow>[], isEmpty);
expect(line.textSpan!.toPlainText(), isNot(startsWith('Episode ')));
expect(line.textSpan!.toPlainText(), isNot(contains(' | ')));
expect(find.byKey(const Key('player-episode-heading')), findsOneWidget);
});
testWidgets('should_fit_long_title_and_synopsis_at_320_width', (
@@ -1563,7 +1574,7 @@ void main() {
tester.getRect(find.byKey(const Key('player-synopsis'))).right,
lessThanOrEqualTo(320 - 14 - 54),
);
expect(synopsis.maxLines, 1);
expect(synopsis.maxLines, 2);
expect(synopsis.overflow, TextOverflow.ellipsis);
expect(
tester.getRect(find.byKey(const Key('player-episodes-pill'))).right,