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
|
/// 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('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}');
}
});
});
}
|