| Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday | 1 | /// 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 yesterday | 14 | library; |
| 15 | |
| Both ends of a scroll, on the same controller 5594a62 nandi 23h ago | 16 | import 'package:flutter/gestures.dart'; |
| Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday | 17 | import 'package:flutter/material.dart'; |
| 18 | import 'package:flutter_test/flutter_test.dart'; |
| 19 | import 'package:frq_core/frq_core.dart' as core; |
| The last of the Clojure 284b59c nandi yesterday | 20 | import 'package:frq/nim_renderer.dart'; |
| Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday | 21 | |
| 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. |
| 24 | const sizes = <String, Size>{ |
| 25 | 'phone': Size(360, 690), |
| 26 | 'desktop': Size(1280, 800), |
| 27 | 'cramped': Size(300, 500), |
| 28 | }; |
| 29 | |
| 30 | Future<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. |
| 38 | void expectLaidOut(WidgetTester tester, String what) { |
| 39 | final e = tester.takeException(); |
| 40 | expect(e, isNull, reason: '$what reported: $e'); |
| 41 | } |
| 42 | |
| 43 | void 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 yesterday | 103 | 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 yesterday | 134 | 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 yesterday | 143 | 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 yesterday | 182 | |
| Both ends of a scroll, on the same controller 5594a62 nandi 23h ago | 183 | 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 ago | 218 | 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 ago | 244 | 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 13h ago | 254 | await layOut(tester, const Size(700, 420)); |
| The arrow goes to the message 4925e54 nandi 14h ago | 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(); |
| Jump to present, which was never on screen ecb440f nandi 13h ago | 280 | await layOut(tester, const Size(700, 420)); |
| The arrow goes to the message 4925e54 nandi 14h ago | 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 | |
| Jump to present, which was never on screen ecb440f nandi 13h ago | 289 | 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 21h ago | 360 | 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 yesterday | 396 | 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 yesterday | 425 | } |