feat(client): 购买页三档 + 支付页 render_type 多态(crypto/redirect/qr 预留)+ 导航接线

购买页三档 pro(月/季/年,金额来自 GET /v1/pay/catalog)选档 → 选支付方式 → 下单;
支付页按 session.render_type 多态渲染(crypto_address 地址+精确金额+复制、redirect
外链拉起、qr 预留复制兜底),轮询用 Task 6 的 paymentFlowProvider,409
CURRENCY_MISMATCH 走「换方式开新单」。同时收口 paymentFlowProvider 非 autoDispose
带来的轮询生命周期缺口——支付页 dispose 时停轮询(cancel() 延后到微任务执行,避免
在自身 unmount 期间同步改 state 炸 Riverpod 断言)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
wangjia
2026-07-10 23:50:35 +08:00
parent e66f79fdf6
commit a5fff28134
15 changed files with 783 additions and 14 deletions
+171
View File
@@ -0,0 +1,171 @@
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<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 {}
}
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<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: '支付页卸载后轮询应已停止');
});
}