Files
pj_supermarket_cloud/test/discounts_tool_test.dart
T

317 lines
11 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart' as dio;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
import 'package:sqflite/sqflite.dart' as sqlite;
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
class _DiscountTestApp extends AppControl {
@override
void onClose() {}
}
/// 所有请求在这里结束,不访问真实优惠、钱包或支付接口。
class _DiscountAdapter implements dio.HttpClientAdapter {
_DiscountAdapter(this.respond);
final Future<dio.ResponseBody> Function(dio.RequestOptions) respond;
final requests = <dio.RequestOptions>[];
@override
Future<dio.ResponseBody> fetch(dio.RequestOptions options,
Stream<Uint8List>? requestStream, Future<void>? cancelFuture) {
requests.add(options);
return respond(options);
}
@override
void close({bool force = false}) {}
}
dio.ResponseBody _json(Object? data, {int code = 0}) =>
dio.ResponseBody.fromString(
jsonEncode({'code': code, 'msg': 'test response', 'data': data}),
200,
headers: {
'content-type': ['application/json']
},
);
DiscountsItem _rule({
bool allProducts = true,
int base = 0,
double rate = 1.5,
ProductHourType hour = ProductHourType.MORNING,
}) =>
DiscountsItem(
name: 'test rule',
hourType: hour,
type: null,
basePrice: base,
specialDishEnable: allProducts,
extraPer: rate,
);
ShopCardEntity _cart(int price, {bool increased = true}) => ShopCardEntity(
orderId: -1,
productServiceUniqueCode: '$price',
productName: 'test',
productPic: '',
productPrice: price,
nature: ProductNature.DEFAULT,
productNumber: 1,
productWidget: 0,
totalPrice: price,
isPriceIncreased: increased,
);
void main() {
final binding = TestWidgetsFlutterBinding.ensureInitialized();
late Directory directory;
late AppDatabase database;
late _DiscountTestApp app;
late dio.HttpClientAdapter originalAdapter;
late String originalBaseUrl;
final muteToasts = dio.InterceptorsWrapper(onRequest: (options, handler) {
// 保持测试无界面;另行断言生产 API 自己设置了 rejectBusinessError 标记。
options.extra[HttpManage.silentRequestKey] = true;
handler.next(options);
});
setUpAll(() async {
directory = await Directory.systemTemp.createTemp('pj-discounts-test-');
for (final channel in [
'plugins.flutter.io/path_provider',
'external_path'
]) {
binding.defaultBinaryMessenger.setMockMethodCallHandler(
MethodChannel(channel),
(_) async => directory.path,
);
}
sqfliteFfiInit();
sqlite.databaseFactory = databaseFactoryFfiNoIsolate;
database = await $FloorAppDatabase.inMemoryDatabaseBuilder().build();
app = _DiscountTestApp()..dataBase = database;
Get.put<AppControl>(app);
await database.getSettingDao.save(SettingEntity(
sysJson: jsonEncode(
SysSetting(sysType: SysType.CANTEEN, serviceId: '10').toJson()),
userJson: jsonEncode(
(UserSetting(username: '', password: '')..tenantId = '1').toJson()),
));
await logger.init;
final client = HttpManage().dio;
originalAdapter = client.httpClientAdapter;
originalBaseUrl = client.options.baseUrl;
client.options.baseUrl = 'https://discount-test.invalid/api';
client.interceptors.add(muteToasts);
});
setUp(() async {
app.isNetwork.value = true;
await database.getUserDao.delAll();
await database.getUserDao.save(UserEntity(
userId: '100',
nickname: 'test',
walletBalance: 1000,
discountsList: [_rule()],
discountsUpdateTime: DateTime(2026, 1, 1),
));
});
tearDownAll(() async {
final client = HttpManage().dio;
client.interceptors.remove(muteToasts);
client.httpClientAdapter = originalAdapter;
client.options.baseUrl = originalBaseUrl;
await logger.close();
await AppSettingTool.removeSettingCache();
await database.close();
Get.delete<AppControl>(force: true);
await directory.delete(recursive: true);
});
test('空优惠成功后清除离线旧规则,后续同结算不再查网络', () async {
final adapter = _DiscountAdapter((_) async => _json([]));
HttpManage().dio.httpClientAdapter = adapter;
final session = DiscountsTool.createSession();
expect(
await DiscountsTool.updateUserDiscountsList(
userId: '100', session: session),
isEmpty);
expect(
await DiscountsTool.updateUserDiscountsList(
userId: '100', session: session),
isEmpty);
expect(adapter.requests, hasLength(1));
final stored = await database.getUserDao.findByUserId('100');
expect(stored!.discountsList, isEmpty);
expect(stored.discountsUpdateTime, isNot(DateTime(2026, 1, 1)));
app.isNetwork.value = false;
expect(await DiscountsTool.updateUserDiscountsList(userId: '100'), isEmpty);
expect(adapter.requests, hasLength(1));
});
test('在线业务拒绝严格抛错、不按零优惠、不回退旧缓存,重试重新请求', () async {
final adapter = _DiscountAdapter((_) async => _json(null, code: 912));
HttpManage().dio.httpClientAdapter = adapter;
final session = DiscountsTool.createSession();
for (var i = 0; i < 2; i++) {
await expectLater(
DiscountsTool.computedExtraPrice(
userId: '100',
shopCardList: [_cart(1000)],
hourType: ProductHourType.MORNING.code,
session: session),
throwsA(isA<dio.DioException>()),
);
}
expect(adapter.requests, hasLength(2));
expect(
adapter.requests.every((request) =>
request.extra[HttpManage.rejectBusinessErrorKey] == true),
isTrue);
expect((await database.getUserDao.findByUserId('100'))!.discountsList,
hasLength(1));
});
test('异常空返回不能当作成功的空优惠', () async {
final adapter = _DiscountAdapter((_) async => _json(null));
HttpManage().dio.httpClientAdapter = adapter;
await expectLater(DiscountsTool.updateUserDiscountsList(userId: '100'),
throwsFormatException);
expect((await database.getUserDao.findByUserId('100'))!.discountsList,
hasLength(1));
});
test('等待规则时冻结商品金额,同结算改变数量仅重算不再次查询', () async {
final source = Completer<dio.ResponseBody>();
final adapter = _DiscountAdapter((_) => source.future);
HttpManage().dio.httpClientAdapter = adapter;
final session = DiscountsTool.createSession();
final cart = [_cart(1000)];
final first = DiscountsTool.computedExtraPrice(
userId: '100',
shopCardList: cart,
hourType: ProductHourType.MORNING.code,
session: session);
cart.single.totalPrice = 2000;
source.complete(_json([_rule().toJson()]));
expect(await first, 500);
expect(
await DiscountsTool.computedExtraPrice(
userId: '100',
shopCardList: cart,
hourType: ProductHourType.MORNING.code,
session: session),
1000);
expect(adapter.requests, hasLength(1));
});
test('明确离线继续使用原本缓存而不访问网络', () async {
final adapter =
_DiscountAdapter((_) async => throw StateError('must not request'));
HttpManage().dio.httpClientAdapter = adapter;
app.isNetwork.value = false;
expect(
await DiscountsTool.computedExtraPrice(
userId: '100',
shopCardList: [_cart(1000)],
hourType: ProductHourType.MORNING.code),
500);
expect(adapter.requests, isEmpty);
});
test('刷新人员余额时保留已有优惠规则和更新时间', () async {
await SyncUserTool.storageUser(
user: ServiceUser(userId: '100', username: 'test', walletBalance: 900));
final stored = await database.getUserDao.findByUserId('100');
expect(stored!.walletBalance, 900);
expect(stored.discountsList, hasLength(1));
expect(stored.discountsUpdateTime, DateTime(2026, 1, 1));
});
test('旧结算网络结果迟到时不能把新结算的空优惠覆盖回旧规则', () async {
final started = Completer<void>();
final oldResponse = Completer<dio.ResponseBody>();
var calls = 0;
final adapter = _DiscountAdapter((_) {
if (++calls == 1) {
started.complete();
return oldResponse.future;
}
return Future.value(_json([]));
});
HttpManage().dio.httpClientAdapter = adapter;
final session = DiscountsTool.createSession();
final old =
DiscountsTool.updateUserDiscountsList(userId: '100', session: session);
await started.future;
session.invalidate();
expect(
await DiscountsTool.updateUserDiscountsList(
userId: '100', session: session),
isEmpty);
oldResponse.complete(_json([_rule(rate: 2).toJson()]));
expect(await old, hasLength(1));
expect((await database.getUserDao.findByUserId('100'))!.discountsList,
isEmpty);
});
test('人员读取旧快照期间更新优惠,余额与新规则不会互相覆盖', () async {
final updatedAt = DateTime(2026, 10, 23, 16, 0);
// 人员刷新先发起读取;随后的规则 UPDATE 可以排在其读取和写入之间。
final profile = SyncUserTool.storageUser(
user: ServiceUser(
userId: '100',
username: 'test',
walletBalance: 700,
));
final discounts = database.getUserDao.updateDiscounts(
'100',
jsonEncode([_rule(rate: 2).toJson()]),
updatedAt,
);
await Future.wait([profile, discounts]);
final stored = (await database.getUserDao.findByUserId('100'))!;
expect(stored.walletBalance, 700);
expect(stored.discountsList!.single.extraPer, 2);
expect(stored.discountsUpdateTime, updatedAt);
// 反向更新规则同样不能把更新后的余额改回请求前的值。
await database.getUserDao.updateDiscounts('100', '[]', updatedAt);
final cleared = (await database.getUserDao.findByUserId('100'))!;
expect(cleared.walletBalance, 700);
expect(cleared.discountsList, isEmpty);
});
test('原收费算法保留餐段、特色菜范围、基数边界和逐规则四舍五入', () {
final rules = [
_rule(base: 1000, rate: 1.5),
_rule(allProducts: false, base: 0, rate: 1.1),
_rule(rate: 0.8),
_rule(hour: ProductHourType.NOON),
];
expect(
DiscountsTool.computeExtraPriceFromRules(
discountsItems: rules,
hourType: ProductHourType.MORNING.code,
totalPrice: 1000,
increasedPrice: 555),
56);
expect(
DiscountsTool.computeExtraPriceFromRules(
discountsItems: rules,
hourType: ProductHourType.MORNING.code,
totalPrice: 1001,
increasedPrice: 555),
557);
});
}