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'), PayCatalogItem(sku: 'pro_quarter', plan: 'pro', days: 92, priceMinor: 6888, currency: 'CNY'), PayCatalogItem(sku: 'pro_year', plan: 'pro', days: 366, priceMinor: 19999, currency: 'CNY'), ]; /// 轮询生命周期测试专用假 API:只记 orderStatus 调用次数,其余按最小实现返回。 class _PollingFakePaymentApi implements PaymentApi { int statusCalls = 0; @override Future> catalog() 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 {} } 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('¥29.99'), findsOneWidget); expect(find.text('¥199.99'), findsOneWidget); expect(find.text(t.proQuarterly), findsOneWidget); }); 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 后停止轮询(离开页面不再调 orderStatus)', (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: '支付页卸载后轮询应已停止'); }); }