Main coves client
1import 'package:coves_flutter/models/comment.dart';
2import 'package:coves_flutter/models/post.dart';
3import 'package:coves_flutter/widgets/comment_thread.dart';
4import 'package:flutter/material.dart';
5import 'package:flutter_test/flutter_test.dart';
6import 'package:provider/provider.dart';
7
8import '../test_helpers/mock_providers.dart';
9
10void main() {
11 late MockAuthProvider mockAuthProvider;
12 late MockVoteProvider mockVoteProvider;
13
14 setUp(() {
15 mockAuthProvider = MockAuthProvider();
16 mockVoteProvider = MockVoteProvider();
17 });
18
19 /// Helper to create a test comment
20 CommentView createComment({
21 required String uri,
22 String content = 'Test comment',
23 String handle = 'test.user',
24 }) {
25 return CommentView(
26 uri: uri,
27 cid: 'cid-$uri',
28 content: content,
29 createdAt: DateTime(2025),
30 indexedAt: DateTime(2025),
31 author: AuthorView(did: 'did:plc:author', handle: handle),
32 post: CommentRef(uri: 'at://did:plc:test/post/123', cid: 'post-cid'),
33 stats: CommentStats(upvotes: 5, downvotes: 1, score: 4),
34 );
35 }
36
37 /// Helper to create a thread with nested replies
38 ThreadViewComment createThread({
39 required String uri,
40 String content = 'Test comment',
41 List<ThreadViewComment>? replies,
42 }) {
43 return ThreadViewComment(
44 comment: createComment(uri: uri, content: content),
45 replies: replies,
46 );
47 }
48
49 Widget createTestWidget(
50 ThreadViewComment thread, {
51 int depth = 0,
52 int maxDepth = 5,
53 void Function(ThreadViewComment)? onCommentTap,
54 void Function(String uri)? onCollapseToggle,
55 void Function(ThreadViewComment, List<ThreadViewComment>)? onContinueThread,
56 Set<String> collapsedComments = const {},
57 List<ThreadViewComment> ancestors = const [],
58 }) {
59 return MultiProvider(
60 providers: [
61 ChangeNotifierProvider<MockAuthProvider>.value(value: mockAuthProvider),
62 ChangeNotifierProvider<MockVoteProvider>.value(value: mockVoteProvider),
63 ],
64 child: MaterialApp(
65 home: Scaffold(
66 body: SingleChildScrollView(
67 child: CommentThread(
68 thread: thread,
69 depth: depth,
70 maxDepth: maxDepth,
71 onCommentTap: onCommentTap,
72 onCollapseToggle: onCollapseToggle,
73 onContinueThread: onContinueThread,
74 collapsedComments: collapsedComments,
75 ancestors: ancestors,
76 ),
77 ),
78 ),
79 ),
80 );
81 }
82
83 group('CommentThread', () {
84 group('countDescendants', () {
85 test('returns 0 for thread with no replies', () {
86 final thread = createThread(uri: 'comment/1');
87
88 expect(CommentThread.countDescendants(thread), 0);
89 });
90
91 test('returns 0 for thread with empty replies', () {
92 final thread = createThread(uri: 'comment/1', replies: []);
93
94 expect(CommentThread.countDescendants(thread), 0);
95 });
96
97 test('counts direct replies', () {
98 final thread = createThread(
99 uri: 'comment/1',
100 replies: [
101 createThread(uri: 'comment/2'),
102 createThread(uri: 'comment/3'),
103 ],
104 );
105
106 expect(CommentThread.countDescendants(thread), 2);
107 });
108
109 test('counts nested replies recursively', () {
110 final thread = createThread(
111 uri: 'comment/1',
112 replies: [
113 createThread(
114 uri: 'comment/2',
115 replies: [
116 createThread(uri: 'comment/3'),
117 createThread(
118 uri: 'comment/4',
119 replies: [
120 createThread(uri: 'comment/5'),
121 ],
122 ),
123 ],
124 ),
125 ],
126 );
127
128 // 1 direct reply + 2 nested + 1 deeply nested = 4
129 expect(CommentThread.countDescendants(thread), 4);
130 });
131 });
132
133 group(
134 'rendering',
135 skip: 'Provider type compatibility issues - needs mock refactoring',
136 () {
137 testWidgets('renders comment content', (tester) async {
138 final thread = createThread(
139 uri: 'comment/1',
140 content: 'Hello, world!',
141 );
142
143 await tester.pumpWidget(createTestWidget(thread));
144
145 expect(find.text('Hello, world!'), findsOneWidget);
146 });
147
148 testWidgets('renders nested replies when depth < maxDepth',
149 (tester) async {
150 final thread = createThread(
151 uri: 'comment/1',
152 content: 'Parent',
153 replies: [
154 createThread(uri: 'comment/2', content: 'Child 1'),
155 createThread(uri: 'comment/3', content: 'Child 2'),
156 ],
157 );
158
159 await tester.pumpWidget(createTestWidget(thread));
160
161 expect(find.text('Parent'), findsOneWidget);
162 expect(find.text('Child 1'), findsOneWidget);
163 expect(find.text('Child 2'), findsOneWidget);
164 });
165
166 testWidgets('shows "Read X more replies" at maxDepth', (tester) async {
167 final thread = createThread(
168 uri: 'comment/1',
169 content: 'At max depth',
170 replies: [
171 createThread(uri: 'comment/2', content: 'Hidden reply'),
172 ],
173 );
174
175 await tester.pumpWidget(createTestWidget(thread, depth: 5));
176
177 expect(find.text('At max depth'), findsOneWidget);
178 expect(find.textContaining('Read'), findsOneWidget);
179 expect(find.textContaining('more'), findsOneWidget);
180 // The hidden reply should NOT be rendered
181 expect(find.text('Hidden reply'), findsNothing);
182 });
183
184 testWidgets('does not show "Read more" when depth < maxDepth',
185 (tester) async {
186 final thread = createThread(
187 uri: 'comment/1',
188 replies: [
189 createThread(uri: 'comment/2'),
190 ],
191 );
192
193 await tester.pumpWidget(createTestWidget(thread, depth: 3));
194
195 expect(find.textContaining('Read'), findsNothing);
196 });
197
198 testWidgets('calls onContinueThread with correct ancestors',
199 (tester) async {
200 ThreadViewComment? tappedThread;
201 List<ThreadViewComment>? receivedAncestors;
202
203 final thread = createThread(
204 uri: 'comment/1',
205 replies: [
206 createThread(uri: 'comment/2'),
207 ],
208 );
209
210 await tester.pumpWidget(createTestWidget(
211 thread,
212 depth: 5,
213 onContinueThread: (t, a) {
214 tappedThread = t;
215 receivedAncestors = a;
216 },
217 ));
218
219 // Find and tap the "Read more" link
220 final readMoreFinder = find.textContaining('Read');
221 expect(readMoreFinder, findsOneWidget);
222
223 await tester.tap(readMoreFinder);
224 await tester.pump();
225
226 expect(tappedThread, isNotNull);
227 expect(tappedThread!.comment.uri, 'comment/1');
228 expect(receivedAncestors, isNotNull);
229 // ancestors should NOT include the thread itself
230 expect(receivedAncestors, isEmpty);
231 });
232
233 testWidgets('handles correct reply count pluralization',
234 (tester) async {
235 // Single reply
236 final singleReplyThread = createThread(
237 uri: 'comment/1',
238 replies: [
239 createThread(uri: 'comment/2'),
240 ],
241 );
242
243 await tester.pumpWidget(
244 createTestWidget(singleReplyThread, depth: 5),
245 );
246
247 expect(find.text('Read 1 more reply'), findsOneWidget);
248 });
249
250 testWidgets('handles multiple replies pluralization', (tester) async {
251 final multiReplyThread = createThread(
252 uri: 'comment/1',
253 replies: [
254 createThread(uri: 'comment/2'),
255 createThread(uri: 'comment/3'),
256 createThread(uri: 'comment/4'),
257 ],
258 );
259
260 await tester.pumpWidget(createTestWidget(multiReplyThread, depth: 5));
261
262 expect(find.text('Read 3 more replies'), findsOneWidget);
263 });
264 },
265 );
266 });
267}