262773a9de
- PaymentFlowController 新增 stopPolling():只停轮询 Timer,不发远程 cancel、 不改 state;PaymentScreen.dispose() 改调它,不再复用 cancel()——离开支付页 (切左导航 tab)不再等同于放弃订单,cancel() 只留给「取消订单」按钮的显式路径。 - redirect 分支 launchUrl 前加 canLaunchUrl 判定 + try-catch,失败时用 showPangolinToast 给可见提示(新增 l10n openAlipayFailed,zh/en 均补)。 - purchase_page.dart::_choose 下单后严格判 phase == awaitingPayment 才跳转 支付页,建单失败(failed)不再误跳。 - 回归测试:payment_pages_test.dart 补两条用例区分 dispose(不触发 cancel、 state 不归 idle)与「取消订单」按钮(远程 cancel + state 归 idle); payment_flow_test.dart 补 stopPolling() 单元测试。
207 lines
8.2 KiB
Dart
207 lines
8.2 KiB
Dart
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 / cancel 调用次数,其余按最小实现返回。
|
||
class _PollingFakePaymentApi implements PaymentApi {
|
||
int statusCalls = 0;
|
||
int cancelCalls = 0;
|
||
|
||
@override
|
||
Future<List<PayCatalogItem>> catalog() async => const [];
|
||
|
||
@override
|
||
Future<PayOrder> createOrder({required String sku, required String method, Map<String, String>? metadata}) async =>
|
||
const PayOrder(
|
||
orderNo: 'pay1',
|
||
session: PaySession(renderType: 'crypto_address', payload: {'address': 'TXYZabc123', 'amount': '4.20'}),
|
||
);
|
||
|
||
@override
|
||
Future<PayOrderStatus> orderStatus(String orderNo) async {
|
||
statusCalls++;
|
||
return PayOrderStatus(orderNo: orderNo, payStatus: 'pending', activated: false, expiresAt: null);
|
||
}
|
||
|
||
@override
|
||
Future<PayOrder> retry(String orderNo, {required String method, Map<String, String>? metadata}) async =>
|
||
const PayOrder(orderNo: 'pay1', session: PaySession(renderType: 'redirect', payload: {'url': 'https://x'}));
|
||
|
||
@override
|
||
Future<void> 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('¥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 后停止轮询,但不取消订单(离开页面≠放弃订单)', (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<bool>(
|
||
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');
|
||
});
|
||
}
|