nandi/frqpublic Fork 0
0e7cd322e9fe4d04d215501ceb97171001c09cc2
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.

Jump to present, which was never on screen ecb440f · on 0e7cd322e9fe4d04d215501ceb97171001c09cc2 · nandi · 10h ago
nim_layout_test.dart · 425 lines · 17.0 KBDart Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
/// Every screen, laid out for real, at sizes that squeeze.
///
/// This is the test that was missing. `Cannot hit test a render box that has
/// never been laid out` is what a failed layout looks like from the outside,
/// and nothing automated ever laid the chat screen out — a GUI on Wayland
/// cannot be clicked, so every check stopped at the room list while the
/// biggest screen in the app went out unverified.
///
/// `tester.takeException()` is the whole point: a layout error is reported to
/// FlutterError rather than thrown at the caller, so a test that only pumps
/// and asserts on widgets passes while the screen is broken. These fail.
///
///   just test layout
library;

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frq_core/frq_core.dart' as core;
import 'package:frq/nim_renderer.dart';

/// Phone, small desktop, and a deliberately cramped one. The head row of the
/// chat screen asks for more than 360 points has, which is why it wraps.
const sizes = <String, Size>{
  'phone': Size(360, 690),
  'desktop': Size(1280, 800),
  'cramped': Size(300, 500),
};

Future<void> layOut(WidgetTester tester, Size size) async {
  await tester.binding.setSurfaceSize(size);
  addTearDown(() => tester.binding.setSurfaceSize(null));
  await tester.pumpWidget(const NimApp());
  await tester.pump();
}

/// Nothing went to FlutterError while that frame was built.
void expectLaidOut(WidgetTester tester, String what) {
  final e = tester.takeException();
  expect(e, isNull, reason: '$what reported: $e');
}

void main() {
  // No offline guard needed: `demoUi` sets the state directly and none of
  // these dispatch `connect`, so nothing here opens a socket.

  group('the connect screen', () {
    for (final entry in sizes.entries) {
      testWidgets('lays out at ${entry.key}', (tester) async {
        core.resetUi();
        await layOut(tester, entry.value);
        expectLaidOut(tester, 'connect at ${entry.key}');
      });
    }

    testWidgets('lays out in every auth mode', (tester) async {
      for (final mode in ['guest', 'bluesky', 'app-password']) {
        core.resetUi();
        core.dispatch('mode.$mode');
        await layOut(tester, sizes['phone']!);
        expectLaidOut(tester, 'connect in $mode');
      }
    });
  });

  group('the chat screen', () {
    // The one that was never laid out by anything automated.
    for (final entry in sizes.entries) {
      testWidgets('lays out at ${entry.key}', (tester) async {
        core.demoUi();
        await layOut(tester, entry.value);
        expectLaidOut(tester, 'chat at ${entry.key}');
      });
    }

    testWidgets('renders the conversation it was given', (tester) async {
      core.demoUi();
      await layOut(tester, sizes['desktop']!);
      expect(find.text('hello there'), findsOneWidget);
      expect(find.text('#test'), findsWidgets);
      expectLaidOut(tester, 'chat content');
    });

    testWidgets('lays out with the people panel up', (tester) async {
      core.demoUi();
      core.dispatch('users.toggle');
      await layOut(tester, sizes['desktop']!);
      expectLaidOut(tester, 'chat with people');
    });

    testWidgets('lays out with every compose banner showing', (tester) async {
      core.demoUi();
      core.dispatch('reply.to:2');
      await layOut(tester, sizes['phone']!);
      expectLaidOut(tester, 'chat replying');

      core.demoUi();
      core.dispatch('edit.start:7');
      await layOut(tester, sizes['phone']!);
      expectLaidOut(tester, 'chat editing');
    });

    testWidgets('lays out with the emoji picker open', (tester) async {
      // 120 emoji in a grid under a message, on a phone.
      core.demoUi();
      core.dispatch('react.open:2');
      await layOut(tester, sizes['phone']!);
      expectLaidOut(tester, 'chat with the picker');
    });

    testWidgets('lays out with the picker showing a whole group',
        (tester) async {
      core.demoUi();
      core.dispatch('react.open:2');
      core.dispatch('emoji.group:Smileys & Emotion');
      await layOut(tester, sizes['cramped']!);
      expectLaidOut(tester, 'chat with a full picker');
    });

    testWidgets('lays out with the overview open', (tester) async {
      core.demoUi();
      core.dispatch('overview.toggle');
      await layOut(tester, sizes['phone']!);
      expectLaidOut(tester, 'chat with the overview');
    });

    testWidgets('lays out with the lightbox open', (tester) async {
      core.demoUi();
      core.dispatch('lightbox:https://example.com/a.png');
      await layOut(tester, sizes['phone']!);
      expectLaidOut(tester, 'chat with the lightbox');
    });

    testWidgets('lays out with a profile open', (tester) async {
      core.demoUi();
      // A guest: no identity to fetch, so the panel says so rather than
      // spinning — and it lays out without a network.
      core.dispatch('profile.open:alice:');
      await layOut(tester, sizes['phone']!);
      expectLaidOut(tester, 'chat with a guest profile');
    });

    testWidgets('lays out when scrolled off the present', (tester) async {
      core.demoUi();
      core.dispatch('jump.present');
      await layOut(tester, sizes['phone']!);
      expectLaidOut(tester, 'chat jumping');
    });
  });

  group('the chats list', () {
    for (final entry in sizes.entries) {
      testWidgets('lays out at ${entry.key}', (tester) async {
        core.demoUi();
        core.dispatch('screen.chats');
        await layOut(tester, entry.value);
        expectLaidOut(tester, 'chats at ${entry.key}');
      });
    }

    testWidgets('lays out with a search term in the box', (tester) async {
      core.demoUi();
      core.dispatch('screen.chats');
      core.dispatch('search.change', 'te');
      await layOut(tester, sizes['phone']!);
      expectLaidOut(tester, 'chats searching');
    });
  });

  group('discover and settings', () {
    for (final screen in ['discover', 'settings']) {
      for (final entry in sizes.entries) {
        testWidgets('$screen lays out at ${entry.key}', (tester) async {
          core.demoUi();
          core.dispatch('screen.$screen');
          await layOut(tester, entry.value);
          expectLaidOut(tester, '$screen at ${entry.key}');
        });
      }
    }
  });

  group('the wheel', () {
    // Laying a screen out was never enough to catch this one: the failure
    // arrives on the first wheel event, not on the first frame. A `Scrollbar`
    // with no controller asks the PrimaryScrollController, and a
    // SingleChildScrollView is only primary on mobile — so on a desktop the
    // scrollbar and the view held different controllers, and every scroll
    // threw "has no ScrollPosition attached".
    Future<void> wheelOver(WidgetTester tester, Finder target) async {
      final pointer = TestPointer(1, PointerDeviceKind.mouse);
      pointer.hover(tester.getCenter(target));
      await tester.sendEventToBinding(pointer.scroll(const Offset(0, 60)));
      await tester.pump();
    }

    for (final screen in ['chat', 'chats', 'discover', 'settings']) {
      // The platform has to be said out loud. A widget test runs as Android
      // by default, where a SingleChildScrollView *is* primary and attaches
      // to the very controller the scrollbar is looking at — so this passed
      // on the broken renderer while the desktop app threw on every wheel
      // event. `variant` rather than an override this test resets itself,
      // which the framework catches as a leaked debug variable.
      testWidgets('$screen scrolls without losing its scrollbar',
          variant: TargetPlatformVariant.desktop(), (tester) async {
        core.demoUi();
        if (screen != 'chat') core.dispatch('screen.$screen');
        await layOut(tester, sizes['desktop']!);
        expectLaidOut(tester, '$screen before scrolling');
        final bars = find.byType(Scrollbar);
        expect(bars, findsWidgets, reason: '$screen has nothing to scroll');
        await wheelOver(tester, bars.first);
        expectLaidOut(tester, '$screen on the wheel');
      });
    }
  });

  group('selection', () {
    testWidgets('the whole tree sits in one SelectionArea', (tester) async {
      // One, not one per Text: a selection has to be draggable across the
      // nick, the time and the message, which is most of what anyone wants
      // to copy out of a chat.
      core.demoUi();
      await layOut(tester, sizes['desktop']!);
      expect(find.byType(SelectionArea), findsOneWidget);
      expect(find.text('hello there'), findsOneWidget);
    });

    testWidgets('and taps still reach what is under it', (tester) async {
      // The risk with wrapping everything: a selection gesture that eats the
      // taps underneath. Opening a room from the list is the plainest one.
      core.demoUi();
      core.dispatch('screen.chats');
      await layOut(tester, sizes['desktop']!);
      // The row's "Open", not its name: the name is a label in this list.
      await tester.tap(find.text('Open').first);
      await tester.pump();
      expect(find.text('hello there'), findsWidgets,
          reason: 'tapping the room did not open it');
      expectLaidOut(tester, 'the room the tap opened');
    });
  });

  group('going to a message', () {
    // The core has always marked the row a reply points at with
    // `scrollHere`, and the renderer ignored the prop — so the arrow on a
    // reply chip highlighted the message and left the view where it was.
    testWidgets('the target is scrolled into view', (tester) async {
      core.demoUi();
      // Short enough that the backlog does not fit, or a scroll has nothing
      // to do and the target is already on screen — which is what the first
      // version of this test proved: the offset stayed at zero because
      // `ensureVisible` was right not to move.
      await layOut(tester, const Size(700, 420));
      final c = tester
          .widget<Scrollable>(find.byType(Scrollable).first)
          .controller!;

      // Away from the present, where the answered message is not.
      c.jumpTo(c.position.maxScrollExtent);
      await tester.pump();
      final before = c.offset;
      expect(before, greaterThan(0.0), reason: 'nothing to scroll here');

      core.dispatch('goto:5');
      await tester.pump(const Duration(milliseconds: 150));
      // Settled, not a timed pump: one pump of 400ms advances the clock but
      // does not run the scroll animation out, and the offset comes back
      // unchanged as though nothing had happened.
      await tester.pumpAndSettle();
      expectLaidOut(tester, 'the backlog after going to a message');
      expect(c.offset, lessThan(before), reason: 'the view did not move');
    });

    testWidgets('and the core is told, so the view is not pinned there',
        (tester) async {
      // A `jumpTo` left set would scroll back to that row on every frame,
      // which is scrolling taken away from the reader.
      core.demoUi();
      await layOut(tester, const Size(700, 420));
      await tester.tap(find.text('').first);
      await tester.pumpAndSettle();
      expectLaidOut(tester, 'the backlog after the arrow');
      expect(core.dispatchFrame('noop').json.contains('"scrollHere":true'),
          isFalse, reason: 'the core still thinks it has somewhere to go');
    });
  });

  group('jump to present', () {
    // The button only shows when the reader has left the present, and
    // nothing ever said they had: `atPresent` was set true at startup, on
    // opening a room and by the button itself, and false by nobody. So the
    // button was never on screen, which is what "jump to present not
    // working" looked like from outside.
    testWidgets('appears once the backlog is scrolled away from',
        (tester) async {
      core.demoUi();
      await layOut(tester, const Size(700, 420));
      expect(find.text('↓ Jump to present'), findsNothing);

      final c = tester
          .widget<Scrollable>(find.byType(Scrollable).first)
          .controller!;
      c.jumpTo(c.position.maxScrollExtent);
      await tester.pumpAndSettle();
      expect(find.text('↓ Jump to present'), findsOneWidget,
          reason: 'the core was never told the reader had left');
    });

    testWidgets('and takes the view back, and goes away again',
        (tester) async {
      core.demoUi();
      await layOut(tester, const Size(700, 420));
      final c = tester
          .widget<Scrollable>(find.byType(Scrollable).first)
          .controller!;
      c.jumpTo(c.position.maxScrollExtent);
      await tester.pumpAndSettle();

      await tester.tap(find.text('↓ Jump to present'));
      await tester.pumpAndSettle();
      // Reversed, so the present is the zero end.
      expect(c.offset, closeTo(c.position.minScrollExtent, 1.0));
      expect(find.text('↓ Jump to present'), findsNothing);
      expectLaidOut(tester, 'the backlog back at the present');
    });

    testWidgets('and the button fits the cramped window too', (tester) async {
      // It is a row the chat screen did not have before, and every row is
      // height the backlog does not get. At 260 points tall this overflows
      // by a pixel, which is how the first run of these tests failed; the
      // cramped size the rest of the suite uses is the one that has to hold.
      core.demoUi();
      await layOut(tester, sizes['cramped']!);
      final c = tester
          .widget<Scrollable>(find.byType(Scrollable).first)
          .controller!;
      c.jumpTo(c.position.maxScrollExtent);
      await tester.pumpAndSettle();
      expect(find.text('↓ Jump to present'), findsOneWidget);
      expectLaidOut(tester, 'cramped, with the jump button up');
    });

    testWidgets('a settings list has no present to be at', (tester) async {
      // It would be the chat screen's button on the wrong screen's
      // scrolling.
      core.demoUi();
      core.dispatch('screen.settings');
      await layOut(tester, const Size(700, 420));
      final c = tester
          .widget<Scrollable>(find.byType(Scrollable).first)
          .controller!;
      c.jumpTo(c.position.maxScrollExtent);
      await tester.pumpAndSettle();
      expect(find.text('↓ Jump to present'), findsNothing);
      expectLaidOut(tester, 'settings scrolled');
    });
  });

  group('identity', () {
    // Duplicate keys among siblings are an error Flutter throws at build
    // time, so this is mostly a guard on the tree the core emits: every
    // message row names itself now, and two rows must never name themselves
    // the same thing.
    testWidgets('every screen builds with the keys the core gives it',
        (tester) async {
      for (final screen in ['chat', 'chats', 'discover', 'settings']) {
        core.demoUi();
        if (screen != 'chat') core.dispatch('screen.$screen');
        await layOut(tester, sizes['desktop']!);
        expectLaidOut(tester, '$screen with keys');
      }
    });

    testWidgets('a row keeps its element when one above it goes away',
        (tester) async {
      // Hiding the joins and parts takes the system line off the top of the
      // demo backlog, which renumbers every row under it. Keyed by position
      // that is a teardown and rebuild of all of them; keyed by the message
      // it is one row leaving.
      //
      // An arriving line would not show this — it lands at the bottom and
      // renumbers nothing, which is how the first version of this test
      // passed against both.
      core.demoUi();
      await layOut(tester, sizes['desktop']!);
      final before = tester.element(find.text('alice').first);
      core.dispatch('join-part.toggle');
      await tester.pump(const Duration(milliseconds: 150));
      expectLaidOut(tester, 'the backlog with the system lines hidden');
      expect(tester.element(find.text('alice').first), same(before),
          reason: 'the row was torn down rather than kept');
    });
  });

  group('emoji', () {
    // ✏️ is U+270F plus a variation selector asking for emoji presentation,
    // and DejaVu Sans claims U+270F — so ordinary fallback draws a monochrome
    // pencil and never reaches the emoji font. Naming the font is the fix,
    // and this is the assertion that it is still named.
    //
    // Emoji *presentation*, which is narrower than "not a letter". A glyph
    // carrying U+FE0F is asking for it, and so is anything from the emoji
    // blocks. The arrows and crosses on buttons — → ✕ ☰ — are not: they are
    // text glyphs on purpose and take the text font, as does the reply chip's
    // "↩ me: a picture", where the arrow sits in a sentence.

    testWidgets('a lone glyph is drawn in the colour emoji font',
        (tester) async {
      core.demoUi();
      await layOut(tester, sizes['desktop']!);
      final glyphs = tester
          .widgetList<Text>(find.byType(Text))
          .where((t) =>
              t.data != null &&
              RegExp(r'[\ufe0f\u{1f300}-\u{1faff}]', unicode: true)
                  .hasMatch(t.data!));
      expect(glyphs, isNotEmpty, reason: 'the chat screen draws no emoji');
      for (final g in glyphs) {
        expect(g.style?.fontFamilyFallback, contains('Noto Color Emoji'),
            reason: 'a bare glyph without the emoji font: ${g.data}');
      }
    });
  });
}