diff --git a/lib/core/data/models/drama.dart b/lib/core/data/models/drama.dart index adf6f75..a2f024f 100644 --- a/lib/core/data/models/drama.dart +++ b/lib/core/data/models/drama.dart @@ -15,6 +15,8 @@ class Episode { this.videoUrl = '', this.durationSec = 0, this.locked = false, + this.likeCount, + this.commentCount, }); final String id; @@ -28,16 +30,21 @@ class Episode { /// 后端判定需要解锁;解锁能力(金币/VIP)在 M3 接入。 final bool locked; + final int? likeCount; + final int? commentCount; - Episode withVideoUrl(String url) => Episode( - id: id, - number: number, - title: title, - isFree: isFree, - videoUrl: url, - durationSec: durationSec, - locked: locked, - ); + Episode withVideoUrl(String url, {int? likeCount, int? commentCount}) => + Episode( + id: id, + number: number, + title: title, + isFree: isFree, + videoUrl: url, + durationSec: durationSec, + locked: locked, + likeCount: likeCount ?? this.likeCount, + commentCount: commentCount ?? this.commentCount, + ); factory Episode.fromJson(Map json) { return Episode( @@ -48,6 +55,8 @@ class Episode { durationSec: (json['durationSec'] as num?)?.toInt() ?? 0, locked: json['locked'] == true, videoUrl: json['playUrl'] as String? ?? '', + likeCount: _optionalInt(json['likeCount']), + commentCount: _optionalInt(json['commentCount']), ); } } diff --git a/lib/core/data/models/interaction.dart b/lib/core/data/models/interaction.dart index d1413e5..b00700f 100644 --- a/lib/core/data/models/interaction.dart +++ b/lib/core/data/models/interaction.dart @@ -2,9 +2,10 @@ import 'json_numbers.dart'; -/// 互动目标类型,契约只允许短剧与评论。 +/// 互动目标类型。 enum InteractionTargetType { drama('DRAMA'), + episode('EPISODE'), comment('COMMENT'); const InteractionTargetType(this.apiValue); diff --git a/lib/core/data/models/play_ticket.dart b/lib/core/data/models/play_ticket.dart index a585079..b613e05 100644 --- a/lib/core/data/models/play_ticket.dart +++ b/lib/core/data/models/play_ticket.dart @@ -14,6 +14,8 @@ class PlayTicket { this.unlockCoin = 0, this.previewSeconds = 0, this.qualities = const [], + this.likeCount, + this.commentCount, }); static const String free = 'FREE'; @@ -38,6 +40,8 @@ class PlayTicket { /// 后端下发的清晰度档位(如 AUTO / 720P / 1080P),HLS 由播放器自动切换。 final List qualities; + final int? likeCount; + final int? commentCount; bool get needsUnlock => accessType == coinRequired || accessType == vipRequired; @@ -58,6 +62,12 @@ class PlayTicket { .whereType() .where((quality) => quality.isNotEmpty) .toList(), + likeCount: json['likeCount'] == null + ? null + : jsonToInt(json['likeCount']), + commentCount: json['commentCount'] == null + ? null + : jsonToInt(json['commentCount']), ); } } diff --git a/lib/core/data/remote/comment_api.dart b/lib/core/data/remote/comment_api.dart index 536523d..3d05e57 100644 --- a/lib/core/data/remote/comment_api.dart +++ b/lib/core/data/remote/comment_api.dart @@ -13,6 +13,7 @@ class CommentApi { /// 拉取游标分页;`rootId` 用于加载某一楼层的回复。 Future page({ required String dramaId, + String? episodeId, String? rootId, String? cursor, int size = commentPageSize, @@ -21,6 +22,7 @@ class CommentApi { '/app/comments', query: { 'dramaId': dramaId, + 'episodeId': ?episodeId, 'rootId': ?rootId, 'cursor': ?cursor, 'size': '$size', @@ -31,10 +33,10 @@ class CommentApi { } /// 查询单部剧的有效评论总数;后端 Long 兼容数字与字符串。 - Future count(String dramaId) async { + Future count(String dramaId, {String? episodeId}) async { final data = await _client.get( '/app/comments/count', - query: {'dramaId': dramaId}, + query: {'dramaId': dramaId, 'episodeId': ?episodeId}, ); return jsonToInt(data); } diff --git a/lib/core/data/repositories/comment_repository.dart b/lib/core/data/repositories/comment_repository.dart index 744daf9..1bd08f0 100644 --- a/lib/core/data/repositories/comment_repository.dart +++ b/lib/core/data/repositories/comment_repository.dart @@ -8,13 +8,14 @@ abstract interface class CommentRepository { /// 拉取评论或回复的分页。 Future page({ required String dramaId, + String? episodeId, String? rootId, String? cursor, int size = commentPageSize, }); /// 查询单部剧的有效评论总数。 - Future count(String dramaId); + Future count(String dramaId, {String? episodeId}); /// 发表评论或回复。 Future publish({ @@ -37,18 +38,21 @@ class HttpCommentRepository implements CommentRepository { @override Future page({ required String dramaId, + String? episodeId, String? rootId, String? cursor, int size = commentPageSize, }) => _api.page( dramaId: dramaId, + episodeId: episodeId, rootId: rootId, cursor: cursor, size: size, ); @override - Future count(String dramaId) => _api.count(dramaId); + Future count(String dramaId, {String? episodeId}) => + _api.count(dramaId, episodeId: episodeId); @override Future publish({ diff --git a/lib/core/data/services/comment_controller.dart b/lib/core/data/services/comment_controller.dart index a81b066..26213cb 100644 --- a/lib/core/data/services/comment_controller.dart +++ b/lib/core/data/services/comment_controller.dart @@ -75,6 +75,7 @@ class CommentController extends ChangeNotifier { try { final page = await _commentRepository.page( dramaId: dramaId, + episodeId: episodeId, size: commentPageSize, ); _comments @@ -101,6 +102,7 @@ class CommentController extends ChangeNotifier { try { final page = await _commentRepository.page( dramaId: dramaId, + episodeId: episodeId, cursor: cursor, size: commentPageSize, ); @@ -128,6 +130,7 @@ class CommentController extends ChangeNotifier { try { final page = await _commentRepository.page( dramaId: dramaId, + episodeId: episodeId, rootId: rootId, size: commentPageSize, ); @@ -157,6 +160,7 @@ class CommentController extends ChangeNotifier { try { final page = await _commentRepository.page( dramaId: dramaId, + episodeId: episodeId, rootId: rootId, cursor: cursor, size: commentPageSize, diff --git a/lib/core/data/services/interaction_controller.dart b/lib/core/data/services/interaction_controller.dart index 539ee5d..fa585ea 100644 --- a/lib/core/data/services/interaction_controller.dart +++ b/lib/core/data/services/interaction_controller.dart @@ -17,6 +17,9 @@ class InteractionController extends ChangeNotifier { final Set _likedDramaIds = {}; final Set _busyDramaIds = {}; final Map _likeCounts = {}; + final Set _likedEpisodeIds = {}; + final Set _busyEpisodeIds = {}; + final Map _episodeLikeCounts = {}; bool _disposed = false; /// 当前会话内该剧是否已点赞。 @@ -28,42 +31,75 @@ class InteractionController extends ChangeNotifier { /// 服务端最新点赞数,仅在点赞接口成功返回后可用。 int? likeCount(String dramaId) => _likeCounts[dramaId]; + /// 当前会话内该集是否已点赞。 + bool isEpisodeLiked(String episodeId) => _likedEpisodeIds.contains(episodeId); + + /// 该集点赞请求是否进行中。 + bool isEpisodeBusy(String episodeId) => _busyEpisodeIds.contains(episodeId); + + /// 该集服务端最新点赞数。 + int? episodeLikeCount(String episodeId) => _episodeLikeCounts[episodeId]; + /// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。 - Future> toggleDramaLike( - String dramaId, - ) async { - if (_busyDramaIds.contains(dramaId)) { + Future> toggleDramaLike(String dramaId) => + _toggleLike( + id: dramaId, + targetType: InteractionTargetType.drama, + likedIds: _likedDramaIds, + busyIds: _busyDramaIds, + counts: _likeCounts, + ); + + /// 乐观切换分集点赞,失败时回滚并以服务端返回状态校准。 + Future> toggleEpisodeLike( + String episodeId, + ) => _toggleLike( + id: episodeId, + targetType: InteractionTargetType.episode, + likedIds: _likedEpisodeIds, + busyIds: _busyEpisodeIds, + counts: _episodeLikeCounts, + ); + + Future> _toggleLike({ + required String id, + required InteractionTargetType targetType, + required Set likedIds, + required Set busyIds, + required Map counts, + }) async { + if (busyIds.contains(id)) { return const OperationResult.failure(null); } - final wasLiked = _likedDramaIds.contains(dramaId); - _busyDramaIds.add(dramaId); + final wasLiked = likedIds.contains(id); + busyIds.add(id); if (wasLiked) { - _likedDramaIds.remove(dramaId); + likedIds.remove(id); } else { - _likedDramaIds.add(dramaId); + likedIds.add(id); } _notify(); try { final result = await _repository.toggleLike( - targetType: InteractionTargetType.drama, - targetId: dramaId, + targetType: targetType, + targetId: id, ); if (result.liked) { - _likedDramaIds.add(dramaId); + likedIds.add(id); } else { - _likedDramaIds.remove(dramaId); + likedIds.remove(id); } - _likeCounts[dramaId] = result.count; + counts[id] = result.count; return OperationResult.success(result); } catch (failure) { if (wasLiked) { - _likedDramaIds.add(dramaId); + likedIds.add(id); } else { - _likedDramaIds.remove(dramaId); + likedIds.remove(id); } return OperationResult.failure(_errorMessage(failure)); } finally { - _busyDramaIds.remove(dramaId); + busyIds.remove(id); _notify(); } } @@ -93,12 +129,18 @@ class InteractionController extends ChangeNotifier { void clear() { if (_likedDramaIds.isEmpty && _busyDramaIds.isEmpty && - _likeCounts.isEmpty) { + _likeCounts.isEmpty && + _likedEpisodeIds.isEmpty && + _busyEpisodeIds.isEmpty && + _episodeLikeCounts.isEmpty) { return; } _likedDramaIds.clear(); _busyDramaIds.clear(); _likeCounts.clear(); + _likedEpisodeIds.clear(); + _busyEpisodeIds.clear(); + _episodeLikeCounts.clear(); _notify(); } diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index b995301..de2ae86 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -116,6 +116,12 @@ class _DramaDetailPageState extends State { final saved = context.watch().isSaved(drama.id); final continueIndex = logic.continueIndex; final episodesReady = drama.detailLoaded && drama.episodes.isNotEmpty; + final statsEpisode = episodesReady + ? drama.episodes[continueIndex != null && + continueIndex < drama.episodes.length + ? continueIndex + : 0] + : null; return Scaffold( backgroundColor: AppColors.ink, bottomNavigationBar: DramaDetailBottomBar( @@ -146,6 +152,7 @@ class _DramaDetailPageState extends State { l10n: l10n, drama: drama, favoriteCount: logic.adjustedFavoriteCount, + likeCount: statsEpisode?.likeCount, ), ), SliverToBoxAdapter( diff --git a/lib/pages/drama_detail/widgets/drama_detail_sections.dart b/lib/pages/drama_detail/widgets/drama_detail_sections.dart index 8c5eda1..9b2e559 100644 --- a/lib/pages/drama_detail/widgets/drama_detail_sections.dart +++ b/lib/pages/drama_detail/widgets/drama_detail_sections.dart @@ -34,11 +34,13 @@ class DramaStatsCard extends StatelessWidget { required this.l10n, required this.drama, this.favoriteCount, + this.likeCount, }); final AppLocalizations l10n; final Drama drama; final int? favoriteCount; + final int? likeCount; @override Widget build(BuildContext context) { @@ -46,9 +48,9 @@ class DramaStatsCard extends StatelessWidget { final favorite = currentFavoriteCount == null ? l10n.episodeCountLabel(drama.episodeCount) : '${formatInteractionCount(currentFavoriteCount)} ${l10n.favorite}'; - final like = drama.likeCount == null + final like = likeCount == null ? '—' - : '${formatInteractionCount(drama.likeCount!)} ${l10n.like}'; + : '${formatInteractionCount(likeCount!)} ${l10n.like}'; return Container( key: const Key('detail-stats'), margin: const EdgeInsets.fromLTRB( diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 43b248a..37095af 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -230,7 +230,11 @@ class PlayerLogic extends ChangeNotifier { qualities = ticket.qualities; if (ticket.accessible && ticket.playUrl.isNotEmpty) { // 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host) - sessionEpisodes[index] = episode.withVideoUrl(ticket.playUrl); + sessionEpisodes[index] = episode.withVideoUrl( + ticket.playUrl, + likeCount: ticket.likeCount, + commentCount: ticket.commentCount, + ); return true; } blockedTicket = ticket; @@ -797,7 +801,11 @@ class PlayerLogic extends ChangeNotifier { notify(); } - Future showComments(BuildContext context, Drama drama) async { + Future showComments( + BuildContext context, + Drama drama, { + String? episodeId, + }) async { session.setOverlayOpen(true); await session.playbackSettled; if (!context.mounted) { @@ -812,7 +820,7 @@ class PlayerLogic extends ChangeNotifier { showDragHandle: false, isDismissible: false, enableDrag: false, - builder: (_) => DramaCommentsSheet(drama: drama), + builder: (_) => DramaCommentsSheet(drama: drama, episodeId: episodeId), ); } finally { session.setOverlayOpen(false); diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index b17ea9f..5f860d7 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -205,13 +205,22 @@ class _PlayerPageState extends State }); } - Future _openComments(BuildContext context, Drama drama) async { + Future _openComments( + BuildContext context, + Drama drama, + Episode episode, + ) async { final comments = context.read(); - await openCommentsWithAuth(context, drama, show: logic.showComments); + await openCommentsWithAuth( + context, + drama, + show: (context, drama) => + logic.showComments(context, drama, episodeId: episode.id), + ); try { - final count = await comments.count(drama.id); + final count = await comments.count(drama.id, episodeId: episode.id); if (!mounted) return; - setState(() => _commentCounts[drama.id] = count); + setState(() => _commentCounts[episode.id] = count); } catch (_) { // 评论面板已经关闭,计数刷新失败时保留旧值即可。 } @@ -456,18 +465,19 @@ class _PlayerPageState extends State onBoostEnd: _endBoost, onTogglePlay: () => logic.session.setUserWantsPlay(!logic.session.userWantsPlay), - liked: interactions.isLiked(drama.id), - likeCount: interactions.likeCount(drama.id) ?? drama.likeCount, + liked: interactions.isEpisodeLiked(episode.id), + likeCount: + interactions.episodeLikeCount(episode.id) ?? episode.likeCount, favoriteCount: drama.favoriteCount == null ? null : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), - commentCount: _commentCounts[drama.id] ?? drama.commentCount, + commentCount: _commentCounts[episode.id] ?? episode.commentCount, saved: context.watch().isSaved(drama.id), onLike: () async { if (await ensureSignedIn(context)) { if (!context.mounted) return; unawaited(context.read().lightImpact()); - final result = await interactions.toggleDramaLike(drama.id); + final result = await interactions.toggleEpisodeLike(episode.id); if (!result.isSuccess && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -481,7 +491,7 @@ class _PlayerPageState extends State } }, onSave: () => unawaited(_toggleSave(context, drama)), - onComments: () => unawaited(_openComments(context, drama)), + onComments: () => unawaited(_openComments(context, drama, episode)), onShare: () => logic.shareDrama(context, drama), onDetail: () => unawaited(logic.openDetail(context)), onOpenFullPlayer: () async { @@ -502,9 +512,11 @@ class _PlayerPageState extends State drama: drama, episode: episode, controller: ready ? videoPort!.controller : null, - liked: interactions.isLiked(drama.id), - likeCount: interactions.likeCount(drama.id) ?? drama.likeCount, - commentCount: _commentCounts[drama.id] ?? drama.commentCount, + liked: interactions.isEpisodeLiked(episode.id), + likeCount: + interactions.episodeLikeCount(episode.id) ?? + episode.likeCount, + commentCount: _commentCounts[episode.id] ?? episode.commentCount, favoriteCount: drama.favoriteCount == null ? null : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), @@ -525,9 +537,10 @@ class _PlayerPageState extends State onDetail: () => unawaited(logic.openDetail(context)), onSpeed: () => unawaited(_openSpeedSheet(context)), onEpisodes: () => logic.showEpisodes(context), - onComments: () => unawaited(_openComments(context, drama)), + onComments: () => + unawaited(_openComments(context, drama, episode)), onLike: () => - unawaited(_toggleLike(context, interactions, drama)), + unawaited(_toggleLike(context, interactions, episode)), onSave: () => unawaited(_toggleSave(context, drama)), onShare: () => logic.shareDrama(context, drama), onMore: () => _openMore(context, episode), diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index 3f9fd81..ead3fe0 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -81,11 +81,11 @@ extension _PlayerViewActions on _PlayerPageState { Future _toggleLike( BuildContext context, InteractionController interactions, - Drama drama, + Episode episode, ) async { if (!await ensureSignedIn(context) || !context.mounted) return; unawaited(context.read().lightImpact()); - final result = await interactions.toggleDramaLike(drama.id); + final result = await interactions.toggleEpisodeLike(episode.id); if (result.isSuccess || !context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index 0cdf480..794a7fe 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -31,9 +31,10 @@ const double _composerTopGap = 8; /// 服务端评论底部弹窗:匿名列表、游标翻页、回复与登录发布。 class DramaCommentsSheet extends StatefulWidget { - const DramaCommentsSheet({super.key, required this.drama}); + const DramaCommentsSheet({super.key, required this.drama, this.episodeId}); final Drama drama; + final String? episodeId; @override State createState() => _DramaCommentsSheetState(); @@ -56,6 +57,7 @@ class _DramaCommentsSheetState extends State { comments: _comments, interactions: context.read(), dramaId: widget.drama.id, + episodeId: widget.episodeId, ); unawaited(_controller.load()); unawaited(_refreshCount()); @@ -77,7 +79,10 @@ class _DramaCommentsSheetState extends State { Future _refreshCount() async { final request = ++_countRequest; try { - final count = await _comments.count(widget.drama.id); + final count = await _comments.count( + widget.drama.id, + episodeId: widget.episodeId, + ); if (!mounted || request != _countRequest) return; setState(() => _serverCount = count); } catch (_) { @@ -464,8 +469,9 @@ class _CommentThread extends StatelessWidget { Text( expanded ? AppLocalizations.of(context)!.hideReplies - : AppLocalizations.of(context)! - .viewReplies(comment.replyCount), + : AppLocalizations.of( + context, + )!.viewReplies(comment.replyCount), style: const TextStyle( color: AppColors.muted, fontSize: 12, diff --git a/test/catalog/drama_model_test.dart b/test/catalog/drama_model_test.dart index bb99b81..ef2ea7f 100644 --- a/test/catalog/drama_model_test.dart +++ b/test/catalog/drama_model_test.dart @@ -20,12 +20,16 @@ void main() { 'title': '第 1 集', 'isFree': true, 'playUrl': '/files/episode/9.mp4', + 'likeCount': '18', + 'commentCount': 4, }, }); expect(drama.detailLoaded, isFalse); expect(drama.episodes, hasLength(1)); expect(drama.episodes.single.videoUrl, '/files/episode/9.mp4'); + expect(drama.episodes.single.likeCount, 18); + expect(drama.episodes.single.commentCount, 4); expect(drama.episodeCount, 77); expect(drama.isHot, isTrue); expect(drama.language, 'zh-CN'); @@ -50,7 +54,14 @@ void main() { 'cover': '/files/cover.jpg', 'totalEpisodes': 77, 'episodes': [ - {'id': 9, 'number': 1, 'title': '第 1 集', 'isFree': true}, + { + 'id': 9, + 'number': 1, + 'title': '第 1 集', + 'isFree': true, + 'likeCount': '21', + 'commentCount': '6', + }, {'id': 10, 'number': 2, 'title': '第 2 集', 'isFree': false}, ], }); @@ -58,5 +69,7 @@ void main() { expect(drama.detailLoaded, isTrue); expect(drama.episodeCount, 77); expect(drama.episodes.first.id, '9'); + expect(drama.episodes.first.likeCount, 21); + expect(drama.episodes.first.commentCount, 6); }); } diff --git a/test/comment/comment_api_test.dart b/test/comment/comment_api_test.dart index 6c25dbb..190f027 100644 --- a/test/comment/comment_api_test.dart +++ b/test/comment/comment_api_test.dart @@ -55,12 +55,18 @@ void main() { ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), ); - final page = await api.page(dramaId: '5', cursor: '99', size: 20); + final page = await api.page( + dramaId: '5', + episodeId: '7', + cursor: '99', + size: 20, + ); expect(adapter.requests.single.method, 'GET'); expect(adapter.requests.single.uri.path, '/app/comments'); expect(adapter.requests.single.queryParameters, { 'dramaId': '5', + 'episodeId': '7', 'cursor': '99', 'size': '20', }); @@ -93,6 +99,7 @@ void main() { final reply = await api.publish( dramaId: '5', + episodeId: '7', content: 'great badword', parentId: '2111012753262120962', ); @@ -101,6 +108,7 @@ void main() { expect(adapter.requests.single.uri.path, '/app/comments'); expect(adapter.requests.single.data, { 'dramaId': '5', + 'episodeId': '7', 'content': 'great badword', 'parentId': '2111012753262120962', }); @@ -135,12 +143,15 @@ void main() { ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), ); - final count = await api.count('5'); + final count = await api.count('5', episodeId: '7'); expect(count, 17); expect(adapter.requests.single.method, 'GET'); expect(adapter.requests.single.uri.path, '/app/comments/count'); - expect(adapter.requests.single.queryParameters, {'dramaId': '5'}); + expect(adapter.requests.single.queryParameters, { + 'dramaId': '5', + 'episodeId': '7', + }); }); test('should_parse_string_comment_count', () async { diff --git a/test/comment/comment_controller_test.dart b/test/comment/comment_controller_test.dart index 30b0868..35224db 100644 --- a/test/comment/comment_controller_test.dart +++ b/test/comment/comment_controller_test.dart @@ -12,10 +12,12 @@ void main() { CommentController controller( FakeCommentRepository comments, { FakeInteractionRepository? interactions, + String? episodeId, }) => CommentController( comments: comments, interactions: interactions ?? FakeInteractionRepository(), dramaId: '5', + episodeId: episodeId, ); test('should_append_next_page_with_cursor_when_has_more_is_true', () async { @@ -63,6 +65,42 @@ void main() { model.dispose(); }); + test('should_pass_episode_id_to_every_page_and_publish_request', () async { + final root = commentFixture(id: '10', replyCount: 1); + final repository = FakeCommentRepository( + pages: [ + CommentPage( + comments: [root], + nextCursor: '10', + hasMore: true, + ), + CommentPage(comments: [commentFixture(id: '9')]), + CommentPage( + comments: [commentFixture(id: '11', rootId: '10')], + nextCursor: '11', + hasMore: true, + ), + CommentPage(comments: [commentFixture(id: '12', rootId: '10')]), + ], + publishResult: commentFixture(id: '13', mine: true), + ); + final model = controller(repository, episodeId: 'episode-7'); + + await model.load(); + await model.loadMore(); + await model.loadReplies('10'); + await model.loadMoreReplies('10'); + await model.publish('Nice episode'); + + expect( + repository.pageCalls.map((call) => call.episodeId), + everyElement('episode-7'), + ); + expect(repository.pageCalls, hasLength(4)); + expect(repository.publishCalls.single.episodeId, 'episode-7'); + model.dispose(); + }); + test('should_use_server_content_and_pending_status_when_review_is_returned', () async { final repository = FakeCommentRepository( publishResult: commentFixture( diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index b65cb5d..b99cb7d 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -239,6 +239,36 @@ void main() { expect(repository.countCalls, ['5']); }); + testWidgets('should_scope_list_count_and_publish_to_episode', (tester) async { + final repository = FakeCommentRepository( + pages: [const CommentPage()], + countResult: 3, + publishResult: commentFixture( + id: '11', + content: 'Episode comment', + mine: true, + ), + ); + + await _pumpSheet(tester, repository: repository, episodeId: 'episode-7'); + + expect(repository.pageCalls.single.episodeId, 'episode-7'); + expect(repository.countEpisodeCalls, ['episode-7']); + + await tester.tap(find.byKey(const Key('comment-input-placeholder'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('comment-composer-input')), + 'Episode comment', + ); + await tester.pump(); + await tester.tap(find.byKey(const Key('comment-composer-post'))); + await tester.pumpAndSettle(); + + expect(repository.publishCalls.single.episodeId, 'episode-7'); + expect(repository.countEpisodeCalls, ['episode-7', 'episode-7']); + }); + testWidgets('should_fall_back_to_exact_count_when_server_total_fails', ( tester, ) async { @@ -964,6 +994,7 @@ Future _pumpSheet( required FakeCommentRepository repository, FakeHaptics? haptics, Locale locale = const Locale('en'), + String? episodeId, }) async { final auth = await _auth(); await tester.pumpWidget( @@ -983,12 +1014,12 @@ Future _pumpSheet( initialRoute: '/comments', routes: { '/': (_) => const Scaffold(backgroundColor: AppColors.ink), - '/comments': (_) => const Scaffold( + '/comments': (_) => Scaffold( backgroundColor: AppColors.ink, resizeToAvoidBottomInset: false, body: Align( alignment: Alignment.bottomCenter, - child: DramaCommentsSheet(drama: _drama), + child: DramaCommentsSheet(drama: _drama, episodeId: episodeId), ), ), }, diff --git a/test/drama_detail/drama_detail_first_frame_test.dart b/test/drama_detail/drama_detail_first_frame_test.dart index 8ab2f78..91b769e 100644 --- a/test/drama_detail/drama_detail_first_frame_test.dart +++ b/test/drama_detail/drama_detail_first_frame_test.dart @@ -158,6 +158,8 @@ Drama _detailDrama(Drama card) => Drama( isFree: number == 1, videoUrl: number == 1 ? '/files/${card.id}-1.mp4' : '', locked: number > 1, + likeCount: card.likeCount, + commentCount: card.commentCount, ), ], ); diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart index 490e7bd..0123304 100644 --- a/test/drama_detail/drama_detail_view_test.dart +++ b/test/drama_detail/drama_detail_view_test.dart @@ -463,6 +463,8 @@ Drama _detailDrama({ durationSec: 135, locked: number > 1, videoUrl: number == 1 ? '/files/detail-k38-1.mp4' : '', + likeCount: withCounts ? 12400 : null, + commentCount: withCounts ? 96 : null, ), ], ); diff --git a/test/interaction/interaction_controller_test.dart b/test/interaction/interaction_controller_test.dart index e79b8cc..121deab 100644 --- a/test/interaction/interaction_controller_test.dart +++ b/test/interaction/interaction_controller_test.dart @@ -62,6 +62,56 @@ void main() { controller.dispose(); }); + test('should_toggle_episode_like_with_plus_one_and_minus_one', () async { + final repository = FakeInteractionRepository( + nextLikeResult: const LikeToggleResult(liked: true, count: 8), + ); + final controller = InteractionController(repository); + + await controller.toggleEpisodeLike('episode-5'); + + expect(repository.likeTargets, ['EPISODE:episode-5']); + expect(controller.isEpisodeLiked('episode-5'), isTrue); + expect(controller.episodeLikeCount('episode-5'), 8); + expect(controller.isLiked('episode-5'), isFalse); + + repository.nextLikeResult = const LikeToggleResult(liked: false, count: 7); + await controller.toggleEpisodeLike('episode-5'); + + expect(controller.isEpisodeLiked('episode-5'), isFalse); + expect(controller.episodeLikeCount('episode-5'), 7); + controller.dispose(); + }); + + test('should_isolate_episode_like_cache_by_episode_and_drama', () async { + final repository = FakeInteractionRepository( + nextLikeResult: const LikeToggleResult(liked: true, count: 8), + ); + final controller = InteractionController(repository); + + await controller.toggleEpisodeLike('episode-1'); + repository.nextLikeResult = const LikeToggleResult(liked: true, count: 3); + await controller.toggleEpisodeLike('episode-2'); + repository.nextLikeResult = const LikeToggleResult(liked: false, count: 7); + await controller.toggleEpisodeLike('episode-1'); + repository.nextLikeResult = const LikeToggleResult(liked: true, count: 12); + await controller.toggleDramaLike('episode-1'); + + expect(controller.isEpisodeLiked('episode-1'), isFalse); + expect(controller.episodeLikeCount('episode-1'), 7); + expect(controller.isEpisodeLiked('episode-2'), isTrue); + expect(controller.episodeLikeCount('episode-2'), 3); + expect(controller.isLiked('episode-1'), isTrue); + expect(controller.likeCount('episode-1'), 12); + expect(repository.likeTargets, [ + 'EPISODE:episode-1', + 'EPISODE:episode-2', + 'EPISODE:episode-1', + 'DRAMA:episode-1', + ]); + controller.dispose(); + }); + test('should_clear_account_scoped_drama_likes_when_session_changes', () async { final repository = FakeInteractionRepository( nextLikeResult: const LikeToggleResult(liked: true, count: 1), diff --git a/test/playback/play_ticket_quality_test.dart b/test/playback/play_ticket_quality_test.dart index 809a7b5..14e0267 100644 --- a/test/playback/play_ticket_quality_test.dart +++ b/test/playback/play_ticket_quality_test.dart @@ -11,9 +11,13 @@ void main() { 'accessType': 'FREE', 'playUrl': '/files/episode/12.m3u8?e=1&s=abc', 'qualities': ['AUTO', '720P', '1080P'], + 'likeCount': '17', + 'commentCount': 5, }); expect(ticket.qualities, ['AUTO', '720P', '1080P']); + expect(ticket.likeCount, 17); + expect(ticket.commentCount, 5); }); test('should_default_to_empty_qualities_when_field_is_missing', () { diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 9c36348..8b763c8 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -637,12 +637,10 @@ void main() { 'auth.refreshToken': 'test-refresh', 'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}', }); - await _pumpApp( - tester, - interactions: FakeInteractionRepository( - nextLikeResult: const LikeToggleResult(liked: true, count: 23000), - ), + final interactions = FakeInteractionRepository( + nextLikeResult: const LikeToggleResult(liked: true, count: 23000), ); + await _pumpApp(tester, interactions: interactions); await _openSeriesPlayer(tester); String railLabel(Key key) => tester @@ -659,6 +657,7 @@ void main() { await tester.tap(find.byKey(const Key('player-rail-like'))); await tester.pumpAndSettle(); expect(railLabel(const Key('player-rail-like')), '2.3万'); + expect(interactions.likeTargets, contains('EPISODE:city-lights-demo-1')); await tester.tap(find.byKey(const Key('player-rail-save'))); await tester.pumpAndSettle(); diff --git a/test/support/fake_catalog.dart b/test/support/fake_catalog.dart index 8b5598c..075febe 100644 --- a/test/support/fake_catalog.dart +++ b/test/support/fake_catalog.dart @@ -95,6 +95,8 @@ Drama _drama( title: 'Episode ${index + 1}', isFree: true, videoUrl: 'https://example.test/$format-$index.mp4', + likeCount: likeCount, + commentCount: commentCount, ), ], ); diff --git a/test/support/fake_interaction_repositories.dart b/test/support/fake_interaction_repositories.dart index 071a33b..6ded61e 100644 --- a/test/support/fake_interaction_repositories.dart +++ b/test/support/fake_interaction_repositories.dart @@ -10,12 +10,14 @@ import 'package:fruitshorts/core/data/repositories/interaction_repository.dart'; class CommentPageCall { const CommentPageCall({ required this.dramaId, + required this.episodeId, required this.rootId, required this.cursor, required this.size, }); final String dramaId; + final String? episodeId; final String? rootId; final String? cursor; final int size; @@ -57,11 +59,13 @@ class FakeCommentRepository implements CommentRepository { final List publishCalls = []; final List deleteCalls = []; final List countCalls = []; + final List countEpisodeCalls = []; int _pageIndex = 0; @override Future page({ required String dramaId, + String? episodeId, String? rootId, String? cursor, int size = commentPageSize, @@ -69,6 +73,7 @@ class FakeCommentRepository implements CommentRepository { pageCalls.add( CommentPageCall( dramaId: dramaId, + episodeId: episodeId, rootId: rootId, cursor: cursor, size: size, @@ -81,8 +86,11 @@ class FakeCommentRepository implements CommentRepository { } @override - Future count(String dramaId) async { + Future count(String dramaId, {String? episodeId}) async { countCalls.add(dramaId); + if (episodeId != null && episodeId.isNotEmpty) { + countEpisodeCalls.add(episodeId); + } final error = countError; if (error != null) throw error; final result = countResult; diff --git a/test/widget_test.dart b/test/widget_test.dart index 6ee71f8..43556a7 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -263,7 +263,7 @@ void main() { await tester.tap(find.byKey(const Key('player-rail-like'))); await tester.pumpAndSettle(); - expect(interactions.likeTargets, contains('DRAMA:city-lights-demo')); + expect(interactions.likeTargets, contains('EPISODE:city-lights-demo-1')); await tester.tap(find.byKey(const Key('player-rail-save'))); await tester.pumpAndSettle();