diff --git a/lib/core/widgets/like_button.dart b/lib/core/widgets/like_button.dart index a159e8e..52936e5 100644 --- a/lib/core/widgets/like_button.dart +++ b/lib/core/widgets/like_button.dart @@ -31,12 +31,21 @@ enum LikeButtonVariant { final Color activeCountColor; } -/// 点赞/收藏交互钮(05 号规范 §3.5 静态规格):图标 20 + 计数 13/w600, -/// 未激活一律 `inkSoft`(6.59:1)。触控 44×44 由 padding 撑足。 +/// 点赞/收藏交互钮(05 号规范 §3.5):图标 20 + 计数 13/w600,未激活 +/// 一律 `inkSoft`(6.59:1)。触控 44×44 由 padding 撑足。 /// -/// T3-14 只做展示([onPressed] 传 null 即禁用态,仍按正常色渲染计数与 -/// 状态);乐观更新动画与 ToggleSync 接线属 T3-15/16。 -class LikeButton extends StatelessWidget { +/// 乐观更新视觉(§3.5/§4 三层闪烁抑制的 UI 半边,状态本体由持有方经 +/// ToggleSync 驱动): +/// +/// - **点按驱动**的状态变化:激活播 240ms 弹性缩放(1→1.25→1)+ 图标 +/// 120ms 淡入;取消仅 120ms 颜色渐出、无缩放。 +/// - **非点按驱动**的状态变化(失败回滚 / 服务端对账):零动画直接跳变; +/// 若激活动画未播完,等播完再跳(避免动画中途反转的抖动,§4.3a)。 +/// - 计数变化一律直接替换,不做滚动动画(回滚时无二次滚动)。 +/// - 系统「减弱动态效果」开启时全部降级为瞬变。 +/// +/// [onPressed] 传 null 即纯展示禁用态(仍按正常色渲染计数与状态)。 +class LikeButton extends StatefulWidget { const LikeButton({ required this.variant, required this.active, @@ -52,15 +61,106 @@ class LikeButton extends StatelessWidget { final VoidCallback? onPressed; final String? semanticLabel; + @override + State createState() => _LikeButtonState(); +} + +class _LikeButtonState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _scaleController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 240), + ); + late final Animation _scale = TweenSequence([ + TweenSequenceItem( + tween: Tween( + begin: 1, + end: 1.25, + ).chain(CurveTween(curve: Curves.easeOut)), + weight: 40, + ), + TweenSequenceItem( + tween: Tween( + begin: 1.25, + end: 1, + ).chain(CurveTween(curve: Curves.easeOutBack)), + weight: 60, + ), + ]).animate(_scaleController); + + /// 当前展示态(回滚等待动画播完期间可短暂落后于 widget.active)。 + late bool _displayActive = widget.active; + + /// 展示计数与状态成对更新(§4.3c:回滚不出现「心已灭计数未减」中间帧)。 + late int _displayCount = widget.count; + + /// 最近一次点按的期望目标态;didUpdateWidget 以此区分「点按驱动」 + /// (播动画)与「回滚/对账」(零动画跳变)。 + bool? _expectedTarget; + + /// 图标切换是否走 120ms 淡入淡出(点按驱动);回滚跳变置 false。 + bool _fadeSwap = false; + + bool get _reduceMotion => + MediaQuery.maybeOf(context)?.disableAnimations ?? false; + + @override + void didUpdateWidget(LikeButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.active == widget.active) { + // 状态未变的计数变化 = 服务端对账:静默替换、不播动画(§4.4)。 + _displayCount = widget.count; + return; + } + final tapDriven = _expectedTarget == widget.active; + _expectedTarget = null; + if (tapDriven && !_reduceMotion) { + _fadeSwap = true; + _displayActive = widget.active; + _displayCount = widget.count; + if (widget.active) _scaleController.forward(from: 0); + return; + } + // 回滚 / 对账:零动画、计数与状态成对跳变。激活动画未播完则等 + // 播完再跳(§4.3a,避免动画中途反转的抖动)。 + _fadeSwap = false; + if (_scaleController.isAnimating) { + _scaleController.forward().whenComplete(() { + if (mounted) { + setState(() { + _displayActive = widget.active; + _displayCount = widget.count; + }); + } + }); + } else { + _displayActive = widget.active; + _displayCount = widget.count; + } + } + + void _handleTap() { + _expectedTarget = !widget.active; + widget.onPressed!(); + } + + @override + void dispose() { + _scaleController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final variant = widget.variant; + final active = _displayActive; final iconColor = active ? variant.activeIconColor : AppColors.inkSoft; final countColor = active ? variant.activeCountColor : AppColors.inkSoft; return Semantics( - label: semanticLabel, - button: onPressed != null, + label: widget.semanticLabel, + button: widget.onPressed != null, child: InkWell( - onTap: onPressed, + onTap: widget.onPressed == null ? null : _handleTap, borderRadius: BorderRadius.circular(AppRadius.pill), child: ConstrainedBox( constraints: const BoxConstraints(minWidth: 44, minHeight: 44), @@ -69,14 +169,24 @@ class LikeButton extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - active ? variant.activeIcon : variant.inactiveIcon, - size: 20, - color: iconColor, + ScaleTransition( + scale: _scale, + child: AnimatedSwitcher( + duration: _fadeSwap + ? const Duration(milliseconds: 120) + : Duration.zero, + child: Icon( + active ? variant.activeIcon : variant.inactiveIcon, + key: ValueKey(active), + size: 20, + color: iconColor, + ), + ), ), const SizedBox(width: 4), + // 计数直接替换(§3.5:不做滚动动画,避免回滚二次滚动)。 Text( - '$count', + '$_displayCount', style: TextStyle( color: countColor, fontSize: 13, diff --git a/lib/features/community/community_controller.dart b/lib/features/community/community_controller.dart index 7de9e99..d5342c1 100644 --- a/lib/features/community/community_controller.dart +++ b/lib/features/community/community_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/features/community/community_interaction_analytics.dart'; import 'package:patbond_flutter/features/community/community_models.dart'; import 'package:patbond_flutter/features/community/community_repository.dart'; import 'package:patbond_flutter/features/community/toggle_sync.dart'; @@ -18,7 +19,7 @@ enum LoadMorePhase { idle, loading, error } /// 评论列表只属详情页,按「页面级状态按页自建」纪律经 [repository] /// 自取,不膨胀本控制器。服务端是唯一事实来源,内存副本仅作展示缓存。 class CommunityController extends ChangeNotifier { - CommunityController({required this._repository}) { + CommunityController({required this._repository, this._interactionAnalytics}) { _likeSync = ToggleSync( read: (id) { final post = _postCache[id]; @@ -35,6 +36,11 @@ class CommunityController extends ChangeNotifier { final state = target ? await _repository.likePost(id) : await _repository.unlikePost(id); + // 成功响应后上报(06 §1.4 口径;乐观翻转与失败均不报)。 + final source = _likeSources[id] ?? InteractionSource.feed; + target + ? _interactionAnalytics?.postLiked(source: source) + : _interactionAnalytics?.postUnliked(source: source); return ToggleOutcome(active: state.liked, count: state.likeCount); }, generation: () => _generation, @@ -62,6 +68,10 @@ class CommunityController extends ChangeNotifier { final state = target ? await _repository.bookmarkPost(id) : await _repository.unbookmarkPost(id); + final source = _bookmarkSources[id] ?? InteractionSource.feed; + target + ? _interactionAnalytics?.postFavorited(source: source) + : _interactionAnalytics?.postUnfavorited(source: source); return ToggleOutcome( active: state.bookmarked, count: state.bookmarkCount, @@ -73,6 +83,12 @@ class CommunityController extends ChangeNotifier { } final CommunityRepository _repository; + final CommunityInteractionAnalytics? _interactionAnalytics; + + /// 各帖最近一次 toggle 的触点来源(Feed 卡片 / 详情页共享同一实例, + /// 成功响应上报时按发起触点归因)。 + final Map _likeSources = {}; + final Map _bookmarkSources = {}; /// 页面级状态(评论列表、我的帖子、收藏页等)按页直接经仓库取数。 CommunityRepository get repository => _repository; @@ -193,10 +209,34 @@ class CommunityController extends ChangeNotifier { } /// 点赞/取消点赞(乐观翻转,终态由 [ToggleSync] 对账收敛,不外抛)。 - void toggleLike(String postId) => _likeSync.toggle(postId); + /// [source] 为触点来源(埋点归因),Feed 卡片缺省 feed、详情页传 + /// post_detail。 + void toggleLike( + String postId, { + InteractionSource source = InteractionSource.feed, + }) { + _likeSources[postId] = source; + _likeSync.toggle(postId); + } /// 收藏/取消收藏(与点赞同构)。 - void toggleBookmark(String postId) => _bookmarkSync.toggle(postId); + void toggleBookmark( + String postId, { + InteractionSource source = InteractionSource.feed, + }) { + _bookmarkSources[postId] = source; + _bookmarkSync.toggle(postId); + } + + /// 评论创建/删除后的计数调整(详情页与 Feed 卡片同源写入, + /// 服务端 comment_count 由写侧同事务维护,本地只做展示对齐)。 + void adjustCommentCount(String postId, int delta) { + final current = + _postCache[postId]?.commentCount ?? _cardOrNull(postId)?.commentCount; + if (current == null) return; + final next = current + delta; + _writeInteraction(postId, commentCount: next < 0 ? 0 : next); + } /// 消费一次性 toggle 错误(SnackBar 展示后清除)。 void clearToggleError() => _toggleError = null; @@ -215,6 +255,8 @@ class CommunityController extends ChangeNotifier { _loadMoreError = null; _toggleError = null; _postCache.clear(); + _likeSources.clear(); + _bookmarkSources.clear(); _likeSync.reset(); _bookmarkSync.reset(); _notify(); @@ -234,6 +276,7 @@ class CommunityController extends ChangeNotifier { int? likeCount, bool? bookmarkedByMe, int? bookmarkCount, + int? commentCount, }) { final post = _postCache[postId]; if (post != null) { @@ -242,6 +285,7 @@ class CommunityController extends ChangeNotifier { likeCount: likeCount, bookmarkedByMe: bookmarkedByMe, bookmarkCount: bookmarkCount, + commentCount: commentCount, ); } final index = _feed.indexWhere((card) => card.id == postId); @@ -252,6 +296,7 @@ class CommunityController extends ChangeNotifier { likeCount: likeCount, bookmarkedByMe: bookmarkedByMe, bookmarkCount: bookmarkCount, + commentCount: commentCount, ); } if (post != null || index != -1) _notify(); diff --git a/lib/features/community/community_interaction_analytics.dart b/lib/features/community/community_interaction_analytics.dart new file mode 100644 index 0000000..ecec26b --- /dev/null +++ b/lib/features/community/community_interaction_analytics.dart @@ -0,0 +1,137 @@ +import 'package:patbond_flutter/analytics/page_view_tracker.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; + +/// 互动域埋点强类型封装(06 号规划 §1.4 字典 v3 互动八事件;后端白名单 +/// 已随 api dev@8089c06 就绪,22 号报告 §1)。沿 pet/feed 域惯例:枚举 +/// 编译期锁死,业务代码禁止手拼事件名与属性;只记行为不记内容 +/// (隐私红线:postId/commentId 等内容 ID 一律不进 props)。 +/// +/// 点赞/收藏/关注**不埋失败**(06 §1.4 取舍:幂等写入单点交互,失败率 +/// 靠服务端错误率观测);`comment_create_started` 被字典锁死 unknown, +/// 不得上报(22 号 §1 末段)。 + +/// 互动触点来源(06 §1.4 source 枚举)。M3 接 feed / post_detail; +/// user_profile / follow_list 随后续页面启用。 +enum InteractionSource { + feed('feed'), + postDetail('post_detail'), + userProfile('user_profile'), + followList('follow_list'); + + const InteractionSource(this.value); + + final String value; +} + +/// 评论创建失败原因(06 §1.4 失败枚举基底)。网络归并口径同 pet/feed 域: +/// 断网/超时/5xx 均并入 network_error,server_error 保留兜底。 +enum CommentCreateFailureReason { + validationError('validation_error'), + notFound('not_found'), + rateLimited('rate_limited'), + networkError('network_error'), + serverError('server_error'); + + const CommentCreateFailureReason(this.value); + + final String value; +} + +/// 类型化异常 → 评论失败原因;会话失效返回 null(应用即将回登录页, +/// 不作为评论失败上报,feed 域同款口径)。 +CommentCreateFailureReason? commentCreateFailureReasonOf(ApiException error) => + switch (error) { + ApiNetworkException _ => CommentCreateFailureReason.networkError, + ApiRateLimitException _ => CommentCreateFailureReason.rateLimited, + SessionExpiredException _ => null, + ApiBusinessException(:final code) => switch (code) { + ApiCodes.paramError => CommentCreateFailureReason.validationError, + ApiCodes.postNotFound || + ApiCodes.commentNotFound || + ApiCodes.communityUserNotFound => CommentCreateFailureReason.notFound, + _ => CommentCreateFailureReason.serverError, + }, + }; + +/// 正文规模分桶(06 §1.3 隐私红线 1:不报精确字数)。 +/// empty / short(≤50) / medium(51–500) / long(>500)。 +String textLengthBucketOf(int length) { + if (length <= 0) return 'empty'; + if (length <= 50) return 'short'; + if (length <= 500) return 'medium'; + return 'long'; +} + +class CommunityInteractionAnalytics { + CommunityInteractionAnalytics(this._track); + + /// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。 + final TrackEventFn _track; + + /// 点赞成功响应后(乐观翻转本身不报;单飞合并链每个实际抵达服务端 + /// 并成功的状态变更各报一条,与「成功响应后」的字典口径一致)。 + void postLiked({required InteractionSource source}) { + _track('post_liked', {'source': source.value}); + } + + /// 取消点赞成功响应后。 + void postUnliked({required InteractionSource source}) { + _track('post_unliked', {'source': source.value}); + } + + /// 收藏成功响应后。 + void postFavorited({required InteractionSource source}) { + _track('post_favorited', {'source': source.value}); + } + + /// 取消收藏成功响应后。 + void postUnfavorited({required InteractionSource source}) { + _track('post_unfavorited', {'source': source.value}); + } + + /// 评论提交成功响应后。 + /// + /// [durationMs]:本次输入会话(首个字符输入)→ 成功响应的耗时 + /// (评论不设 started 事件,时长随成功事件带出); + /// [textLength] 经 [textLengthBucketOf] 分桶后上报,精确字数不出端。 + void commentCreateSucceeded({ + required int durationMs, + required bool isReply, + required int textLength, + }) { + _track('comment_create_succeeded', { + 'durationMs': durationMs, + 'isReply': isReply, + 'textLengthBucket': textLengthBucketOf(textLength), + }); + } + + /// 评论提交失败。 + /// + /// [errorCode] 为业务错误码(网络错误时缺席);[httpStatus] 由五位 + /// 业务码推导(`code ~/ 100`,pet 域同款口径);[attemptSeq] 为本次 + /// 输入会话内第几次提交尝试(从 1 起,成功或清空输入后重置)。 + void commentCreateFailed({ + required CommentCreateFailureReason reason, + required int attemptSeq, + int? errorCode, + }) { + _track('comment_create_failed', { + 'failureReason': reason.value, + 'attemptSeq': attemptSeq, + 'errorCode': ?errorCode, + if (errorCode != null && errorCode >= 10000) + 'httpStatus': errorCode ~/ 100, + }); + } + + /// 关注成功响应后。 + void userFollowed({required InteractionSource source}) { + _track('user_followed', {'source': source.value}); + } + + /// 取关成功响应后。 + void userUnfollowed({required InteractionSource source}) { + _track('user_unfollowed', {'source': source.value}); + } +} diff --git a/test/core/widgets/like_button_test.dart b/test/core/widgets/like_button_test.dart new file mode 100644 index 0000000..6989ed2 --- /dev/null +++ b/test/core/widgets/like_button_test.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/like_button.dart'; + +/// 宿主:模拟持有方(controller)的状态翻转——点按经 [onTap] 乐观翻转, +/// 外部(回滚/对账)经 [setActive] 直接改。 +class _Host extends StatefulWidget { + const _Host({required this.initialActive, required this.initialCount}); + + final bool initialActive; + final int initialCount; + + @override + State<_Host> createState() => _HostState(); +} + +class _HostState extends State<_Host> { + late bool active = widget.initialActive; + late int count = widget.initialCount; + + void setExternal({required bool active, required int count}) { + setState(() { + this.active = active; + this.count = count; + }); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + body: Center( + child: LikeButton( + variant: LikeButtonVariant.like, + active: active, + count: count, + onPressed: () => setState(() { + active = !active; + count += active ? 1 : -1; + }), + semanticLabel: '点赞', + ), + ), + ), + ); + } +} + +void main() { + double scaleOf(WidgetTester tester) => tester + .widget( + find.descendant( + of: find.byType(LikeButton), + matching: find.byType(ScaleTransition), + ), + ) + .scale + .value; + + testWidgets('点按激活:240ms 弹性缩放动画(中途 >1,播完归位)', (tester) async { + await tester.pumpWidget(const _Host(initialActive: false, initialCount: 6)); + + await tester.tap(find.byIcon(Icons.favorite_border)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 90)); + expect(scaleOf(tester), greaterThan(1.0)); + expect(find.byIcon(Icons.favorite), findsOneWidget); + expect(find.text('7'), findsOneWidget); + + await tester.pumpAndSettle(); + expect(scaleOf(tester), 1.0); + }); + + testWidgets('点按取消:仅颜色切换,无缩放动画', (tester) async { + await tester.pumpWidget(const _Host(initialActive: true, initialCount: 7)); + + await tester.tap(find.byIcon(Icons.favorite)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 90)); + expect(scaleOf(tester), 1.0); + expect(find.byIcon(Icons.favorite_border), findsOneWidget); + expect(find.text('6'), findsOneWidget); + await tester.pumpAndSettle(); + }); + + testWidgets('外部回滚:零动画直接跳变,计数与状态成对恢复', (tester) async { + await tester.pumpWidget(const _Host(initialActive: false, initialCount: 6)); + + // 点按激活并播完动画(在途请求随后失败的场景)。 + await tester.tap(find.byIcon(Icons.favorite_border)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.favorite), findsOneWidget); + + // 外部(非点按)回滚:下一帧即恢复,无过渡动画。 + tester + .state<_HostState>(find.byType(_Host)) + .setExternal(active: false, count: 6); + await tester.pump(); + expect(find.byIcon(Icons.favorite_border), findsOneWidget); + expect(find.text('6'), findsOneWidget); + expect(scaleOf(tester), 1.0); + }); + + testWidgets('激活动画未播完时回滚:等播完再成对跳变(§4.3a 抖动抑制)', (tester) async { + await tester.pumpWidget(const _Host(initialActive: false, initialCount: 6)); + + await tester.tap(find.byIcon(Icons.favorite_border)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 60)); + expect(find.byIcon(Icons.favorite), findsOneWidget); + + // 动画中途回滚:本帧仍显示激活视觉(等待播完),计数同持。 + tester + .state<_HostState>(find.byType(_Host)) + .setExternal(active: false, count: 6); + await tester.pump(); + expect(find.byIcon(Icons.favorite), findsOneWidget); + expect(find.text('7'), findsOneWidget); + + // 播完后成对跳回。 + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.favorite_border), findsOneWidget); + expect(find.text('6'), findsOneWidget); + }); + + testWidgets('对账静默:状态不变的计数变化直接替换,无动画', (tester) async { + await tester.pumpWidget(const _Host(initialActive: true, initialCount: 7)); + + tester + .state<_HostState>(find.byType(_Host)) + .setExternal(active: true, count: 9); + await tester.pump(); + expect(find.text('9'), findsOneWidget); + expect(scaleOf(tester), 1.0); + }); + + testWidgets('onPressed 为 null 时禁用但照常渲染激活态', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: const Scaffold( + body: LikeButton( + variant: LikeButtonVariant.bookmark, + active: true, + count: 2, + ), + ), + ), + ); + expect(find.byIcon(Icons.bookmark), findsOneWidget); + expect(find.text('2'), findsOneWidget); + }); +} diff --git a/test/features/community/community_interaction_analytics_test.dart b/test/features/community/community_interaction_analytics_test.dart new file mode 100644 index 0000000..b1c01de --- /dev/null +++ b/test/features/community/community_interaction_analytics_test.dart @@ -0,0 +1,126 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/features/community/community_interaction_analytics.dart'; + +void main() { + late List<(String, Map?)> events; + late CommunityInteractionAnalytics analytics; + + setUp(() { + events = []; + analytics = CommunityInteractionAnalytics( + (name, [props]) async => events.add((name, props)), + ); + }); + + List names() => events.map((e) => e.$1).toList(); + List?> props() => events.map((e) => e.$2).toList(); + + test('互动四事件:source 逐字段(22 号白名单键集)', () { + analytics.postLiked(source: InteractionSource.feed); + analytics.postUnliked(source: InteractionSource.postDetail); + analytics.postFavorited(source: InteractionSource.postDetail); + analytics.postUnfavorited(source: InteractionSource.feed); + + expect(names(), [ + 'post_liked', + 'post_unliked', + 'post_favorited', + 'post_unfavorited', + ]); + expect(props(), [ + {'source': 'feed'}, + {'source': 'post_detail'}, + {'source': 'post_detail'}, + {'source': 'feed'}, + ]); + }); + + test('comment_create_succeeded:durationMs/isReply/textLengthBucket', () { + analytics.commentCreateSucceeded( + durationMs: 4200, + isReply: false, + textLength: 12, + ); + expect(names(), ['comment_create_succeeded']); + expect(props().single, { + 'durationMs': 4200, + 'isReply': false, + 'textLengthBucket': 'short', + }); + }); + + test('comment_create_failed:httpStatus 由五位业务码推导,网络错误缺席', () { + analytics.commentCreateFailed( + reason: CommentCreateFailureReason.validationError, + attemptSeq: 2, + errorCode: 40000, + ); + analytics.commentCreateFailed( + reason: CommentCreateFailureReason.networkError, + attemptSeq: 3, + ); + expect(names(), ['comment_create_failed', 'comment_create_failed']); + expect(props(), [ + { + 'failureReason': 'validation_error', + 'attemptSeq': 2, + 'errorCode': 40000, + 'httpStatus': 400, + }, + {'failureReason': 'network_error', 'attemptSeq': 3}, + ]); + }); + + test('关注对:user_followed / user_unfollowed', () { + analytics.userFollowed(source: InteractionSource.postDetail); + analytics.userUnfollowed(source: InteractionSource.postDetail); + expect(names(), ['user_followed', 'user_unfollowed']); + expect(props(), [ + {'source': 'post_detail'}, + {'source': 'post_detail'}, + ]); + }); + + test('textLengthBucketOf 分桶边界(06 §1.3 红线:不报精确字数)', () { + expect(textLengthBucketOf(0), 'empty'); + expect(textLengthBucketOf(1), 'short'); + expect(textLengthBucketOf(50), 'short'); + expect(textLengthBucketOf(51), 'medium'); + expect(textLengthBucketOf(500), 'medium'); + expect(textLengthBucketOf(501), 'long'); + }); + + test('失败原因映射:网络归并口径 + 防枚举族归 not_found + 会话失效 null', () { + expect( + commentCreateFailureReasonOf(const ApiNetworkException()), + CommentCreateFailureReason.networkError, + ); + expect( + commentCreateFailureReasonOf(const ApiRateLimitException()), + CommentCreateFailureReason.rateLimited, + ); + expect( + commentCreateFailureReasonOf( + const ApiBusinessException(code: ApiCodes.paramError, message: 'x'), + ), + CommentCreateFailureReason.validationError, + ); + expect( + commentCreateFailureReasonOf( + const ApiBusinessException(code: ApiCodes.postNotFound, message: 'x'), + ), + CommentCreateFailureReason.notFound, + ); + expect( + commentCreateFailureReasonOf( + const ApiBusinessException(code: 50000, message: 'x'), + ), + CommentCreateFailureReason.serverError, + ); + expect( + commentCreateFailureReasonOf(const SessionExpiredException()), + isNull, + ); + }); +}