nandi/frqpublic Fork 0
claude/ci-image-modal-deploy-f0f1d9
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

nim_layout_test.dart · 425 lines · 17.0 KBDart Blame HistoryRaw
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday1/// Every screen, laid out for real, at sizes that squeeze.
2///
3/// This is the test that was missing. `Cannot hit test a render box that has
4/// never been laid out` is what a failed layout looks like from the outside,
5/// and nothing automated ever laid the chat screen out — a GUI on Wayland
6/// cannot be clicked, so every check stopped at the room list while the
7/// biggest screen in the app went out unverified.
8///
9/// `tester.takeException()` is the whole point: a layout error is reported to
10/// FlutterError rather than thrown at the caller, so a test that only pumps
11/// and asserts on widgets passes while the screen is broken. These fail.
12///
13/// just test layout
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday14library;
15
Both ends of a scroll, on the same controller 5594a62 nandi 15h ago16import 'package:flutter/gestures.dart';
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday17import 'package:flutter/material.dart';
18import 'package:flutter_test/flutter_test.dart';
19import 'package:frq_core/frq_core.dart' as core;
The last of the Clojure 284b59c nandi 18h ago20import 'package:frq/nim_renderer.dart';
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday21
22/// Phone, small desktop, and a deliberately cramped one. The head row of the
23/// chat screen asks for more than 360 points has, which is why it wraps.
24const sizes = <String, Size>{
25 'phone': Size(360, 690),
26 'desktop': Size(1280, 800),
27 'cramped': Size(300, 500),
28};
29
30Future<void> layOut(WidgetTester tester, Size size) async {
31 await tester.binding.setSurfaceSize(size);
32 addTearDown(() => tester.binding.setSurfaceSize(null));
33 await tester.pumpWidget(const NimApp());
34 await tester.pump();
35}
36
37/// Nothing went to FlutterError while that frame was built.
38void expectLaidOut(WidgetTester tester, String what) {
39 final e = tester.takeException();
40 expect(e, isNull, reason: '$what reported: $e');
41}
42
43void main() {
44 // No offline guard needed: `demoUi` sets the state directly and none of
45 // these dispatch `connect`, so nothing here opens a socket.
46
47 group('the connect screen', () {
48 for (final entry in sizes.entries) {
49 testWidgets('lays out at ${entry.key}', (tester) async {
50 core.resetUi();
51 await layOut(tester, entry.value);
52 expectLaidOut(tester, 'connect at ${entry.key}');
53 });
54 }
55
56 testWidgets('lays out in every auth mode', (tester) async {
57 for (final mode in ['guest', 'bluesky', 'app-password']) {
58 core.resetUi();
59 core.dispatch('mode.$mode');
60 await layOut(tester, sizes['phone']!);
61 expectLaidOut(tester, 'connect in $mode');
62 }
63 });
64 });
65
66 group('the chat screen', () {
67 // The one that was never laid out by anything automated.
68 for (final entry in sizes.entries) {
69 testWidgets('lays out at ${entry.key}', (tester) async {
70 core.demoUi();
71 await layOut(tester, entry.value);
72 expectLaidOut(tester, 'chat at ${entry.key}');
73 });
74 }
75
76 testWidgets('renders the conversation it was given', (tester) async {
77 core.demoUi();
78 await layOut(tester, sizes['desktop']!);
79 expect(find.text('hello there'), findsOneWidget);
80 expect(find.text('#test'), findsWidgets);
81 expectLaidOut(tester, 'chat content');
82 });
83
84 testWidgets('lays out with the people panel up', (tester) async {
85 core.demoUi();
86 core.dispatch('users.toggle');
87 await layOut(tester, sizes['desktop']!);
88 expectLaidOut(tester, 'chat with people');
89 });
90
91 testWidgets('lays out with every compose banner showing', (tester) async {
92 core.demoUi();
93 core.dispatch('reply.to:2');
94 await layOut(tester, sizes['phone']!);
95 expectLaidOut(tester, 'chat replying');
96
97 core.demoUi();
98 core.dispatch('edit.start:7');
99 await layOut(tester, sizes['phone']!);
100 expectLaidOut(tester, 'chat editing');
101 });
102
Three buttons that did nothing now do what they say 98d6135 nandi 18h ago103 testWidgets('lays out with the emoji picker open', (tester) async {
104 // 120 emoji in a grid under a message, on a phone.
105 core.demoUi();
106 core.dispatch('react.open:2');
107 await layOut(tester, sizes['phone']!);
108 expectLaidOut(tester, 'chat with the picker');
109 });
110
111 testWidgets('lays out with the picker showing a whole group',
112 (tester) async {
113 core.demoUi();
114 core.dispatch('react.open:2');
115 core.dispatch('emoji.group:Smileys & Emotion');
116 await layOut(tester, sizes['cramped']!);
117 expectLaidOut(tester, 'chat with a full picker');
118 });
119
120 testWidgets('lays out with the overview open', (tester) async {
121 core.demoUi();
122 core.dispatch('overview.toggle');
123 await layOut(tester, sizes['phone']!);
124 expectLaidOut(tester, 'chat with the overview');
125 });
126
127 testWidgets('lays out with the lightbox open', (tester) async {
128 core.demoUi();
129 core.dispatch('lightbox:https://example.com/a.png');
130 await layOut(tester, sizes['phone']!);
131 expectLaidOut(tester, 'chat with the lightbox');
132 });
133
A face that opens someone 9bb81a1 nandi 18h ago134 testWidgets('lays out with a profile open', (tester) async {
135 core.demoUi();
136 // A guest: no identity to fetch, so the panel says so rather than
137 // spinning — and it lays out without a network.
138 core.dispatch('profile.open:alice:');
139 await layOut(tester, sizes['phone']!);
140 expectLaidOut(tester, 'chat with a guest profile');
141 });
142
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday143 testWidgets('lays out when scrolled off the present', (tester) async {
144 core.demoUi();
145 core.dispatch('jump.present');
146 await layOut(tester, sizes['phone']!);
147 expectLaidOut(tester, 'chat jumping');
148 });
149 });
150
151 group('the chats list', () {
152 for (final entry in sizes.entries) {
153 testWidgets('lays out at ${entry.key}', (tester) async {
154 core.demoUi();
155 core.dispatch('screen.chats');
156 await layOut(tester, entry.value);
157 expectLaidOut(tester, 'chats at ${entry.key}');
158 });
159 }
160
161 testWidgets('lays out with a search term in the box', (tester) async {
162 core.demoUi();
163 core.dispatch('screen.chats');
164 core.dispatch('search.change', 'te');
165 await layOut(tester, sizes['phone']!);
166 expectLaidOut(tester, 'chats searching');
167 });
168 });
169
170 group('discover and settings', () {
171 for (final screen in ['discover', 'settings']) {
172 for (final entry in sizes.entries) {
173 testWidgets('$screen lays out at ${entry.key}', (tester) async {
174 core.demoUi();
175 core.dispatch('screen.$screen');
176 await layOut(tester, entry.value);
177 expectLaidOut(tester, '$screen at ${entry.key}');
178 });
179 }
180 }
181 });
The pencil was drawn by a text font 1cb40af nandi 16h ago182
Both ends of a scroll, on the same controller 5594a62 nandi 15h ago183 group('the wheel', () {
184 // Laying a screen out was never enough to catch this one: the failure
185 // arrives on the first wheel event, not on the first frame. A `Scrollbar`
186 // with no controller asks the PrimaryScrollController, and a
187 // SingleChildScrollView is only primary on mobile — so on a desktop the
188 // scrollbar and the view held different controllers, and every scroll
189 // threw "has no ScrollPosition attached".
190 Future<void> wheelOver(WidgetTester tester, Finder target) async {
191 final pointer = TestPointer(1, PointerDeviceKind.mouse);
192 pointer.hover(tester.getCenter(target));
193 await tester.sendEventToBinding(pointer.scroll(const Offset(0, 60)));
194 await tester.pump();
195 }
196
197 for (final screen in ['chat', 'chats', 'discover', 'settings']) {
198 // The platform has to be said out loud. A widget test runs as Android
199 // by default, where a SingleChildScrollView *is* primary and attaches
200 // to the very controller the scrollbar is looking at — so this passed
201 // on the broken renderer while the desktop app threw on every wheel
202 // event. `variant` rather than an override this test resets itself,
203 // which the framework catches as a leaked debug variable.
204 testWidgets('$screen scrolls without losing its scrollbar',
205 variant: TargetPlatformVariant.desktop(), (tester) async {
206 core.demoUi();
207 if (screen != 'chat') core.dispatch('screen.$screen');
208 await layOut(tester, sizes['desktop']!);
209 expectLaidOut(tester, '$screen before scrolling');
210 final bars = find.byType(Scrollbar);
211 expect(bars, findsWidgets, reason: '$screen has nothing to scroll');
212 await wheelOver(tester, bars.first);
213 expectLaidOut(tester, '$screen on the wheel');
214 });
215 }
216 });
217
Text can be selected ec74784 nandi 15h ago218 group('selection', () {
219 testWidgets('the whole tree sits in one SelectionArea', (tester) async {
220 // One, not one per Text: a selection has to be draggable across the
221 // nick, the time and the message, which is most of what anyone wants
222 // to copy out of a chat.
223 core.demoUi();
224 await layOut(tester, sizes['desktop']!);
225 expect(find.byType(SelectionArea), findsOneWidget);
226 expect(find.text('hello there'), findsOneWidget);
227 });
228
229 testWidgets('and taps still reach what is under it', (tester) async {
230 // The risk with wrapping everything: a selection gesture that eats the
231 // taps underneath. Opening a room from the list is the plainest one.
232 core.demoUi();
233 core.dispatch('screen.chats');
234 await layOut(tester, sizes['desktop']!);
235 // The row's "Open", not its name: the name is a label in this list.
236 await tester.tap(find.text('Open').first);
237 await tester.pump();
238 expect(find.text('hello there'), findsWidgets,
239 reason: 'tapping the room did not open it');
240 expectLaidOut(tester, 'the room the tap opened');
241 });
242 });
243
The arrow goes to the message 4925e54 nandi 7h ago244 group('going to a message', () {
245 // The core has always marked the row a reply points at with
246 // `scrollHere`, and the renderer ignored the prop — so the arrow on a
247 // reply chip highlighted the message and left the view where it was.
248 testWidgets('the target is scrolled into view', (tester) async {
249 core.demoUi();
250 // Short enough that the backlog does not fit, or a scroll has nothing
251 // to do and the target is already on screen — which is what the first
252 // version of this test proved: the offset stayed at zero because
253 // `ensureVisible` was right not to move.
Jump to present, which was never on screen ecb440f nandi 5h ago254 await layOut(tester, const Size(700, 420));
The arrow goes to the message 4925e54 nandi 7h ago255 final c = tester
256 .widget<Scrollable>(find.byType(Scrollable).first)
257 .controller!;
258
259 // Away from the present, where the answered message is not.
260 c.jumpTo(c.position.maxScrollExtent);
261 await tester.pump();
262 final before = c.offset;
263 expect(before, greaterThan(0.0), reason: 'nothing to scroll here');
264
265 core.dispatch('goto:5');
266 await tester.pump(const Duration(milliseconds: 150));
267 // Settled, not a timed pump: one pump of 400ms advances the clock but
268 // does not run the scroll animation out, and the offset comes back
269 // unchanged as though nothing had happened.
270 await tester.pumpAndSettle();
271 expectLaidOut(tester, 'the backlog after going to a message');
272 expect(c.offset, lessThan(before), reason: 'the view did not move');
273 });
274
275 testWidgets('and the core is told, so the view is not pinned there',
276 (tester) async {
277 // A `jumpTo` left set would scroll back to that row on every frame,
278 // which is scrolling taken away from the reader.
279 core.demoUi();
Jump to present, which was never on screen ecb440f nandi 5h ago280 await layOut(tester, const Size(700, 420));
The arrow goes to the message 4925e54 nandi 7h ago281 await tester.tap(find.text('').first);
282 await tester.pumpAndSettle();
283 expectLaidOut(tester, 'the backlog after the arrow');
284 expect(core.dispatchFrame('noop').json.contains('"scrollHere":true'),
285 isFalse, reason: 'the core still thinks it has somewhere to go');
286 });
287 });
288
Jump to present, which was never on screen ecb440f nandi 5h ago289 group('jump to present', () {
290 // The button only shows when the reader has left the present, and
291 // nothing ever said they had: `atPresent` was set true at startup, on
292 // opening a room and by the button itself, and false by nobody. So the
293 // button was never on screen, which is what "jump to present not
294 // working" looked like from outside.
295 testWidgets('appears once the backlog is scrolled away from',
296 (tester) async {
297 core.demoUi();
298 await layOut(tester, const Size(700, 420));
299 expect(find.text('↓ Jump to present'), findsNothing);
300
301 final c = tester
302 .widget<Scrollable>(find.byType(Scrollable).first)
303 .controller!;
304 c.jumpTo(c.position.maxScrollExtent);
305 await tester.pumpAndSettle();
306 expect(find.text('↓ Jump to present'), findsOneWidget,
307 reason: 'the core was never told the reader had left');
308 });
309
310 testWidgets('and takes the view back, and goes away again',
311 (tester) async {
312 core.demoUi();
313 await layOut(tester, const Size(700, 420));
314 final c = tester
315 .widget<Scrollable>(find.byType(Scrollable).first)
316 .controller!;
317 c.jumpTo(c.position.maxScrollExtent);
318 await tester.pumpAndSettle();
319
320 await tester.tap(find.text('↓ Jump to present'));
321 await tester.pumpAndSettle();
322 // Reversed, so the present is the zero end.
323 expect(c.offset, closeTo(c.position.minScrollExtent, 1.0));
324 expect(find.text('↓ Jump to present'), findsNothing);
325 expectLaidOut(tester, 'the backlog back at the present');
326 });
327
328 testWidgets('and the button fits the cramped window too', (tester) async {
329 // It is a row the chat screen did not have before, and every row is
330 // height the backlog does not get. At 260 points tall this overflows
331 // by a pixel, which is how the first run of these tests failed; the
332 // cramped size the rest of the suite uses is the one that has to hold.
333 core.demoUi();
334 await layOut(tester, sizes['cramped']!);
335 final c = tester
336 .widget<Scrollable>(find.byType(Scrollable).first)
337 .controller!;
338 c.jumpTo(c.position.maxScrollExtent);
339 await tester.pumpAndSettle();
340 expect(find.text('↓ Jump to present'), findsOneWidget);
341 expectLaidOut(tester, 'cramped, with the jump button up');
342 });
343
344 testWidgets('a settings list has no present to be at', (tester) async {
345 // It would be the chat screen's button on the wrong screen's
346 // scrolling.
347 core.demoUi();
348 core.dispatch('screen.settings');
349 await layOut(tester, const Size(700, 420));
350 final c = tester
351 .widget<Scrollable>(find.byType(Scrollable).first)
352 .controller!;
353 c.jumpTo(c.position.maxScrollExtent);
354 await tester.pumpAndSettle();
355 expect(find.text('↓ Jump to present'), findsNothing);
356 expectLaidOut(tester, 'settings scrolled');
357 });
358 });
359
A row is named for its message, not its place 4083860 nandi 14h ago360 group('identity', () {
361 // Duplicate keys among siblings are an error Flutter throws at build
362 // time, so this is mostly a guard on the tree the core emits: every
363 // message row names itself now, and two rows must never name themselves
364 // the same thing.
365 testWidgets('every screen builds with the keys the core gives it',
366 (tester) async {
367 for (final screen in ['chat', 'chats', 'discover', 'settings']) {
368 core.demoUi();
369 if (screen != 'chat') core.dispatch('screen.$screen');
370 await layOut(tester, sizes['desktop']!);
371 expectLaidOut(tester, '$screen with keys');
372 }
373 });
374
375 testWidgets('a row keeps its element when one above it goes away',
376 (tester) async {
377 // Hiding the joins and parts takes the system line off the top of the
378 // demo backlog, which renumbers every row under it. Keyed by position
379 // that is a teardown and rebuild of all of them; keyed by the message
380 // it is one row leaving.
381 //
382 // An arriving line would not show this — it lands at the bottom and
383 // renumbers nothing, which is how the first version of this test
384 // passed against both.
385 core.demoUi();
386 await layOut(tester, sizes['desktop']!);
387 final before = tester.element(find.text('alice').first);
388 core.dispatch('join-part.toggle');
389 await tester.pump(const Duration(milliseconds: 150));
390 expectLaidOut(tester, 'the backlog with the system lines hidden');
391 expect(tester.element(find.text('alice').first), same(before),
392 reason: 'the row was torn down rather than kept');
393 });
394 });
395
The pencil was drawn by a text font 1cb40af nandi 16h ago396 group('emoji', () {
397 // ✏️ is U+270F plus a variation selector asking for emoji presentation,
398 // and DejaVu Sans claims U+270F — so ordinary fallback draws a monochrome
399 // pencil and never reaches the emoji font. Naming the font is the fix,
400 // and this is the assertion that it is still named.
401 //
402 // Emoji *presentation*, which is narrower than "not a letter". A glyph
403 // carrying U+FE0F is asking for it, and so is anything from the emoji
404 // blocks. The arrows and crosses on buttons — → ✕ ☰ — are not: they are
405 // text glyphs on purpose and take the text font, as does the reply chip's
406 // "↩ me: a picture", where the arrow sits in a sentence.
407
408 testWidgets('a lone glyph is drawn in the colour emoji font',
409 (tester) async {
410 core.demoUi();
411 await layOut(tester, sizes['desktop']!);
412 final glyphs = tester
413 .widgetList<Text>(find.byType(Text))
414 .where((t) =>
415 t.data != null &&
416 RegExp(r'[\ufe0f\u{1f300}-\u{1faff}]', unicode: true)
417 .hasMatch(t.data!));
418 expect(glyphs, isNotEmpty, reason: 'the chat screen draws no emoji');
419 for (final g in glyphs) {
420 expect(g.style?.fontFamilyFallback, contains('Noto Color Emoji'),
421 reason: 'a bare glyph without the emoji font: ${g.data}');
422 }
423 });
424 });
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday425}