| Nim owns the screen, Dart owns the pixels 43a02c2 nandi 23h ago | 1 | /// The spike's real proof: Nim's tree, as Flutter widgets, driven by taps. |
| 2 | /// |
| 3 | /// A screenshot shows that something painted. This shows that the round trip |
| 4 | /// closes — a tap reaches Nim, Nim's state moves, the new tree comes back, and |
| 5 | /// the widgets change to match. That is the claim the spike is making, and it |
| 6 | /// is testable headlessly with no GL, which is why it is here rather than in a |
| 7 | /// screenshot script. |
| 8 | /// |
| 9 | /// just nim-spike-test |
| 10 | import 'package:flutter/material.dart'; |
| 11 | import 'package:flutter_test/flutter_test.dart'; |
| 12 | import 'package:frq_core/frq_core.dart' as core; |
| 13 | import 'package:cljd_flutter/nim_renderer.dart'; |
| 14 | |
| 15 | void main() { |
| A message in #test, from Nim 35994d4 nandi 23h ago | 16 | // No sockets from a widget test. `connect` otherwise opens a real TLS |
| 17 | // connection to irc.freeq.at, which these tests did until this line. |
| 18 | setUpAll(core.goOffline); |
| Nim owns the screen, Dart owns the pixels 43a02c2 nandi 23h ago | 19 | setUp(core.resetUi); |
| 20 | |
| 21 | /// The TextField currently showing [text]. |
| 22 | /// |
| 23 | /// By content and not by position, and that distinction caught a bug in |
| 24 | /// these tests: in guest mode the FIRST field is the nickname, not the |
| 25 | /// host, so `find.byType(TextField).first` was clearing the wrong one and |
| 26 | /// the assertion about the host failed for a reason that had nothing to do |
| 27 | /// with the code under test. |
| 28 | Finder fieldShowing(WidgetTester tester, String text) => find.byWidgetPredicate( |
| 29 | (w) => w is TextField && w.controller?.text == text); |
| 30 | |
| 31 | testWidgets('the connect screen arrives from Nim as real widgets', |
| 32 | (tester) async { |
| 33 | await tester.pumpWidget(const NimApp()); |
| 34 | |
| 35 | expect(find.text('frq'), findsWidgets); |
| 36 | expect(find.text('Server'), findsOneWidget); |
| 37 | expect(find.text('Connect'), findsOneWidget); |
| 38 | expect(find.byType(Checkbox), findsOneWidget); |
| 39 | // Guest is the default mode, so the nickname field is the one shown. |
| 40 | expect(find.widgetWithText(OutlinedButton, 'Bluesky'), findsOneWidget); |
| 41 | }); |
| 42 | |
| 43 | testWidgets('nothing renders as an unknown tag', (tester) async { |
| 44 | await tester.pumpWidget(const NimApp()); |
| 45 | // The renderer paints an orange `?tag` box for a tag it does not know. |
| 46 | // Finding one means Nim emitted something Dart has never heard of, which |
| 47 | // is exactly the drift this test exists to catch. |
| 48 | expect(find.textContaining('?'), findsNothing); |
| 49 | }); |
| 50 | |
| 51 | testWidgets('tapping a mode button changes which fields exist', |
| 52 | (tester) async { |
| 53 | await tester.pumpWidget(const NimApp()); |
| 54 | // Guest is the default, so the nickname field is the one on screen. |
| 55 | expect(fieldShowing(tester, 'frq-guest'), findsOneWidget); |
| 56 | |
| 57 | await tester.tap(find.text('Bluesky')); |
| 58 | await tester.pump(); |
| 59 | |
| 60 | // The Bluesky copy comes from Nim, not from this side. |
| 61 | expect(find.text('Sign in with Bluesky'), findsOneWidget); |
| 62 | // ...and the nickname field is gone, because Nim stopped emitting it. |
| 63 | expect(fieldShowing(tester, 'frq-guest'), findsNothing); |
| 64 | }); |
| 65 | |
| 66 | testWidgets('the TLS checkbox rewrites the port field', (tester) async { |
| 67 | await tester.pumpWidget(const NimApp()); |
| 68 | |
| 69 | TextField portField() => tester.widgetList<TextField>(find.byType(TextField)) |
| 70 | .firstWhere((f) => f.controller?.text == '6697' || |
| 71 | f.controller?.text == '6667'); |
| 72 | |
| 73 | expect(portField().controller!.text, '6697'); |
| 74 | await tester.tap(find.byType(Checkbox)); |
| 75 | await tester.pump(); |
| 76 | expect(portField().controller!.text, '6667'); |
| 77 | }); |
| 78 | |
| 79 | testWidgets('typing goes to Nim and comes back', (tester) async { |
| 80 | await tester.pumpWidget(const NimApp()); |
| 81 | |
| 82 | await tester.enterText(fieldShowing(tester, 'irc.freeq.at'), 'localhost'); |
| 83 | await tester.pump(); |
| 84 | |
| 85 | // Round trip: the text is in the widget because Nim put it in the tree, |
| 86 | // not because the TextField remembered it. Asking Nim directly is what |
| 87 | // makes that distinction. |
| 88 | expect(core.render().toString(), isNotEmpty); |
| 89 | expect( |
| 90 | tester.widgetList<TextField>(find.byType(TextField)) |
| 91 | .any((f) => f.controller?.text == 'localhost'), |
| 92 | isTrue, |
| 93 | ); |
| 94 | }); |
| 95 | |
| 96 | testWidgets('Connect with an empty host shows Nim\'s error, and it dismisses', |
| 97 | (tester) async { |
| 98 | await tester.pumpWidget(const NimApp()); |
| 99 | |
| 100 | // A space, not an empty string: Nim's rule is `strip().len == 0`, and a |
| 101 | // space exercises it where "" would also pass a naive emptiness check. |
| 102 | await tester.enterText(fieldShowing(tester, 'irc.freeq.at'), ' '); |
| 103 | await tester.pump(); |
| 104 | await tester.tap(find.text('Connect')); |
| 105 | await tester.pump(); |
| 106 | |
| 107 | expect(find.textContaining('A server is required'), findsOneWidget); |
| 108 | expect(find.text('Dismiss'), findsOneWidget); |
| 109 | |
| 110 | await tester.tap(find.text('Dismiss')); |
| 111 | await tester.pump(); |
| 112 | expect(find.text('Dismiss'), findsNothing); |
| 113 | }); |
| 114 | |
| 115 | testWidgets('Connect swaps the button for a spinner', (tester) async { |
| 116 | await tester.pumpWidget(const NimApp()); |
| 117 | expect(find.byType(CircularProgressIndicator), findsNothing); |
| 118 | |
| 119 | await tester.tap(find.text('Connect')); |
| 120 | await tester.pump(); |
| 121 | |
| 122 | expect(find.byType(CircularProgressIndicator), findsOneWidget); |
| 123 | expect(find.text('Connect'), findsNothing); |
| 124 | expect(find.textContaining('irc.freeq.at:6697'), findsOneWidget); |
| A message in #test, from Nim 35994d4 nandi 23h ago | 125 | // And a way out of it, which the first run of the spike did not have: |
| 126 | // a connection that never completes was a spinner with no escape. |
| 127 | expect(find.text('Cancel'), findsOneWidget); |
| Nim owns the screen, Dart owns the pixels 43a02c2 nandi 23h ago | 128 | }); |
| 129 | } |