Files
pangolin/client/test/widget/payment_pages_test.dart
T
2026-07-12 19:34:07 +08:00

246 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<List<PayCatalogItem>> catalog() async => const [];
@override
Future<List<PayOrderSummary>> listOrders() 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(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<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');
});
}