import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pangolin_vpn/l10n/strings_zh.dart'; import 'package:pangolin_vpn/models/payment.dart'; import 'package:pangolin_vpn/pangolin_theme.dart'; import 'package:pangolin_vpn/screens/payment_page.dart'; import 'package:pangolin_vpn/screens/purchase_page.dart'; import 'package:pangolin_vpn/services/payment_api.dart'; import 'package:pangolin_vpn/state/payment_provider.dart'; import 'package:pangolin_vpn/widgets/plan_card.dart'; import '../helpers/harness.dart'; const _items = [ PayCatalogItem(sku: 'pro_month', plan: 'pro', days: 31, priceMinor: 2999, currency: 'CNY', priceUsdtMicro: 4990000), PayCatalogItem(sku: 'pro_quarter', plan: 'pro', days: 92, priceMinor: 6888, currency: 'CNY', priceUsdtMicro: 12990000), PayCatalogItem(sku: 'pro_year', plan: 'pro', days: 366, priceMinor: 19999, currency: 'CNY', priceUsdtMicro: 34990000), ]; const _itemsWithPromo = [ PayCatalogItem( sku: 'pro_month_promo', plan: 'pro', days: 31, priceMinor: 600, currency: 'CNY', priceUsdtMicro: 1000000, promo: true), ..._items, ]; /// 轮询生命周期测试专用假 API:记 orderStatus / cancel 调用次数,其余按最小实现返回。 class _PollingFakePaymentApi implements PaymentApi { int statusCalls = 0; int cancelCalls = 0; @override Future> catalog() async => const []; @override Future> listOrders() async => const []; @override Future createOrder({required String sku, required String method, Map? metadata}) async => const PayOrder( orderNo: 'pay1', session: PaySession(renderType: 'crypto_address', payload: {'address': 'TXYZabc123', 'amount': '4.20'}), ); @override Future orderStatus(String orderNo) async { statusCalls++; return PayOrderStatus(orderNo: orderNo, payStatus: 'pending', activated: false, expiresAt: null); } @override Future retry(String orderNo, {required String method, Map? metadata}) async => const PayOrder(orderNo: 'pay1', session: PaySession(renderType: 'redirect', payload: {'url': 'https://x'})); @override Future cancel(String orderNo) async { cancelCalls++; } } void main() { setUpAll(disableGoogleFontsFetching); const t = StringsZh(); testWidgets('购买页:三档 PlanCard + 价格', (tester) async { // 默认测试视口(800×600)放不下三张 PlanCard(ListView 非懒加载但受 // Viewport cacheExtent 限制,第三张滚动区外不构建)——加高视口, // 不改断言本身。 tester.view.physicalSize = const Size(800, 2400); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); await tester.pumpWidget(wrapThemed( PurchaseScreen(t: t, embedded: true), overrides: [payCatalogProvider.overrideWith((ref) async => _items)], )); await tester.pumpAndSettle(); expect(find.byType(PlanCard), findsNWidgets(3)); expect(find.text(t.proQuarterly), findsOneWidget); // 默认支付渠道 = USDT → 套餐价显 USDT expect(find.text('\$4.99'), findsOneWidget); expect(find.text('\$34.99'), findsOneWidget); // 切到人民币渠道 → 套餐价改显 ¥ await tester.tap(find.text(t.payChannelCny)); await tester.pumpAndSettle(); expect(find.text('¥29.99'), findsOneWidget); expect(find.text('¥199.99'), findsOneWidget); }); testWidgets('购买页:全员优惠月付特殊卡片(随渠道显价 + 原价划线)', (tester) async { tester.view.physicalSize = const Size(800, 2400); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); await tester.pumpWidget(wrapThemed( PurchaseScreen(t: t, embedded: true), overrides: [payCatalogProvider.overrideWith((ref) async => _itemsWithPromo)], )); await tester.pumpAndSettle(); // 普通 3 档照旧渲染 + 1 张特殊卡片(非 PlanCard)。 expect(find.byType(PlanCard), findsNWidgets(3)); expect(find.text(t.promoBadge), findsOneWidget); // 默认 USDT → 现价 \$1.00;原价(pro_month)\$4.99 同时出现在划线原价 + 下方普通 // pro_month PlanCard(两处同值,断言 2 处)。 expect(find.text('\$1.00'), findsOneWidget); expect(find.text('\$4.99'), findsNWidgets(2)); // 切到人民币 → 现价 ¥6.00;原价 ¥29.99 同理 2 处。 await tester.tap(find.text(t.payChannelCny)); await tester.pumpAndSettle(); expect(find.text('¥6.00'), findsOneWidget); expect(find.text('¥29.99'), findsNWidgets(2)); }); testWidgets('支付页 crypto_address:地址/金额/复制,金额用 mono', (tester) async { final flow = PaymentFlowState( phase: PaymentPhase.awaitingPayment, item: _items.first, method: 'crypto', order: const PayOrder( orderNo: 'pay1', session: PaySession(renderType: 'crypto_address', payload: { 'address': 'TXYZabc123', 'amount': '4.201234', 'amount_minor': 4201234, 'currency': 'USDT', 'network': 'TRC20', }), ), ); await tester.pumpWidget(wrapThemed( PaymentScreen(t: t, embedded: true), overrides: [ paymentFlowProvider.overrideWith((ref) => PaymentFlowController.fixed(flow)), ], )); await tester.pump(); expect(find.text('TXYZabc123'), findsOneWidget); expect(find.textContaining('4.201234'), findsOneWidget); expect(find.text(t.payExactAmountHint), findsOneWidget); expect(find.text(t.awaitingPayment), findsOneWidget); }); testWidgets('支付页 redirect:外链按钮 + 引导文案', (tester) async { final flow = PaymentFlowState( phase: PaymentPhase.awaitingPayment, item: _items.first, method: 'alipay', order: const PayOrder( orderNo: 'pay1', session: PaySession(renderType: 'redirect', payload: {'url': 'https://alipay.example/x'}), ), ); await tester.pumpWidget(wrapThemed( PaymentScreen(t: t, embedded: true), overrides: [paymentFlowProvider.overrideWith((ref) => PaymentFlowController.fixed(flow))], )); await tester.pump(); expect(find.text(t.openAlipay), findsOneWidget); expect(find.text(t.openAlipayHint), findsOneWidget); }); testWidgets('支付页成功态:已开通 + 完成', (tester) async { final flow = PaymentFlowState( phase: PaymentPhase.succeeded, item: _items.first, method: 'crypto', status: PayOrderStatus( orderNo: 'pay1', payStatus: 'succeeded', activated: true, expiresAt: DateTime.utc(2026, 8, 10)), ); await tester.pumpWidget(wrapThemed( PaymentScreen(t: t, embedded: true), overrides: [paymentFlowProvider.overrideWith((ref) => PaymentFlowController.fixed(flow))], )); await tester.pump(); expect(find.text(t.paySucceeded), findsOneWidget); expect(find.text(t.payDone), findsOneWidget); }); testWidgets('支付页 dispose 后停止轮询,但不取消订单(离开页面≠放弃订单)', (tester) async { final api = _PollingFakePaymentApi(); final container = ProviderContainer(overrides: [paymentApiProvider.overrideWithValue(api)]); addTearDown(container.dispose); await container.read(paymentFlowProvider.notifier).start(_items.first, 'crypto'); expect(container.read(paymentFlowProvider).phase, PaymentPhase.awaitingPayment); final showPage = ValueNotifier(true); addTearDown(showPage.dispose); await tester.pumpWidget(UncontrolledProviderScope( container: container, child: MaterialApp( debugShowCheckedModeBanner: false, theme: PangolinTheme.light, home: ValueListenableBuilder( valueListenable: showPage, builder: (_, show, __) => show ? PaymentScreen(t: t, embedded: true) : const SizedBox.shrink(), ), ), )); await tester.pump(); // 页面挂载期间:轮询按 3s 间隔正常触发。 await tester.pump(const Duration(seconds: 4)); final callsWhileMounted = api.statusCalls; expect(callsWhileMounted, greaterThan(0)); // 卸载支付页(触发 dispose)→ 只停轮询,之后不应再新增 orderStatus 调用。 showPage.value = false; await tester.pump(); await tester.pump(const Duration(seconds: 4)); await tester.pump(const Duration(seconds: 4)); expect(api.statusCalls, callsWhileMounted, reason: '支付页卸载后轮询应已停止'); // 关键回归断言:dispose 绝不能是「隐式取消订单」——不发远程 cancel, // controller state 也不应被重置为 idle,订单在后台仍然存活。 expect(api.cancelCalls, 0, reason: 'dispose 不应远程取消订单'); expect(container.read(paymentFlowProvider).phase, PaymentPhase.awaitingPayment, reason: 'dispose 后订单状态应保持 awaitingPayment,不应被静默重置为 idle'); expect(container.read(paymentFlowProvider).order?.orderNo, 'pay1'); }); testWidgets('支付页「取消订单」按钮:确实远程取消 + 状态归 idle(与 dispose 路径区分)', (tester) async { final api = _PollingFakePaymentApi(); final container = ProviderContainer(overrides: [paymentApiProvider.overrideWithValue(api)]); addTearDown(container.dispose); await container.read(paymentFlowProvider.notifier).start(_items.first, 'crypto'); expect(container.read(paymentFlowProvider).phase, PaymentPhase.awaitingPayment); await tester.pumpWidget(UncontrolledProviderScope( container: container, child: MaterialApp( debugShowCheckedModeBanner: false, theme: PangolinTheme.light, home: PaymentScreen(t: t, embedded: true), ), )); await tester.pump(); await tester.tap(find.text(t.cancelOrder)); await tester.pumpAndSettle(); expect(api.cancelCalls, 1, reason: '显式点「取消订单」应远程取消'); expect(container.read(paymentFlowProvider).phase, PaymentPhase.idle, reason: '显式取消后状态应归 idle'); }); }