Files
pangolin/client/test/widget/payment_pages_test.dart
T
wangjia 272eca04ce feat(client): pay v2 支付渠道 switch + 订单列表/详情端到端
购买/支付(Phase B):
- 新 SegSwitch 段控 widget(镜像 .segswitch 原子)
- 购买页重构:顶部支付渠道 switch(默认 USDT→加密货币 / 人民币→支付宝),套餐价随渠道分币种显示,删底部弹窗选方式;点购买直达支付页
- 支付页加订单信息卡(套餐/渠道/金额按币种)
- models:PayChannel + PayCatalogItem.priceUsdtMicro/priceLabel(ch)/perMonthLabel(ch)

订单(P0,全新):
- payment_api.listOrders() + orders_provider(列表 + 详情 family)
- OrdersScreen 列表→详情(状态 pill / 可复制订单号 / 继续支付·重新购买)
- 导航:NavView.orders + desktop_shell 分支 + account 入口

配套:
- l10n +19 getter(支付渠道 4 + 订单 15)×6 语言,l10n 闸绿
- 图标单源迁移 lucide_icons_flutter + pangolin_icons.dart(codegen)
- 联系渠道订正(Telegram/邮件 support@yanmeiai.com/官网) + 前端去广告&时长
- 测试:购买 switch 分币种 + 订单列表/空态/详情 + 假 API listOrders;golden 重录
- flutter analyze 0 error,flutter test 226 全绿

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-12 00:07:15 +08:00

216 lines
8.7 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),
];
/// 轮询生命周期测试专用假 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('支付页 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');
});
}