| 1 | import "dart:async"; |
| 2 | |
| 3 | import "package:cake_wallet/new-ui/widgets/new_future_primary_button.dart"; |
| 4 | import "package:cake_wallet/new-ui/widgets/new_primary_button.dart"; |
| 5 | import "package:flutter/material.dart"; |
| 6 | import "package:flutter_svg/flutter_svg.dart"; |
| 7 | import "package:flutter_test/flutter_test.dart"; |
| 8 | |
| 9 | const _svg = '<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"/>'; |
| 10 | |
| 11 | void main() { |
| 12 | Widget wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); |
| 13 | |
| 14 | NewPrimaryButton inner(WidgetTester tester) => |
| 15 | tester.widget<NewPrimaryButton>(find.byType(NewPrimaryButton)); |
| 16 | |
| 17 | Future<void>? fire(WidgetTester tester) { |
| 18 | final result = (inner(tester).onPressed as dynamic)(); |
| 19 | return result is Future<void> ? result : null; |
| 20 | } |
| 21 | |
| 22 | Widget build({ |
| 23 | required Future<void> Function() onPressed, |
| 24 | bool disabled = false, |
| 25 | SvgPicture? image, |
| 26 | Color color = Colors.blue, |
| 27 | Color textColor = Colors.white, |
| 28 | Color borderColor = Colors.transparent, |
| 29 | String text = "Send", |
| 30 | }) => |
| 31 | wrap( |
| 32 | NewFuturePrimaryButton( |
| 33 | onPressed: onPressed, |
| 34 | text: text, |
| 35 | color: color, |
| 36 | textColor: textColor, |
| 37 | borderColor: borderColor, |
| 38 | disabled: disabled, |
| 39 | image: image, |
| 40 | ), |
| 41 | ); |
| 42 | |
| 43 | group("NewFuturePrimaryButton", () { |
| 44 | group("prop forwarding", () { |
| 45 | testWidgets("passes every prop through to NewPrimaryButton", (tester) async { |
| 46 | final image = SvgPicture.string(_svg); |
| 47 | |
| 48 | await tester.pumpWidget( |
| 49 | build( |
| 50 | onPressed: () async {}, |
| 51 | text: "Confirm", |
| 52 | color: Colors.green, |
| 53 | textColor: Colors.black, |
| 54 | borderColor: Colors.red, |
| 55 | disabled: true, |
| 56 | image: image, |
| 57 | ), |
| 58 | ); |
| 59 | |
| 60 | final button = inner(tester); |
| 61 | expect(button.text, "Confirm"); |
| 62 | expect(button.color, Colors.green); |
| 63 | expect(button.textColor, Colors.black); |
| 64 | expect(button.borderColor, Colors.red); |
| 65 | expect(button.disabled, isTrue); |
| 66 | expect(button.image, same(image)); |
| 67 | }); |
| 68 | |
| 69 | testWidgets("defaults: transparent border, not disabled, no image", (tester) async { |
| 70 | await tester.pumpWidget( |
| 71 | wrap( |
| 72 | NewFuturePrimaryButton( |
| 73 | onPressed: () async {}, |
| 74 | text: "Send", |
| 75 | color: Colors.blue, |
| 76 | textColor: Colors.white, |
| 77 | ), |
| 78 | ), |
| 79 | ); |
| 80 | |
| 81 | final button = inner(tester); |
| 82 | expect(button.borderColor, Colors.transparent); |
| 83 | expect(button.disabled, isFalse); |
| 84 | expect(button.image, isNull); |
| 85 | }); |
| 86 | |
| 87 | testWidgets("starts out not loading", (tester) async { |
| 88 | await tester.pumpWidget(build(onPressed: () async {})); |
| 89 | |
| 90 | expect(inner(tester).isLoading, isFalse); |
| 91 | }); |
| 92 | }); |
| 93 | |
| 94 | group("loading lifecycle", () { |
| 95 | testWidgets("flips isLoading true while pending, false once resolved", (tester) async { |
| 96 | final completer = Completer<void>(); |
| 97 | await tester.pumpWidget(build(onPressed: () => completer.future)); |
| 98 | |
| 99 | final pressed = fire(tester); |
| 100 | await tester.pump(); |
| 101 | expect( |
| 102 | inner(tester).isLoading, |
| 103 | isTrue, |
| 104 | reason: "should be loading while the future is in flight", |
| 105 | ); |
| 106 | |
| 107 | completer.complete(); |
| 108 | await pressed; |
| 109 | await tester.pump(); |
| 110 | expect(inner(tester).isLoading, isFalse); |
| 111 | }); |
| 112 | |
| 113 | testWidgets("synchronous-ish callback still settles back to not loading", (tester) async { |
| 114 | var calls = 0; |
| 115 | await tester.pumpWidget(build(onPressed: () async => calls++)); |
| 116 | |
| 117 | await fire(tester); |
| 118 | await tester.pump(); |
| 119 | |
| 120 | expect(calls, 1); |
| 121 | expect(inner(tester).isLoading, isFalse); |
| 122 | }); |
| 123 | |
| 124 | testWidgets("invokes the callback exactly once per press", (tester) async { |
| 125 | var calls = 0; |
| 126 | await tester.pumpWidget(build(onPressed: () async => calls++)); |
| 127 | |
| 128 | await fire(tester); |
| 129 | await tester.pump(); |
| 130 | await fire(tester); |
| 131 | await tester.pump(); |
| 132 | |
| 133 | expect(calls, 2); |
| 134 | }); |
| 135 | }); |
| 136 | |
| 137 | testWidgets("ignores presses while a previous future is pending", (tester) async { |
| 138 | final completer = Completer<void>(); |
| 139 | var calls = 0; |
| 140 | await tester.pumpWidget( |
| 141 | build( |
| 142 | onPressed: () { |
| 143 | calls++; |
| 144 | return completer.future; |
| 145 | }, |
| 146 | ), |
| 147 | ); |
| 148 | |
| 149 | final pressed = fire(tester); |
| 150 | await tester.pump(); |
| 151 | |
| 152 | unawaited(fire(tester)); |
| 153 | unawaited(fire(tester)); |
| 154 | unawaited(fire(tester)); |
| 155 | await tester.pump(); |
| 156 | |
| 157 | expect(calls, 1, reason: "extra presses must be swallowed"); |
| 158 | |
| 159 | completer.complete(); |
| 160 | await pressed; |
| 161 | await tester.pump(); |
| 162 | |
| 163 | await fire(tester); |
| 164 | await tester.pump(); |
| 165 | expect(calls, 2); |
| 166 | }); |
| 167 | |
| 168 | group("handle errors", () { |
| 169 | testWidgets("clears the loading flag when the callback throws", (tester) async { |
| 170 | await tester.pumpWidget( |
| 171 | build(onPressed: () async => throw StateError("boom")), |
| 172 | ); |
| 173 | |
| 174 | await expectLater(fire(tester), throwsA(isA<StateError>())); |
| 175 | await tester.pump(); |
| 176 | |
| 177 | expect(inner(tester).isLoading, isFalse, reason: "the finally block must reset the flag"); |
| 178 | }); |
| 179 | |
| 180 | testWidgets("rethrows instead of swallowing the failure", (tester) async { |
| 181 | await tester.pumpWidget( |
| 182 | build(onPressed: () async => throw StateError("boom")), |
| 183 | ); |
| 184 | |
| 185 | await expectLater(fire(tester), throwsA(isA<StateError>())); |
| 186 | }); |
| 187 | |
| 188 | testWidgets("stays pressable after the callback throws", (tester) async { |
| 189 | var calls = 0; |
| 190 | await tester.pumpWidget( |
| 191 | build( |
| 192 | onPressed: () { |
| 193 | calls++; |
| 194 | throw StateError("boom"); |
| 195 | }, |
| 196 | ), |
| 197 | ); |
| 198 | |
| 199 | await expectLater(fire(tester), throwsA(isA<StateError>())); |
| 200 | await tester.pump(); |
| 201 | await expectLater(fire(tester), throwsA(isA<StateError>())); |
| 202 | await tester.pump(); |
| 203 | |
| 204 | expect(calls, 2, reason: "the re-entrancy guard must not latch on failure"); |
| 205 | expect(inner(tester).isLoading, isFalse); |
| 206 | }); |
| 207 | }); |
| 208 | |
| 209 | group("disposal mid-flight", () { |
| 210 | testWidgets("does not setState after being disposed", (tester) async { |
| 211 | final completer = Completer<void>(); |
| 212 | await tester.pumpWidget(build(onPressed: () => completer.future)); |
| 213 | |
| 214 | final pressed = fire(tester); |
| 215 | await tester.pump(); |
| 216 | |
| 217 | await tester.pumpWidget(wrap(const SizedBox.shrink())); |
| 218 | expect(find.byType(NewPrimaryButton), findsNothing); |
| 219 | |
| 220 | completer.complete(); |
| 221 | await pressed; |
| 222 | await tester.pump(); |
| 223 | |
| 224 | expect( |
| 225 | tester.takeException(), |
| 226 | isNull, |
| 227 | reason: "the mounted check must guard the second setState", |
| 228 | ); |
| 229 | }); |
| 230 | |
| 231 | testWidgets("still propagates a throw when disposed", (tester) async { |
| 232 | final completer = Completer<void>(); |
| 233 | await tester.pumpWidget(build(onPressed: () => completer.future)); |
| 234 | |
| 235 | final pressed = fire(tester); |
| 236 | await tester.pump(); |
| 237 | await tester.pumpWidget(wrap(const SizedBox.shrink())); |
| 238 | |
| 239 | completer.completeError(StateError("boom")); |
| 240 | |
| 241 | await expectLater(pressed, throwsA(isA<StateError>())); |
| 242 | expect( |
| 243 | tester.takeException(), |
| 244 | isNull, |
| 245 | reason: "no setState-after-dispose FlutterError on the error path", |
| 246 | ); |
| 247 | }); |
| 248 | }); |
| 249 | |
| 250 | testWidgets("disabled is forwarded but not enforced by this wrapper", (tester) async { |
| 251 | // Press-blocking lives in NewPrimaryButton; this widget only relays the |
| 252 | // flag. If that ever moves up here, this expectation should change. |
| 253 | var calls = 0; |
| 254 | await tester.pumpWidget( |
| 255 | build(onPressed: () async => calls++, disabled: true), |
| 256 | ); |
| 257 | |
| 258 | expect(inner(tester).disabled, isTrue); |
| 259 | |
| 260 | await fire(tester); |
| 261 | await tester.pump(); |
| 262 | expect(calls, 1); |
| 263 | }); |
| 264 | }); |
| 265 | } |