| 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 15h 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 18h ago | 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 17h ago | 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 17h ago | 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 16h ago | 182 | |
| Both ends of a scroll, on the same controller 5594a62 nandi 15h 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 14h 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 pencil was drawn by a text font 1cb40af nandi 16h ago | 244 | group('emoji', () { |
| 245 | // ✏️ is U+270F plus a variation selector asking for emoji presentation, |
| 246 | // and DejaVu Sans claims U+270F — so ordinary fallback draws a monochrome |
| 247 | // pencil and never reaches the emoji font. Naming the font is the fix, |
| 248 | // and this is the assertion that it is still named. |
| 249 | // |
| 250 | // Emoji *presentation*, which is narrower than "not a letter". A glyph |
| 251 | // carrying U+FE0F is asking for it, and so is anything from the emoji |
| 252 | // blocks. The arrows and crosses on buttons — → ✕ ☰ — are not: they are |
| 253 | // text glyphs on purpose and take the text font, as does the reply chip's |
| 254 | // "↩ me: a picture", where the arrow sits in a sentence. |
| 255 | |
| 256 | testWidgets('a lone glyph is drawn in the colour emoji font', |
| 257 | (tester) async { |
| 258 | core.demoUi(); |
| 259 | await layOut(tester, sizes['desktop']!); |
| 260 | final glyphs = tester |
| 261 | .widgetList<Text>(find.byType(Text)) |
| 262 | .where((t) => |
| 263 | t.data != null && |
| 264 | RegExp(r'[\ufe0f\u{1f300}-\u{1faff}]', unicode: true) |
| 265 | .hasMatch(t.data!)); |
| 266 | expect(glyphs, isNotEmpty, reason: 'the chat screen draws no emoji'); |
| 267 | for (final g in glyphs) { |
| 268 | expect(g.style?.fontFamilyFallback, contains('Noto Color Emoji'), |
| 269 | reason: 'a bare glyph without the emoji font: ${g.data}'); |
| 270 | } |
| 271 | }); |
| 272 | }); |
| Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday | 273 | } |