nandi/frqpublic Fork 0
dcd31d9fc7fd137826da39c11d8f678b8842a850
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 · 354 lines · 14.1 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 23h 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 yesterday20import '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 yesterday103 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 yesterday134 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 yesterday182
Both ends of a scroll, on the same controller 5594a62 nandi 23h 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 22h 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 14h 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.
254 await layOut(tester, const Size(700, 260));
255 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();
280 await layOut(tester, const Size(700, 260));
281 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
A row is named for its message, not its place 4083860 nandi 21h ago289 group('identity', () {
290 // Duplicate keys among siblings are an error Flutter throws at build
291 // time, so this is mostly a guard on the tree the core emits: every
292 // message row names itself now, and two rows must never name themselves
293 // the same thing.
294 testWidgets('every screen builds with the keys the core gives it',
295 (tester) async {
296 for (final screen in ['chat', 'chats', 'discover', 'settings']) {
297 core.demoUi();
298 if (screen != 'chat') core.dispatch('screen.$screen');
299 await layOut(tester, sizes['desktop']!);
300 expectLaidOut(tester, '$screen with keys');
301 }
302 });
303
304 testWidgets('a row keeps its element when one above it goes away',
305 (tester) async {
306 // Hiding the joins and parts takes the system line off the top of the
307 // demo backlog, which renumbers every row under it. Keyed by position
308 // that is a teardown and rebuild of all of them; keyed by the message
309 // it is one row leaving.
310 //
311 // An arriving line would not show this — it lands at the bottom and
312 // renumbers nothing, which is how the first version of this test
313 // passed against both.
314 core.demoUi();
315 await layOut(tester, sizes['desktop']!);
316 final before = tester.element(find.text('alice').first);
317 core.dispatch('join-part.toggle');
318 await tester.pump(const Duration(milliseconds: 150));
319 expectLaidOut(tester, 'the backlog with the system lines hidden');
320 expect(tester.element(find.text('alice').first), same(before),
321 reason: 'the row was torn down rather than kept');
322 });
323 });
324
The pencil was drawn by a text font 1cb40af nandi yesterday325 group('emoji', () {
326 // ✏️ is U+270F plus a variation selector asking for emoji presentation,
327 // and DejaVu Sans claims U+270F — so ordinary fallback draws a monochrome
328 // pencil and never reaches the emoji font. Naming the font is the fix,
329 // and this is the assertion that it is still named.
330 //
331 // Emoji *presentation*, which is narrower than "not a letter". A glyph
332 // carrying U+FE0F is asking for it, and so is anything from the emoji
333 // blocks. The arrows and crosses on buttons — → ✕ ☰ — are not: they are
334 // text glyphs on purpose and take the text font, as does the reply chip's
335 // "↩ me: a picture", where the arrow sits in a sentence.
336
337 testWidgets('a lone glyph is drawn in the colour emoji font',
338 (tester) async {
339 core.demoUi();
340 await layOut(tester, sizes['desktop']!);
341 final glyphs = tester
342 .widgetList<Text>(find.byType(Text))
343 .where((t) =>
344 t.data != null &&
345 RegExp(r'[\ufe0f\u{1f300}-\u{1faff}]', unicode: true)
346 .hasMatch(t.data!));
347 expect(glyphs, isNotEmpty, reason: 'the chat screen draws no emoji');
348 for (final g in glyphs) {
349 expect(g.style?.fontFamilyFallback, contains('Noto Color Emoji'),
350 reason: 'a bare glyph without the emoji font: ${g.data}');
351 }
352 });
353 });
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday354}