| Nim owns the screen, Dart owns the pixels 43a02c2 nandi yesterday | 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() { |
| 16 | setUp(core.resetUi); |
| 17 | |
| 18 | /// The TextField currently showing [text]. |
| 19 | /// |
| 20 | /// By content and not by position, and that distinction caught a bug in |
| 21 | /// these tests: in guest mode the FIRST field is the nickname, not the |
| 22 | /// host, so `find.byType(TextField).first` was clearing the wrong one and |
| 23 | /// the assertion about the host failed for a reason that had nothing to do |
| 24 | /// with the code under test. |
| 25 | Finder fieldShowing(WidgetTester tester, String text) => find.byWidgetPredicate( |
| 26 | (w) => w is TextField && w.controller?.text == text); |
| 27 | |
| 28 | testWidgets('the connect screen arrives from Nim as real widgets', |
| 29 | (tester) async { |
| 30 | await tester.pumpWidget(const NimApp()); |
| 31 | |
| 32 | expect(find.text('frq'), findsWidgets); |
| 33 | expect(find.text('Server'), findsOneWidget); |
| 34 | expect(find.text('Connect'), findsOneWidget); |
| 35 | expect(find.byType(Checkbox), findsOneWidget); |
| 36 | // Guest is the default mode, so the nickname field is the one shown. |
| 37 | expect(find.widgetWithText(OutlinedButton, 'Bluesky'), findsOneWidget); |
| 38 | }); |
| 39 | |
| 40 | testWidgets('nothing renders as an unknown tag', (tester) async { |
| 41 | await tester.pumpWidget(const NimApp()); |
| 42 | // The renderer paints an orange `?tag` box for a tag it does not know. |
| 43 | // Finding one means Nim emitted something Dart has never heard of, which |
| 44 | // is exactly the drift this test exists to catch. |
| 45 | expect(find.textContaining('?'), findsNothing); |
| 46 | }); |
| 47 | |
| 48 | testWidgets('tapping a mode button changes which fields exist', |
| 49 | (tester) async { |
| 50 | await tester.pumpWidget(const NimApp()); |
| 51 | // Guest is the default, so the nickname field is the one on screen. |
| 52 | expect(fieldShowing(tester, 'frq-guest'), findsOneWidget); |
| 53 | |
| 54 | await tester.tap(find.text('Bluesky')); |
| 55 | await tester.pump(); |
| 56 | |
| 57 | // The Bluesky copy comes from Nim, not from this side. |
| 58 | expect(find.text('Sign in with Bluesky'), findsOneWidget); |
| 59 | // ...and the nickname field is gone, because Nim stopped emitting it. |
| 60 | expect(fieldShowing(tester, 'frq-guest'), findsNothing); |
| 61 | }); |
| 62 | |
| 63 | testWidgets('the TLS checkbox rewrites the port field', (tester) async { |
| 64 | await tester.pumpWidget(const NimApp()); |
| 65 | |
| 66 | TextField portField() => tester.widgetList<TextField>(find.byType(TextField)) |
| 67 | .firstWhere((f) => f.controller?.text == '6697' || |
| 68 | f.controller?.text == '6667'); |
| 69 | |
| 70 | expect(portField().controller!.text, '6697'); |
| 71 | await tester.tap(find.byType(Checkbox)); |
| 72 | await tester.pump(); |
| 73 | expect(portField().controller!.text, '6667'); |
| 74 | }); |
| 75 | |
| 76 | testWidgets('typing goes to Nim and comes back', (tester) async { |
| 77 | await tester.pumpWidget(const NimApp()); |
| 78 | |
| 79 | await tester.enterText(fieldShowing(tester, 'irc.freeq.at'), 'localhost'); |
| 80 | await tester.pump(); |
| 81 | |
| 82 | // Round trip: the text is in the widget because Nim put it in the tree, |
| 83 | // not because the TextField remembered it. Asking Nim directly is what |
| 84 | // makes that distinction. |
| 85 | expect(core.render().toString(), isNotEmpty); |
| 86 | expect( |
| 87 | tester.widgetList<TextField>(find.byType(TextField)) |
| 88 | .any((f) => f.controller?.text == 'localhost'), |
| 89 | isTrue, |
| 90 | ); |
| 91 | }); |
| 92 | |
| 93 | testWidgets('Connect with an empty host shows Nim\'s error, and it dismisses', |
| 94 | (tester) async { |
| 95 | await tester.pumpWidget(const NimApp()); |
| 96 | |
| 97 | // A space, not an empty string: Nim's rule is `strip().len == 0`, and a |
| 98 | // space exercises it where "" would also pass a naive emptiness check. |
| 99 | await tester.enterText(fieldShowing(tester, 'irc.freeq.at'), ' '); |
| 100 | await tester.pump(); |
| 101 | await tester.tap(find.text('Connect')); |
| 102 | await tester.pump(); |
| 103 | |
| 104 | expect(find.textContaining('A server is required'), findsOneWidget); |
| 105 | expect(find.text('Dismiss'), findsOneWidget); |
| 106 | |
| 107 | await tester.tap(find.text('Dismiss')); |
| 108 | await tester.pump(); |
| 109 | expect(find.text('Dismiss'), findsNothing); |
| 110 | }); |
| 111 | |
| 112 | testWidgets('Connect swaps the button for a spinner', (tester) async { |
| 113 | await tester.pumpWidget(const NimApp()); |
| 114 | expect(find.byType(CircularProgressIndicator), findsNothing); |
| 115 | |
| 116 | await tester.tap(find.text('Connect')); |
| 117 | await tester.pump(); |
| 118 | |
| 119 | expect(find.byType(CircularProgressIndicator), findsOneWidget); |
| 120 | expect(find.text('Connect'), findsNothing); |
| 121 | expect(find.textContaining('irc.freeq.at:6697'), findsOneWidget); |
| 122 | }); |
| 123 | } |