feat(client): 购买模型/仓库切 pay v2 契约(render_type 多态 + 分金额 + 订单列表)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
/// 分转元字符串,千分位分组 + 两位小数,纯整数运算(金额禁 float)。
|
||||
///
|
||||
/// 例:299900 → "2,999.00";100 → "1.00";0 → "0.00";负数保留符号。
|
||||
String yuanFromMinor(int minor) {
|
||||
final negative = minor < 0;
|
||||
final abs = negative ? -minor : minor;
|
||||
final yuan = abs ~/ 100;
|
||||
final cents = abs % 100;
|
||||
|
||||
final yuanDigits = yuan.toString();
|
||||
final len = yuanDigits.length;
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < len; i++) {
|
||||
if (i > 0 && (len - i) % 3 == 0) buffer.write(',');
|
||||
buffer.write(yuanDigits[i]);
|
||||
}
|
||||
|
||||
final centsStr = cents.toString().padLeft(2, '0');
|
||||
return '${negative ? '-' : ''}$buffer.$centsStr';
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import '../core/config/app_constants.dart';
|
||||
import '../core/utils/money.dart';
|
||||
|
||||
class LicenseInfo {
|
||||
final int id;
|
||||
@@ -65,24 +66,41 @@ class LicenseInfo {
|
||||
phase == 'grace' || phase == 'readonly' || phase == 'locked';
|
||||
}
|
||||
|
||||
/// POST /license/purchase 响应:pay 收银台跳转信息。
|
||||
/// POST /license/purchase 响应:pay v2 收银台会话信息(render_type 多态)。
|
||||
class PurchaseOrder {
|
||||
final String payUrl;
|
||||
final String outTradeNo;
|
||||
final String amount; // 实付金额(pay 侧权威价,如 "2999.00")
|
||||
final String renderType; // redirect | qr | ...
|
||||
final Map<String, dynamic> payload;
|
||||
final int amountMinor;
|
||||
final String currency;
|
||||
final String subject;
|
||||
|
||||
const PurchaseOrder({
|
||||
required this.payUrl,
|
||||
required this.outTradeNo,
|
||||
required this.amount,
|
||||
required this.renderType,
|
||||
required this.payload,
|
||||
required this.amountMinor,
|
||||
required this.currency,
|
||||
required this.subject,
|
||||
});
|
||||
|
||||
/// render_type == redirect 时的跳转链接(payload['url'])。
|
||||
String get redirectUrl => payload['url'] as String? ?? '';
|
||||
|
||||
/// render_type == qr 时的二维码内容(payload['qr_content'])。
|
||||
String get qrContent => payload['qr_content'] as String? ?? '';
|
||||
|
||||
/// 兼容旧字段:purchase_card.dart(Task 7 改造前)仍读 payUrl/amount。
|
||||
String get payUrl => redirectUrl;
|
||||
String get amount => yuanFromMinor(amountMinor);
|
||||
|
||||
factory PurchaseOrder.fromJson(Map<String, dynamic> json) => PurchaseOrder(
|
||||
payUrl: json['pay_url'] as String? ?? '',
|
||||
outTradeNo: json['out_trade_no'] as String? ?? '',
|
||||
amount: json['amount'] as String? ?? '',
|
||||
renderType: json['render_type'] as String? ?? 'redirect',
|
||||
payload:
|
||||
(json['payload'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||
amountMinor: (json['amount_minor'] as num?)?.toInt() ?? 0,
|
||||
currency: json['currency'] as String? ?? 'CNY',
|
||||
subject: json['subject'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
@@ -91,13 +109,17 @@ class PurchaseOrder {
|
||||
class PurchaseStatusInfo {
|
||||
final String outTradeNo;
|
||||
final String status; // pending | paid | failed
|
||||
final String amount;
|
||||
final int amountMinor;
|
||||
final String currency;
|
||||
final String amount; // Deprecated:后端 formatMinor 串回退,串失败时用 amountMinor
|
||||
final DateTime? paidAt;
|
||||
final DateTime? expiresAt; // 续期后的门店授权到期时间(仅 paid 时有值)
|
||||
|
||||
const PurchaseStatusInfo({
|
||||
required this.outTradeNo,
|
||||
required this.status,
|
||||
required this.amountMinor,
|
||||
required this.currency,
|
||||
required this.amount,
|
||||
this.paidAt,
|
||||
this.expiresAt,
|
||||
@@ -105,10 +127,16 @@ class PurchaseStatusInfo {
|
||||
|
||||
bool get isPaid => status == 'paid';
|
||||
|
||||
/// 展示用金额:优先按 amount_minor 换算,退回旧 amount 串兜底。
|
||||
String get displayAmount =>
|
||||
amountMinor > 0 ? yuanFromMinor(amountMinor) : amount;
|
||||
|
||||
factory PurchaseStatusInfo.fromJson(Map<String, dynamic> json) =>
|
||||
PurchaseStatusInfo(
|
||||
outTradeNo: json['out_trade_no'] as String? ?? '',
|
||||
status: json['status'] as String? ?? 'pending',
|
||||
amountMinor: (json['amount_minor'] as num?)?.toInt() ?? 0,
|
||||
currency: json['currency'] as String? ?? 'CNY',
|
||||
amount: json['amount'] as String? ?? '',
|
||||
paidAt: json['paid_at'] != null
|
||||
? DateTime.tryParse(json['paid_at'] as String)
|
||||
@@ -118,3 +146,105 @@ class PurchaseStatusInfo {
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// GET /license/purchases 列表项。
|
||||
class PurchaseRecord {
|
||||
final String outTradeNo;
|
||||
final String productBizCode;
|
||||
final int amountMinor;
|
||||
final String currency;
|
||||
final String amount; // Deprecated:后端 formatMinor 串回退
|
||||
final String status; // pending | paid | failed
|
||||
final String payUrl;
|
||||
final String userName;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? paidAt;
|
||||
final DateTime? renewedTo;
|
||||
|
||||
const PurchaseRecord({
|
||||
required this.outTradeNo,
|
||||
required this.productBizCode,
|
||||
required this.amountMinor,
|
||||
required this.currency,
|
||||
required this.amount,
|
||||
required this.status,
|
||||
required this.payUrl,
|
||||
required this.userName,
|
||||
this.createdAt,
|
||||
this.paidAt,
|
||||
this.renewedTo,
|
||||
});
|
||||
|
||||
bool get isPaid => status == 'paid';
|
||||
|
||||
/// 展示用金额:优先按 amount_minor 换算,退回旧 amount 串兜底。
|
||||
String get displayAmount =>
|
||||
amountMinor > 0 ? yuanFromMinor(amountMinor) : amount;
|
||||
|
||||
factory PurchaseRecord.fromJson(Map<String, dynamic> json) =>
|
||||
PurchaseRecord(
|
||||
outTradeNo: json['out_trade_no'] as String? ?? '',
|
||||
productBizCode: json['product_biz_code'] as String? ?? '',
|
||||
amountMinor: (json['amount_minor'] as num?)?.toInt() ?? 0,
|
||||
currency: json['currency'] as String? ?? 'CNY',
|
||||
amount: json['amount'] as String? ?? '',
|
||||
status: json['status'] as String? ?? 'pending',
|
||||
payUrl: json['pay_url'] as String? ?? '',
|
||||
userName: json['user_name'] as String? ?? '',
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.tryParse(json['created_at'] as String)
|
||||
: null,
|
||||
paidAt: json['paid_at'] != null
|
||||
? DateTime.tryParse(json['paid_at'] as String)
|
||||
: null,
|
||||
renewedTo: json['renewed_to'] != null
|
||||
? DateTime.tryParse(json['renewed_to'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// GET /license/purchases 汇总(订单管理 tab KPI)。
|
||||
class PurchaseSummary {
|
||||
final int paidTotalMinor;
|
||||
final int paidCount;
|
||||
final int pendingCount;
|
||||
final int totalCount;
|
||||
|
||||
const PurchaseSummary({
|
||||
required this.paidTotalMinor,
|
||||
required this.paidCount,
|
||||
required this.pendingCount,
|
||||
required this.totalCount,
|
||||
});
|
||||
|
||||
factory PurchaseSummary.fromJson(Map<String, dynamic> json) =>
|
||||
PurchaseSummary(
|
||||
paidTotalMinor: (json['paid_total_minor'] as num?)?.toInt() ?? 0,
|
||||
paidCount: (json['paid_count'] as num?)?.toInt() ?? 0,
|
||||
pendingCount: (json['pending_count'] as num?)?.toInt() ?? 0,
|
||||
totalCount: (json['total_count'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// GET /license/purchases 响应整体。
|
||||
class PurchaseListResult {
|
||||
final List<PurchaseRecord> items;
|
||||
final int total;
|
||||
final PurchaseSummary summary;
|
||||
|
||||
const PurchaseListResult({
|
||||
required this.items,
|
||||
required this.total,
|
||||
required this.summary,
|
||||
});
|
||||
|
||||
factory PurchaseListResult.fromJson(Map<String, dynamic> json) =>
|
||||
PurchaseListResult(
|
||||
items: (json['items'] as List<dynamic>? ?? const [])
|
||||
.map((e) => PurchaseRecord.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
total: (json['total'] as num?)?.toInt() ?? 0,
|
||||
summary: PurchaseSummary.fromJson(
|
||||
(json['summary'] as Map<String, dynamic>?) ?? const {}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,46 @@ class LicenseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消一笔未支付订单(防 pending 单堆积)。仅管理员可用。
|
||||
/// 返回 true=已取消;订单不存在(404)或不可取消(409,如已支付/已取消)
|
||||
/// 视为业务性「取消不了」,返回 false 而非抛异常。
|
||||
Future<bool> cancelPurchase(String outTradeNo) async {
|
||||
try {
|
||||
final resp = await _client.post('/license/purchase/$outTradeNo/cancel');
|
||||
final data = resp.data['data'] as Map<String, dynamic>?;
|
||||
return data?['canceled'] as bool? ?? false;
|
||||
} on DioException catch (e) {
|
||||
final status = e.response?.statusCode;
|
||||
if (status == 404 || status == 409) return false;
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '取消订单失败,请稍后重试',
|
||||
statusCode: status,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 购买订单列表(订单管理 tab,分页 + 状态筛选)。仅管理员可用。
|
||||
Future<PurchaseListResult> listPurchases({
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
String? status,
|
||||
}) async {
|
||||
try {
|
||||
final resp = await _client.get('/license/purchases', params: {
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
if (status != null) 'status': status,
|
||||
});
|
||||
return PurchaseListResult.fromJson(
|
||||
resp.data['data'] as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '获取订单列表失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 本店首月特惠是否已享用(购买弹窗据此置灰特惠档)。
|
||||
Future<bool> promoUsed() async {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jiu_client/models/license.dart';
|
||||
|
||||
void main() {
|
||||
group('PurchaseOrder.fromJson() — pay v2 契约', () {
|
||||
test('解析 redirect 形态(render_type/payload/amount_minor)', () {
|
||||
final order = PurchaseOrder.fromJson({
|
||||
'order_no': 'pay-abc123',
|
||||
'out_trade_no': 'pay-abc123',
|
||||
'render_type': 'redirect',
|
||||
'payload': {'url': 'https://pay.example.com/checkout/abc123'},
|
||||
'amount_minor': 299900,
|
||||
'currency': 'CNY',
|
||||
'subject': '酒库管理系统 - 年度授权',
|
||||
'pay_url': 'https://pay.example.com/checkout/abc123',
|
||||
'amount': '2999.00',
|
||||
});
|
||||
|
||||
expect(order.outTradeNo, 'pay-abc123');
|
||||
expect(order.renderType, 'redirect');
|
||||
expect(order.amountMinor, 299900);
|
||||
expect(order.currency, 'CNY');
|
||||
expect(order.subject, '酒库管理系统 - 年度授权');
|
||||
expect(order.redirectUrl, 'https://pay.example.com/checkout/abc123');
|
||||
// 兼容 getter:Task 7 前 purchase_card.dart 仍读 payUrl/amount
|
||||
expect(order.payUrl, order.redirectUrl);
|
||||
expect(order.amount, '2,999.00');
|
||||
});
|
||||
|
||||
test('解析 qr 形态 payload', () {
|
||||
final order = PurchaseOrder.fromJson({
|
||||
'out_trade_no': 'pay-qr1',
|
||||
'render_type': 'qr',
|
||||
'payload': {'qr_content': 'weixin://wxpay/bizpayurl?pr=xxx'},
|
||||
'amount_minor': 100,
|
||||
'currency': 'CNY',
|
||||
'subject': '月度授权',
|
||||
});
|
||||
|
||||
expect(order.renderType, 'qr');
|
||||
expect(order.qrContent, 'weixin://wxpay/bizpayurl?pr=xxx');
|
||||
expect(order.redirectUrl, '');
|
||||
});
|
||||
});
|
||||
|
||||
group('PurchaseStatusInfo.fromJson()', () {
|
||||
test('解析 paid 状态,amount_minor 优先于 amount 串', () {
|
||||
final status = PurchaseStatusInfo.fromJson({
|
||||
'out_trade_no': 'pay-abc123',
|
||||
'status': 'paid',
|
||||
'product_biz_code': 'annual',
|
||||
'amount_minor': 299900,
|
||||
'currency': 'CNY',
|
||||
'amount': '2999.00',
|
||||
'paid_at': '2026-07-10T10:00:00Z',
|
||||
'expires_at': '2027-07-10T00:00:00Z',
|
||||
});
|
||||
|
||||
expect(status.isPaid, isTrue);
|
||||
expect(status.amountMinor, 299900);
|
||||
expect(status.currency, 'CNY');
|
||||
expect(status.displayAmount, '2,999.00');
|
||||
expect(status.paidAt, isNotNull);
|
||||
expect(status.expiresAt, isNotNull);
|
||||
});
|
||||
|
||||
test('amount_minor 缺失时回退旧 amount 串', () {
|
||||
final status = PurchaseStatusInfo.fromJson({
|
||||
'out_trade_no': 'pay-legacy',
|
||||
'status': 'pending',
|
||||
'amount': '99.00',
|
||||
});
|
||||
|
||||
expect(status.amountMinor, 0);
|
||||
expect(status.displayAmount, '99.00');
|
||||
});
|
||||
});
|
||||
|
||||
group('PurchaseRecord.fromJson()(订单列表项)', () {
|
||||
test('解析完整订单列表项', () {
|
||||
final record = PurchaseRecord.fromJson({
|
||||
'out_trade_no': 'pay-abc123',
|
||||
'product_biz_code': 'annual',
|
||||
'amount_minor': 299900,
|
||||
'currency': 'CNY',
|
||||
'amount': '2999.00',
|
||||
'status': 'paid',
|
||||
'pay_url': 'https://pay.example.com/checkout/abc123',
|
||||
'user_name': '张三',
|
||||
'created_at': '2026-07-01T10:00:00Z',
|
||||
'paid_at': '2026-07-01T10:05:00Z',
|
||||
'renewed_to': '2027-07-01T00:00:00Z',
|
||||
});
|
||||
|
||||
expect(record.outTradeNo, 'pay-abc123');
|
||||
expect(record.productBizCode, 'annual');
|
||||
expect(record.amountMinor, 299900);
|
||||
expect(record.displayAmount, '2,999.00');
|
||||
expect(record.isPaid, isTrue);
|
||||
expect(record.userName, '张三');
|
||||
expect(record.createdAt, isNotNull);
|
||||
expect(record.paidAt, isNotNull);
|
||||
expect(record.renewedTo, isNotNull);
|
||||
});
|
||||
|
||||
test('缺省可选字段不报错', () {
|
||||
final record = PurchaseRecord.fromJson({
|
||||
'out_trade_no': 'pay-pending1',
|
||||
'product_biz_code': 'monthly',
|
||||
'amount_minor': 9900,
|
||||
'currency': 'CNY',
|
||||
'amount': '99.00',
|
||||
'status': 'pending',
|
||||
'pay_url': '',
|
||||
'user_name': '',
|
||||
});
|
||||
|
||||
expect(record.isPaid, isFalse);
|
||||
expect(record.paidAt, isNull);
|
||||
expect(record.renewedTo, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('PurchaseListResult.fromJson()', () {
|
||||
test('解析 items + total + summary', () {
|
||||
final result = PurchaseListResult.fromJson({
|
||||
'items': [
|
||||
{
|
||||
'out_trade_no': 'pay-1',
|
||||
'product_biz_code': 'annual',
|
||||
'amount_minor': 299900,
|
||||
'currency': 'CNY',
|
||||
'amount': '2999.00',
|
||||
'status': 'paid',
|
||||
'pay_url': '',
|
||||
'user_name': '张三',
|
||||
},
|
||||
],
|
||||
'total': 1,
|
||||
'summary': {
|
||||
'paid_total_minor': 299900,
|
||||
'paid_count': 1,
|
||||
'pending_count': 0,
|
||||
'total_count': 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.items.length, 1);
|
||||
expect(result.total, 1);
|
||||
expect(result.summary.paidTotalMinor, 299900);
|
||||
expect(result.summary.paidCount, 1);
|
||||
expect(result.summary.pendingCount, 0);
|
||||
expect(result.summary.totalCount, 1);
|
||||
});
|
||||
|
||||
test('空列表不报错', () {
|
||||
final result = PurchaseListResult.fromJson({'items': [], 'total': 0});
|
||||
expect(result.items, isEmpty);
|
||||
expect(result.summary.totalCount, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
||||
import 'package:jiu_client/core/api/api_client.dart';
|
||||
import 'package:jiu_client/core/exceptions.dart';
|
||||
import 'package:jiu_client/repositories/license_repository.dart';
|
||||
|
||||
const _baseUrl = 'http://localhost:8080/api/v1';
|
||||
|
||||
class _TestApiClient extends ApiClient {
|
||||
final Dio _testDio;
|
||||
_TestApiClient(this._testDio) : super(token: 'test-token');
|
||||
|
||||
@override
|
||||
Future<Response> get(String path, {Map<String, dynamic>? params}) =>
|
||||
_testDio.get(path, queryParameters: params);
|
||||
|
||||
@override
|
||||
Future<Response> post(String path, {dynamic data}) =>
|
||||
_testDio.post(path, data: data);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late Dio dio;
|
||||
late DioAdapter adapter;
|
||||
late LicenseRepository repo;
|
||||
|
||||
setUp(() {
|
||||
dio = Dio(BaseOptions(baseUrl: _baseUrl));
|
||||
adapter = DioAdapter(dio: dio, matcher: const FullHttpRequestMatcher());
|
||||
repo = LicenseRepository(_TestApiClient(dio));
|
||||
});
|
||||
|
||||
group('LicenseRepository.cancelPurchase()', () {
|
||||
test('200 canceled=true → 返回 true', () async {
|
||||
adapter.onPost(
|
||||
'/license/purchase/pay-abc123/cancel',
|
||||
(server) => server.reply(200, {
|
||||
'data': {'canceled': true}
|
||||
}),
|
||||
);
|
||||
|
||||
final ok = await repo.cancelPurchase('pay-abc123');
|
||||
expect(ok, isTrue);
|
||||
});
|
||||
|
||||
test('404 订单不存在 → 返回 false(不抛异常)', () async {
|
||||
adapter.onPost(
|
||||
'/license/purchase/pay-missing/cancel',
|
||||
(server) => server.reply(404, {'error': '订单不存在'}),
|
||||
);
|
||||
|
||||
final ok = await repo.cancelPurchase('pay-missing');
|
||||
expect(ok, isFalse);
|
||||
});
|
||||
|
||||
test('409 已支付不可取消 → 返回 false(不抛异常)', () async {
|
||||
adapter.onPost(
|
||||
'/license/purchase/pay-paid1/cancel',
|
||||
(server) => server.reply(409, {'error': '订单已支付,无法取消'}),
|
||||
);
|
||||
|
||||
final ok = await repo.cancelPurchase('pay-paid1');
|
||||
expect(ok, isFalse);
|
||||
});
|
||||
|
||||
test('其它错误(如 5xx)→ 抛 AppException', () async {
|
||||
adapter.onPost(
|
||||
'/license/purchase/pay-err/cancel',
|
||||
(server) => server.reply(502, {'error': '取消失败,请稍后重试'}),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
repo.cancelPurchase('pay-err'),
|
||||
throwsA(isA<AppException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('LicenseRepository.listPurchases()', () {
|
||||
test('返回订单列表 + 汇总', () async {
|
||||
adapter.onGet(
|
||||
'/license/purchases',
|
||||
(server) => server.reply(200, {
|
||||
'data': {
|
||||
'items': [
|
||||
{
|
||||
'out_trade_no': 'pay-1',
|
||||
'product_biz_code': 'annual',
|
||||
'amount_minor': 299900,
|
||||
'currency': 'CNY',
|
||||
'amount': '2999.00',
|
||||
'status': 'paid',
|
||||
'pay_url': '',
|
||||
'user_name': '张三',
|
||||
'created_at': '2026-07-01T10:00:00Z',
|
||||
'paid_at': '2026-07-01T10:05:00Z',
|
||||
},
|
||||
],
|
||||
'total': 1,
|
||||
'summary': {
|
||||
'paid_total_minor': 299900,
|
||||
'paid_count': 1,
|
||||
'pending_count': 0,
|
||||
'total_count': 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
queryParameters: {'page': 1, 'page_size': 20},
|
||||
);
|
||||
|
||||
final result = await repo.listPurchases();
|
||||
expect(result.total, 1);
|
||||
expect(result.items.single.outTradeNo, 'pay-1');
|
||||
expect(result.summary.paidCount, 1);
|
||||
});
|
||||
|
||||
test('status 筛选透传查询参数', () async {
|
||||
adapter.onGet(
|
||||
'/license/purchases',
|
||||
(server) => server.reply(200, {
|
||||
'data': {'items': [], 'total': 0},
|
||||
}),
|
||||
queryParameters: {'page': 1, 'page_size': 20, 'status': 'pending'},
|
||||
);
|
||||
|
||||
final result = await repo.listPurchases(status: 'pending');
|
||||
expect(result.items, isEmpty);
|
||||
});
|
||||
|
||||
test('失败 → 抛 AppException', () async {
|
||||
adapter.onGet(
|
||||
'/license/purchases',
|
||||
(server) => server.reply(500, {'error': '获取订单列表失败'}),
|
||||
queryParameters: {'page': 1, 'page_size': 20},
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
repo.listPurchases(),
|
||||
throwsA(isA<AppException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jiu_client/core/utils/money.dart';
|
||||
|
||||
void main() {
|
||||
group('yuanFromMinor()', () {
|
||||
test('299900 分 → "2,999.00"(千分位 + 两位小数)', () {
|
||||
expect(yuanFromMinor(299900), '2,999.00');
|
||||
});
|
||||
|
||||
test('100 分 → "1.00"', () {
|
||||
expect(yuanFromMinor(100), '1.00');
|
||||
});
|
||||
|
||||
test('0 分 → "0.00"', () {
|
||||
expect(yuanFromMinor(0), '0.00');
|
||||
});
|
||||
|
||||
test('个位/十位/百位不加逗号', () {
|
||||
expect(yuanFromMinor(5), '0.05');
|
||||
expect(yuanFromMinor(99), '0.99');
|
||||
expect(yuanFromMinor(12300), '123.00');
|
||||
});
|
||||
|
||||
test('7 位数金额分组正确', () {
|
||||
expect(yuanFromMinor(123456789), '1,234,567.89');
|
||||
});
|
||||
|
||||
test('负数保留符号', () {
|
||||
expect(yuanFromMinor(-299900), '-2,999.00');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user