已完成用户APP首期功能开发

交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
This commit is contained in:
czl231
2026-09-13 00:57:32 +08:00
parent a0a4bb1218
commit 3ef33b531d
793 changed files with 40959 additions and 1204 deletions

View File

@@ -0,0 +1,126 @@
// 功能描述:验证地址新增、失败重试、删除确认和默认地址刷新。
// 版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/data/services/api_client.dart';
import 'package:user_app/domain/models/shipping_address.dart';
import 'package:user_app/ui/core/app_theme.dart';
import 'package:user_app/ui/features/address/addresses_page.dart';
import '../support/a1_fixture.dart';
class AddressFixture extends A1FixtureRepository {
List<ShippingAddress> values = [];
final requests = <String>[];
int deletes = 0;
@override
Future<List<ShippingAddress>> shippingAddresses() async => values;
@override
Future<void> saveShippingAddress(ShippingAddress address, {required String requestNo}) async {
requests.add(requestNo);
if (this.fail) throw const ApiException(500, '保存失败,请重试');
values = [
ShippingAddress(
identity: 'saved',
address: address.address,
contactName: address.contactName,
contactPhone: address.contactPhone,
isDefault: address.isDefault,
),
];
}
@override
Future<void> setDefaultAddress(String identity) async {
if (this.fail) throw const ApiException(500, '设置失败');
final a = values.single;
values = [
ShippingAddress(
identity: a.identity,
address: a.address,
contactName: a.contactName,
contactPhone: a.contactPhone,
isDefault: true,
),
];
}
@override
Future<void> deleteAddress(String identity) async {
deletes++;
values = [];
}
}
void main() {
testWidgets('新增校验,失败保留输入与请求号,成功刷新列表', (tester) async {
final repo = AddressFixture()..fail = true;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: AddressesPage(repository: repo),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('新增收货地址'));
await tester.pumpAndSettle();
await tester.tap(find.text('保存地址'));
await tester.pumpAndSettle();
expect(repo.requests, isEmpty);
final fields = find.byType(TextFormField);
await tester.enterText(fields.at(0), '收货人');
await tester.enterText(fields.at(1), '13800000001');
await tester.enterText(fields.at(2), '广东省广州市测试路 1 号');
await tester.tap(find.text('保存地址'));
await tester.pumpAndSettle();
expect(find.text('保存失败,请重试'), findsOneWidget);
repo.fail = false;
await tester.tap(find.text('保存地址'));
await tester.pumpAndSettle();
expect(repo.requests.length, 2);
expect(repo.requests.first, repo.requests.last);
expect(find.text('收货人'), findsOneWidget);
expect(find.text('138****0001'), findsOneWidget);
expect(find.text('地址管理'), findsOneWidget);
});
testWidgets('默认切换失败不假更新,成功刷新,删除需要确认', (tester) async {
final repo = AddressFixture()
..values = [
const ShippingAddress(
identity: 'owned',
address: '测试地址',
contactName: '收货人',
contactPhone: '13800000001',
),
];
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: AddressesPage(repository: repo),
),
);
await tester.pumpAndSettle();
repo.fail = true;
await tester.tap(find.text('设为默认'));
await tester.pumpAndSettle();
expect(repo.values.single.isDefault, false);
repo.fail = false;
await tester.tap(find.text('设为默认'));
await tester.pumpAndSettle();
expect(repo.values.single.isDefault, true);
await tester.tap(find.text('编辑'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('删除地址'));
await tester.tap(find.text('删除地址'));
await tester.pumpAndSettle();
await tester.tap(find.text('取消'));
await tester.pumpAndSettle();
expect(repo.deletes, 0);
await tester.tap(find.text('删除地址'));
await tester.pumpAndSettle();
await tester.tap(find.text('删除'));
await tester.pumpAndSettle();
expect(repo.deletes, 1);
expect(find.text('暂无收货地址'), findsOneWidget);
});
}

View File

@@ -0,0 +1,188 @@
// 功能描述:购物车服务端确认、失效处理、管理删除与多商品重试回归;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:user_app/domain/models/cart_item.dart';
import 'package:user_app/domain/models/primary_models.dart';
import 'package:user_app/domain/models/shipping_address.dart';
import 'package:user_app/data/services/api_client.dart';
import 'package:user_app/ui/features/shop/cart_page.dart';
import 'package:user_app/ui/core/app_theme.dart';
import 'package:user_app/ui/features/shop/checkout_page.dart';
import 'package:user_app/ui/features/shop/product_detail_page.dart';
import '../support/a1_fixture.dart';
class _Repo extends A1FixtureRepository {
List<CartItem> items = [
const CartItem(
product: ProductSummary(identity: 'p1', name: '测试商品一', price: 1999, stock: 4),
quantity: 1,
selected: true,
available: true,
revision: 'one',
),
const CartItem(
product: ProductSummary(identity: 'p2', name: '测试商品二', price: 3000, stock: 5),
quantity: 2,
selected: true,
available: true,
revision: 'two',
),
const CartItem(
product: ProductSummary(identity: 'p3', name: '失效商品', price: 1000, stock: 0),
quantity: 1,
selected: true,
available: false,
revision: 'three',
),
];
final submitted = <({String request, int amount, List<CartItem> items})>[];
int writes = 0;
bool failWrite = false;
final targets = <int>[];
@override
Future<List<CartItem>> cart() async => List.of(items);
@override
Future<CartItem> cartItem(String identity) async =>
items.firstWhere((i) => i.product.identity == identity);
@override
Future<CartItem> setCartItem(
CartItem item, {
required int quantity,
required bool selected,
}) async {
writes++;
targets.add(quantity);
final updated = CartItem(
product: item.product,
quantity: quantity,
selected: selected,
available: item.available,
revision: 'v$writes',
);
items = [
for (final old in items)
if (old.product.identity != item.product.identity) old else if (quantity > 0) updated,
];
if (failWrite) {
failWrite = false;
throw const ApiException(500, '结果待确认');
}
return updated;
}
@override
Future<Map<String, Object?>> submitCartOrder({
required String requestNo,
required List<CartItem> items,
required ShippingAddress address,
required int expectedAmount,
String remark = '',
}) async {
submitted.add((request: requestNo, amount: expectedAmount, items: List.of(items)));
if (submitted.length == 1) throw const ApiException(500, '提交结果未知');
return {'identity': 'order'};
}
}
Future<GoRouter> _show(WidgetTester tester, _Repo repo, {String path = '/cart'}) async {
final router = GoRouter(
initialLocation: path,
routes: [
GoRoute(
path: '/cart',
builder: (_, s) => CartPage(repository: repo),
),
GoRoute(
path: '/cart/checkout',
builder: (_, s) => CheckoutPage(repository: repo, productIdentity: '', fromCart: true),
),
GoRoute(
path: '/products/p1',
builder: (_, s) => ProductDetailPage(repository: repo, identity: 'p1'),
),
GoRoute(
path: '/orders',
builder: (_, s) => const Scaffold(body: Text('订单列表')),
),
GoRoute(
path: '/payment/shop/:identity',
builder: (_, s) => Scaffold(body: Text('支付订单 ${s.pathParameters['identity']}')),
),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(MaterialApp.router(routerConfig: router, theme: AppTheme.light()));
await tester.pumpAndSettle();
return router;
}
void main() {
testWidgets('服务端数量与失败重读,全选、失效取消、管理删除', (tester) async {
final repo = _Repo();
await _show(tester, repo);
expect(find.text('合计:¥79.99'), findsOneWidget);
expect(find.text('商品已失效'), findsOneWidget);
repo.failWrite = true;
await tester.tap(find.byTooltip('增加 测试商品一'));
await tester.pumpAndSettle();
expect(repo.items.first.quantity, 2);
expect(find.text('合计:¥99.98'), findsOneWidget);
expect(find.text('结果待确认'), findsOneWidget);
await tester.tap(find.text('重新加载'));
await tester.pumpAndSettle();
final invalid = find.descendant(
of: find.bySemanticsLabel('选择 失效商品'),
matching: find.byType(Checkbox),
);
await tester.scrollUntilVisible(invalid, 180);
await tester.drag(find.byType(Scrollable).first, const Offset(0, -120));
await tester.pumpAndSettle();
await tester.tap(invalid);
await tester.pumpAndSettle();
expect(repo.items.last.selected, isFalse);
await tester.tap(find.text('管理'));
await tester.pumpAndSettle();
final all = find.descendant(of: find.bySemanticsLabel('全选商品'), matching: find.byType(Checkbox));
await tester.tap(all);
await tester.pumpAndSettle();
await tester.tap(find.text('删除(3)'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, '删除'));
await tester.pumpAndSettle();
expect(repo.items, isEmpty);
expect(find.text('购物车还是空的'), findsOneWidget);
});
testWidgets('多商品确认与未知结果重试保留同一请求和整数金额', (tester) async {
final repo = _Repo()..items.removeLast();
await _show(tester, repo);
await tester.tap(find.text('去结算(2)'));
await tester.pumpAndSettle();
expect(find.text('测试商品一'), findsOneWidget);
expect(find.text('测试商品二'), findsOneWidget);
expect(repo.submitted, isEmpty);
await tester.tap(find.widgetWithText(FilledButton, '提交订单'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, '重试提交'));
await tester.pumpAndSettle();
expect(repo.submitted.length, 2);
expect(repo.submitted[0].request, repo.submitted[1].request);
expect(repo.submitted[0].amount, 7999);
expect(repo.submitted[1].items.last.quantity, 2);
expect(find.text('支付订单 order'), findsOneWidget);
});
testWidgets('加入购物车未知结果重试不在新数量上重复累加', (tester) async {
final repo = _Repo()..failWrite = true;
await _show(tester, repo, path: '/products/p1');
await tester.ensureVisible(find.text('加入购物车'));
await tester.tap(find.text('加入购物车'));
await tester.pumpAndSettle();
expect(repo.targets, [2]);
await tester.tap(find.text('重试加入'));
await tester.pumpAndSettle();
expect(repo.targets, [2, 2]);
expect(repo.items.first.quantity, 2);
});
}

View File

@@ -0,0 +1,77 @@
// 功能描述:合同申请未知失败重试保持请求号,取消必须确认并刷新;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/domain/models/gas_order_detail.dart';
import 'package:user_app/ui/features/orders/contract_change_requests.dart';
import '../support/a1_fixture.dart';
class _Repo extends A1FixtureRepository {
final ids = <String>[];
bool created = false, cancelled = false;
@override
Future<bool> requestContractChange(String identity, String description, String requestNo) async {
expect(identity, 'c1');
expect(description, '申请变更配送约定');
ids.add(requestNo);
if (ids.length == 1) throw Exception('网络异常');
created = true;
return false;
}
@override
Future<List<ContractChangeRequest>> contractChangeRequests(String identity) async => !created
? []
: [
ContractChangeRequest.fromJson({
'identity': 'request',
'ticket_no': 'TK1',
'status_name': cancelled ? '已取消' : '待受理',
'description': '申请变更配送约定',
'result': '',
'allowed_actions': cancelled ? [] : ['cancel'],
}),
];
@override
Future<void> cancelTicket(String identity) async {
expect(identity, 'request');
cancelled = true;
}
}
void main() {
testWidgets('申请失败重试和取消确认', (tester) async {
final repo = _Repo();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ContractChangeRequests(repository: repo, identity: 'c1'),
),
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '申请变更配送约定');
await tester.tap(find.text('提交申请'));
await tester.pumpAndSettle();
await tester.tap(find.text('提交申请'));
await tester.pumpAndSettle();
expect(repo.ids.length, 2);
expect(repo.ids.first, repo.ids.last);
await tester.scrollUntilVisible(
find.text('取消申请'),
200,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
await tester.tap(find.text('取消申请'));
await tester.pumpAndSettle();
await tester.tap(find.text('返回'));
await tester.pumpAndSettle();
expect(repo.cancelled, isFalse);
await tester.tap(find.text('取消申请'));
await tester.pumpAndSettle();
await tester.tap(find.text('确认'));
await tester.pumpAndSettle();
expect(repo.cancelled, isTrue);
expect(find.textContaining('已取消'), findsOneWidget);
});
}

View File

@@ -0,0 +1,42 @@
// 功能描述:PDF保存失败重试、取消及防重复提交;版本:1.0.0。
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/ui/features/orders/contract_download_button.dart';
import '../support/a1_fixture.dart';
class _Repo extends A1FixtureRepository {
@override
Future<Uint8List> gasContractPdf(String identity) async =>
Uint8List.fromList('%PDF-1.7'.codeUnits);
}
void main() {
testWidgets('失败可重试,系统取消不报告成功', (tester) async {
var calls = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ContractDownloadButton(
repository: _Repo(),
identity: 'c1',
number: '../HT1',
saver: (name, bytes) async {
expect(name, '___HT1_合同');
expect(String.fromCharCodes(bytes), '%PDF-1.7');
if (++calls == 1) throw Exception('保存失败');
return false;
},
),
),
),
);
await tester.tap(find.text('下载PDF'));
await tester.pumpAndSettle();
expect(find.text('保存失败,请重试'), findsOneWidget);
await tester.tap(find.text('下载PDF'));
await tester.pumpAndSettle();
expect(calls, 2);
expect(find.text('合同已保存'), findsNothing);
});
}

View File

@@ -0,0 +1,29 @@
// 功能描述:验证配送详情使用本人接口事实并明确展示首期缺项;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/ui/features/orders/delivery_detail_page.dart';
import '../support/a1_fixture.dart';
void main() {
testWidgets('配送详情展示真实分组并提示受控联系暂未开放', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: DeliveryDetailPage(repository: A1FixtureRepository(), identity: 'gas-1'),
),
);
await tester.pumpAndSettle();
expect(find.text('配送中'), findsOneWidget);
expect(find.text('张师傅'), findsOneWidget);
expect(find.text('危险品从业资质 · 已核验'), findsOneWidget);
expect(find.textContaining('178509050920001'), findsOneWidget);
expect(find.text('电话与订单消息暂未开放'), findsOneWidget);
await tester.ensureVisible(find.text('电话联系'));
await tester.tap(find.text('电话联系'));
await tester.pumpAndSettle();
expect(find.text('电话联系暂未开放'), findsOneWidget);
expect(find.textContaining('受控呼叫服务'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}

View File

@@ -0,0 +1,28 @@
// 功能描述:验证配送轨迹使用隐私化接口事实并保留首期未开放提示;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/ui/features/orders/delivery_track_page.dart';
import '../support/a1_fixture.dart';
void main() {
testWidgets('配送轨迹展示路线、进度、配送人员并刷新', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: DeliveryTrackPage(repository: A1FixtureRepository(), identity: 'gas-1'),
),
);
await tester.pumpAndSettle();
expect(find.text('配送中'), findsOneWidget);
expect(find.text('薛海气站'), findsOneWidget);
expect(find.text('气站已接单'), findsOneWidget);
expect(find.text('商品装车完成'), findsOneWidget);
expect(find.text('正在前往'), findsOneWidget);
expect(find.text('张师傅'), findsOneWidget);
await tester.tap(find.byTooltip('刷新轨迹'));
await tester.pumpAndSettle();
expect(find.text('实时更新 09:56'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}

View File

@@ -0,0 +1,28 @@
// 功能描述:押金汇总、筛选、规则与退瓶状态入口回归;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/ui/core/app_theme.dart';
import 'package:user_app/ui/features/wallet/deposit_page.dart';
import '../support/a1_fixture.dart';
void main() {
testWidgets('展示服务端押金并按状态筛选,退瓶入口不伪造提交', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: DepositPage(repository: A1FixtureRepository()),
),
);
await tester.pumpAndSettle();
expect(find.text('¥200.00'), findsOneWidget);
expect(find.text('GZ-15-0286'), findsOneWidget);
await tester.tap(find.text('退款中').first);
await tester.pumpAndSettle();
expect(find.text('GZ-15-0193'), findsOneWidget);
expect(find.text('GZ-15-0286'), findsNothing);
await tester.tap(find.text('使用中').first);
await tester.pumpAndSettle();
expect(find.text('申请退瓶'), findsOneWidget);
});
}

View File

@@ -0,0 +1,43 @@
// 功能描述:退瓶退押金页状态确认与最终确认回归;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/ui/core/app_theme.dart';
import 'package:user_app/ui/features/wallet/deposit_return_page.dart';
import '../support/a1_fixture.dart';
void main() {
testWidgets('完整展示退瓶要素且最终确认前不提交', (tester) async {
final repository = A1FixtureRepository();
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: DepositReturnPage(repository: repository, depositIdentity: 'd1'),
),
);
await tester.pumpAndSettle();
expect(find.text('退瓶退押金'), findsOneWidget);
expect(find.text('15kg 液化气钢瓶'), findsOneWidget);
expect(find.text('上门回收'), findsOneWidget);
await tester.scrollUntilVisible(find.text('预计退款'), 220);
expect(find.text('¥100.00'), findsOneWidget);
for (final label in [
'瓶体完整,无变形、锈蚀',
'阀门无缺失,无泄漏',
'已停止使用并关闭阀门',
'我已阅读《退瓶规则》',
]) {
await tester.scrollUntilVisible(find.text(label), 160);
await tester.tap(find.text(label));
await tester.pump();
}
await tester.ensureVisible(find.text('提交退瓶申请'));
await tester.tap(find.text('提交退瓶申请'));
await tester.pumpAndSettle();
expect(find.text('确认提交退瓶申请'), findsOneWidget);
expect(repository.depositReturnCalls, 0);
await tester.tap(find.text('再检查一下'));
await tester.pumpAndSettle();
expect(repository.depositReturnCalls, 0);
});
}

View File

@@ -0,0 +1,95 @@
// 功能:设备分组首期资料管理与设备归组交互回归;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/domain/models/client_models.dart';
import 'package:user_app/domain/models/device_group.dart';
import 'package:user_app/ui/features/profile/device_groups_page.dart';
import '../support/a1_fixture.dart';
class _GroupFixture extends A1FixtureRepository {
final groups = <DeviceGroup>[
const DeviceGroup(identity: 'kitchen', name: '厨房', sortNo: 0, deviceIdentities: {'valve'}),
];
String assignedGroup = 'kitchen';
@override
Future<List<ClientRecord>> devices() async => const [
ClientRecord(identity: 'valve', title: '厨房角阀', subtitle: 'V001', raw: {'kind': 'valve'}),
];
@override
Future<List<DeviceGroup>> deviceGroups() async => List.of(groups);
@override
Future<void> saveDeviceGroup({
String? identity,
required String name,
required String requestNo,
}) async {
if (identity == null) {
groups.add(
DeviceGroup(
identity: 'new-group',
name: name,
sortNo: groups.length,
deviceIdentities: const {},
),
);
return;
}
final index = groups.indexWhere((group) => group.identity == identity);
final old = groups[index];
groups[index] = DeviceGroup(
identity: old.identity,
name: name,
sortNo: old.sortNo,
deviceIdentities: old.deviceIdentities,
);
}
@override
Future<void> deleteDeviceGroup(String identity) async {
groups.removeWhere((group) => group.identity == identity);
}
@override
Future<void> assignDeviceGroup(String deviceIdentity, String groupIdentity) async {
assignedGroup = groupIdentity;
}
}
void main() {
testWidgets('可新建设备分组并保留未开放控制提示', (tester) async {
final repository = _GroupFixture();
await tester.pumpWidget(MaterialApp(home: DeviceGroupsPage(repository: repository)));
await tester.pumpAndSettle();
expect(find.text('厨房'), findsOneWidget);
expect(find.textContaining('分组开关及安全检查暂未开放'), findsOneWidget);
await tester.tap(find.text('新建分组'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '客厅');
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
expect(repository.groups.any((group) => group.name == '客厅'), isTrue);
expect(find.text('客厅'), findsOneWidget);
});
testWidgets('设备可真实移回未分组', (tester) async {
final repository = _GroupFixture();
await tester.pumpWidget(MaterialApp(home: DeviceGroupsPage(repository: repository)));
await tester.pumpAndSettle();
await tester.tap(find.text('管理分组'));
await tester.pumpAndSettle();
await tester.tap(find.text('更改'));
await tester.pumpAndSettle();
await tester.tap(find.text('未分组'));
await tester.pumpAndSettle();
expect(repository.assignedGroup, isEmpty);
expect(find.text('设备分组已更新'), findsOneWidget);
});
}

View File

@@ -0,0 +1,23 @@
// 功能:未开放设备卡在窄屏和放大文字下不溢出、不展示模拟遥测;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/ui/features/home/device_status_placeholder.dart';
void main() {
for (final width in [320.0, 360.0, 390.0, 430.0]) {
testWidgets('设备未开放卡片$width宽度放大文字', (tester) async {
tester.view.physicalSize = Size(width, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(MaterialApp(home: MediaQuery(
data: MediaQueryData(size: Size(width, 844), textScaler: const TextScaler.linear(1.3)),
child: const Scaffold(body: Padding(padding: EdgeInsets.all(32), child: DeviceStatusPlaceholder())),
)));
expect(tester.takeException(), isNull);
expect(find.text('暂不可查询'), findsNWidgets(4));
expect(find.text('在线'), findsNothing);
expect(find.text('正常'), findsNothing);
});
}
}

View File

@@ -0,0 +1,61 @@
// 功能:本人设备搜索、类型筛选和未知状态展示;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/domain/models/client_models.dart';
import 'package:user_app/domain/models/device_group.dart';
import 'package:user_app/ui/features/profile/devices_page.dart';
import '../support/a1_fixture.dart';
class _DeviceFixture extends A1FixtureRepository {
@override
Future<List<ClientRecord>> devices() async => [
ClientRecord(identity: 'valve', title: '厨房阀门', subtitle: 'V001', raw: {'kind': 'valve'}),
ClientRecord(identity: 'alarm', title: '厨房报警器', subtitle: 'A002', raw: {'kind': 'alarm'}),
];
@override
Future<List<DeviceGroup>> deviceGroups() async => const [
DeviceGroup(identity: 'kitchen', name: '厨房组', sortNo: 0, deviceIdentities: {'valve', 'alarm'}),
];
}
void main() {
testWidgets('按类型及编号筛选,未知状态不冒充零告警或离线', (tester) async {
await tester.pumpWidget(MaterialApp(home: DevicesPage(repository: _DeviceFixture())));
await tester.pumpAndSettle();
expect(find.text('—'), findsNWidgets(3));
expect(find.text('厨房组'), findsOneWidget);
expect(find.text('厨房阀门'), findsOneWidget);
await tester.tap(find.text('报警器'));
await tester.pumpAndSettle();
expect(find.text('厨房阀门'), findsNothing);
expect(find.text('厨房报警器'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'V001');
await tester.pumpAndSettle();
expect(find.text('没有符合条件的设备'), findsOneWidget);
await tester.tap(find.text('全部'));
await tester.pumpAndSettle();
expect(find.text('厨房阀门'), findsOneWidget);
await tester.tap(find.text('控制'));
await tester.pumpAndSettle();
expect(find.text('该功能暂未开放,目前无法使用。'), findsOneWidget);
});
for (final width in [320.0, 390.0, 430.0]) {
testWidgets('设备页$width宽度放大字体无溢出', (tester) async {
tester.view.physicalSize = Size(width, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(1.3)),
child: child!,
),
home: DevicesPage(repository: _DeviceFixture()),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
}
}

View File

@@ -0,0 +1,81 @@
// 功能:联系人真实成功刷新与失败幂等重试回归;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/domain/models/client_models.dart';
import 'package:user_app/data/services/api_client.dart';
import 'package:user_app/ui/features/profile/emergency_contacts_page.dart';
import '../support/a1_fixture.dart';
class _ContactsFixture extends A1FixtureRepository {
final calls = <String>[];
bool failContactSave = true;
int? failureCode;
final contacts = <ClientRecord>[];
@override
Future<List<ClientRecord>> emergencyContacts() async => List.of(contacts);
@override
Future<void> saveEmergencyContact({String? identity,required String name,required String phone,
required String relationship,required String requestNo}) async {
calls.add(requestNo);
if (failureCode != null) throw ApiException(failureCode!, 'server message');
if (failContactSave) throw StateError('timeout');
contacts.add(ClientRecord(identity:'test-contact',title:name,subtitle:phone,raw:{'relationship':relationship}));
}
}
void main() {
for (final entry in <int,String>{2501:'最多可添加5位联系人',2502:'该手机号已在联系人列表中',2503:'该新增请求已处理'}.entries) {
testWidgets('业务拒绝${entry.key}显示具体原因且不添加记录', (tester) async {
final repo = _ContactsFixture()..failureCode=entry.key;
await tester.pumpWidget(MaterialApp(home:EmergencyContactsPage(repository:repo)));
await tester.pumpAndSettle();
await tester.tap(find.text('添加')); await tester.pumpAndSettle();
await tester.enterText(find.byType(TextFormField).at(0),'联系人');
await tester.enterText(find.byType(TextFormField).at(1),'13800000002');
await tester.tap(find.text('保存')); await tester.pumpAndSettle();
expect(find.textContaining(entry.value),findsOneWidget);
expect(repo.contacts,isEmpty);
});
}
for (final width in [320.0,390.0,430.0]) {
testWidgets('联系人长姓名在$width宽度和放大文字下保持可操作', (tester) async {
tester.view.physicalSize = Size(width,844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final repo = _ContactsFixture();
repo.contacts.add(ClientRecord(identity:'long',title:'用于核对长姓名换行的联系人',subtitle:'13800000002',raw:{'relationship':'需要保持完整显示的关系描述'}));
await tester.pumpWidget(MaterialApp(builder: (context,child) => MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(1.3)),child:child!),
home:EmergencyContactsPage(repository:repo)));
await tester.pumpAndSettle();
expect(find.byTooltip('编辑联系人'),findsOneWidget);
expect(tester.takeException(),isNull);
});
}
testWidgets('失败保留输入及新增请求号,成功后刷新且权限仍未开放', (tester) async {
final repo = _ContactsFixture();
await tester.pumpWidget(MaterialApp(home:EmergencyContactsPage(repository:repo)));
await tester.pumpAndSettle();
await tester.tap(find.text('添加'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextFormField).at(0),'测试联系人');
await tester.enterText(find.byType(TextFormField).at(1),'13800000002');
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
expect(find.textContaining('未能确认保存结果'),findsOneWidget);
expect(repo.contacts,isEmpty);
repo.failContactSave = false;
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
expect(repo.calls.length,2);
expect(repo.calls[0],repo.calls[1]);
expect(find.text('测试联系人'),findsOneWidget);
expect(find.text('138****0002'),findsOneWidget);
expect(find.text('权限 · 暂未开放'),findsOneWidget);
await tester.tap(find.text('远程控制'));
await tester.pumpAndSettle();
expect(find.text('该功能暂未开放,目前无法使用。'),findsOneWidget);
expect(tester.takeException(),isNull);
});
}

View File

@@ -0,0 +1,101 @@
// 功能:家庭共享页面布局、邀请幂等和撤销交互回归;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/domain/models/family_sharing.dart';
import 'package:user_app/ui/features/profile/family_sharing_page.dart';
import '../support/a1_fixture.dart';
class _FamilyFixture extends A1FixtureRepository {
String? invitedPhone;
String? requestNo;
String? revokedIdentity;
@override
Future<FamilyDashboardData> familyDashboard() async => FamilyDashboardData(
householdName: '薛海路之家',
ownerName: '张先生',
memberCount: 2,
deviceCount: 1,
members: [
const FamilyMember(
identity: 'owner',
name: '张先生',
phoneMasked: '138****5678',
relationship: '房主',
inviteStatus: 20,
isOwner: true,
permissions: [],
),
FamilyMember(
identity: 'pending',
name: '王女士',
phoneMasked: '137****9056',
relationship: '家人',
inviteStatus: 10,
isOwner: false,
permissions: const [],
expiresAt: DateTime(2026, 9, 20),
),
],
devices: const [
FamilyDevice(
identity: 'device-1',
name: '厨房报警器',
kind: 'alarm',
shareCount: 0,
mappingConfigured: true,
),
],
);
@override
Future<void> inviteFamilyMember({
required String name,
required String phone,
required String relationship,
required String requestNo,
required List<FamilyDevicePermission> permissions,
}) async {
invitedPhone = phone;
this.requestNo = requestNo;
}
@override
Future<void> revokeFamilyMember(String identity) async {
revokedIdentity = identity;
}
}
void main() {
testWidgets('家庭共享展示房主、待确认成员、设备和安全规则', (tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(MaterialApp(home: FamilySharingPage(repository: _FamilyFixture())));
await tester.pumpAndSettle();
expect(find.text('薛海路之家'), findsOneWidget);
expect(find.text('张先生'), findsWidgets);
expect(find.text('王女士'), findsOneWidget);
expect(find.text('待确认'), findsOneWidget);
expect(find.text('厨房报警器'), findsOneWidget);
expect(find.text('远程控制需二次确认,保障用气安全'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('邀请校验通过后提交手机号和稳定请求号', (tester) async {
final repository = _FamilyFixture();
await tester.pumpWidget(MaterialApp(home: FamilySharingPage(repository: repository)));
await tester.pumpAndSettle();
await tester.tap(find.text('添加'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextFormField).at(0), '李女士');
await tester.enterText(find.byType(TextFormField).at(1), '13900000003');
await tester.tap(find.text('发送邀请'));
await tester.pumpAndSettle();
expect(repository.invitedPhone, '13900000003');
expect(repository.requestNo, isNotEmpty);
expect(tester.takeException(), isNull);
});
}

View File

@@ -0,0 +1,136 @@
// 功能描述:收藏分类、下架保留、管理取消和加购重试回归;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/domain/models/product_favorite.dart';
import 'package:user_app/domain/models/primary_models.dart';
import 'package:user_app/domain/models/cart_item.dart';
import 'package:user_app/data/services/api_client.dart';
import 'package:user_app/ui/core/app_theme.dart';
import 'package:user_app/ui/features/shop/favorites_page.dart';
import '../support/a1_fixture.dart';
class _Repo extends A1FixtureRepository {
List<ProductFavorite> items = const [
ProductFavorite(
state: ProductFavoriteState(productIdentity: 'p1', active: true, revision: 'r1'),
product: ProductSummary(
identity: 'p1',
name: '燃气报警器',
price: 16900,
stock: 5,
category: '配件',
),
available: true,
specifications: ['家用款'],
),
ProductFavorite(
state: ProductFavoriteState(productIdentity: 'p2', active: true, revision: 'r2'),
product: ProductSummary(
identity: 'p2',
name: '家用燃气灶',
price: 69900,
stock: 0,
category: '灶具',
),
available: false,
),
];
final removed = <String>[];
final quantities = <int>[];
int quantity = 2;
bool failAdd = true;
@override
Future<List<ProductFavorite>> favorites() async => List.of(items);
@override
Future<ProductFavoriteState> setFavorite(ProductFavoriteState state, bool active) async {
removed.add(state.productIdentity);
items = items.where((i) => i.product.identity != state.productIdentity).toList();
return ProductFavoriteState(
productIdentity: state.productIdentity,
active: active,
revision: 'new',
);
}
@override
Future<CartItem> cartItem(String identity) async => CartItem(
product: items.first.product,
quantity: quantity,
selected: true,
available: true,
revision: 'cart',
);
@override
Future<CartItem> setCartItem(
CartItem item, {
required int quantity,
required bool selected,
}) async {
quantities.add(quantity);
this.quantity = quantity;
if (failAdd) {
failAdd = false;
throw const ApiException(500, '加购结果未知');
}
return CartItem(
product: item.product,
quantity: quantity,
selected: selected,
available: true,
revision: 'next',
);
}
}
void main() {
testWidgets('按分类计数、下架保留且无加购,管理取消恢复空态', (tester) async {
final repo = _Repo();
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: FavoritesPage(repository: repo),
),
);
await tester.pumpAndSettle();
expect(find.text('全部 2'), findsOneWidget);
expect(find.text('暂不可售'), findsOneWidget);
expect(find.text('加入购物车'), findsOneWidget);
await tester.tap(find.text('灶具 1'));
await tester.pumpAndSettle();
expect(find.text('家用燃气灶'), findsOneWidget);
expect(find.text('燃气报警器'), findsNothing);
await tester.tap(find.text('删除'));
await tester.pumpAndSettle();
expect(repo.removed, ['p2']);
expect(find.text('全部 1'), findsOneWidget);
await tester.tap(find.text('管理'));
await tester.pumpAndSettle();
await tester.tap(find.byType(Checkbox).last);
await tester.pumpAndSettle();
await tester.tap(find.text('取消收藏(1)'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, '取消收藏'));
await tester.pumpAndSettle();
expect(repo.removed, ['p2', 'p1']);
expect(find.text('暂无收藏商品'), findsOneWidget);
});
testWidgets('收藏加购未知结果复用绝对目标数量', (tester) async {
final repo = _Repo();
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: FavoritesPage(repository: repo),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('加入购物车'));
await tester.pumpAndSettle();
expect(repo.quantities, [3]);
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
await tester.tap(find.text('重试加入'));
await tester.pumpAndSettle();
expect(repo.quantities, [3, 3]);
expect(repo.quantity, 3);
});
}

View File

@@ -0,0 +1,34 @@
// 功能:验证首期缺项与明确二期入口的不同提示,以及已开放入口行为;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/ui/core/feature_entry.dart';
void main() {
testWidgets('未指定阶段的缺项保留入口并提示暂未开放', (tester) async {
await tester.pumpWidget(const MaterialApp(home: Scaffold(body:
FeatureEntry(icon: Icons.devices, label: '设备测试'))));
expect(find.text('暂未开放'), findsOneWidget);
expect(find.text('即将开放'), findsNothing);
await tester.tap(find.text('设备测试'));
await tester.pumpAndSettle();
expect(find.text(UnavailableStage.firstPhase.message), findsOneWidget);
});
testWidgets('明确二期显示即将开放而已开放功能继续执行', (tester) async {
await tester.pumpWidget(const MaterialApp(home: Scaffold(body:
FeatureEntry(icon: Icons.devices, label: '二期测试', stage: UnavailableStage.secondPhase))));
expect(find.text('即将开放'), findsOneWidget);
await tester.tap(find.text('二期测试'));
await tester.pumpAndSettle();
expect(find.text(UnavailableStage.secondPhase.message), findsOneWidget);
await tester.tap(find.text('知道了'));
await tester.pumpAndSettle();
var invoked = false;
await tester.pumpWidget(MaterialApp(home: Scaffold(body:
FeatureEntry(icon: Icons.devices, label: '可用测试', onTap: () => invoked = true))));
await tester.pumpAndSettle();
expect(find.text('暂未开放'), findsNothing);
await tester.tap(find.text('可用测试'));
expect(invoked, isTrue);
});
}

View File

@@ -0,0 +1,54 @@
// 功能描述:合同历史读取失败重试、事实显示及关闭;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/domain/models/gas_order_detail.dart';
import 'package:user_app/ui/features/orders/gas_contract_history.dart';
import '../support/a1_fixture.dart';
class _Repo extends A1FixtureRepository {
int calls = 0;
@override
Future<List<GasContractEvent>> gasContractHistory(String identity) async {
expect(identity, 'c1');
if (++calls == 1) throw Exception('网络故障');
return [
GasContractEvent.fromJson({
'identity': 'r1',
'action': 'renew',
'contract_status': 11,
'occurred_at': '2026-09-08T10:00:00Z',
'effective_at': '2026-09-08T00:00:00Z',
'expired_at': '2027-09-08T00:00:00Z',
}),
];
}
}
void main() {
testWidgets('历史错误可重试且不冒充签署记录', (tester) async {
final repo = _Repo();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => showGasContractHistory(context, repo, 'c1'),
child: const Text('打开'),
),
),
),
),
);
await tester.tap(find.text('打开'));
await tester.pumpAndSettle();
expect(find.text('加载失败'), findsOneWidget);
await tester.tap(find.text('重新加载'));
await tester.pumpAndSettle();
expect(find.text('合同续期'), findsOneWidget);
expect(find.text('变更后状态:生效中'), findsOneWidget);
expect(find.text('到期日期:2027-09-08'), findsOneWidget);
await tester.tap(find.byIcon(Icons.close));
await tester.pumpAndSettle();
expect(find.text('合同续期'), findsNothing);
});
}

View File

@@ -0,0 +1,47 @@
// 功能描述:合同页面筛选、搜索、正文读取及失败重试;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/domain/models/gas_order_detail.dart';
import 'package:user_app/ui/core/app_theme.dart';
import 'package:user_app/ui/features/orders/gas_contracts_page.dart';
import '../support/a1_fixture.dart';
class _Repo extends A1FixtureRepository {
int calls = 0;
@override
Future<List<GasContractSummary>> gasContracts() async {
if (++calls == 1) throw Exception('网络错误');
return super.gasContracts();
}
}
void main() {
testWidgets('加载失败可重试,按真实状态和合同编号筛选,查看正文', (tester) async {
final repo = _Repo();
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: GasContractsPage(repository: repo),
),
);
await tester.pumpAndSettle();
expect(find.text('加载失败'), findsOneWidget);
await tester.tap(find.text('重新加载'));
await tester.pumpAndSettle();
expect(find.text('居民瓶装燃气供用气合同'), findsOneWidget);
await tester.tap(find.text('已到期'));
await tester.pumpAndSettle();
expect(find.text('暂无符合条件的合同'), findsOneWidget);
await tester.tap(find.text('全部'));
await tester.tap(find.byIcon(Icons.search));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '不存在');
await tester.pumpAndSettle();
expect(find.text('暂无符合条件的合同'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'HT20260905001');
await tester.pumpAndSettle();
await tester.tap(find.text('查看合同'));
await tester.pumpAndSettle();
expect(find.textContaining('测试合同正文'), findsOneWidget);
});
}

View File

@@ -0,0 +1,25 @@
// 功能描述:气瓶下单页金额和二次确认提交回归;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/ui/features/orders/gas_order_create_page.dart';
import '../support/a1_fixture.dart';
void main() {
testWidgets('展示服务端报价且最终确认前不创建订单', (tester) async {
final repository = A1FixtureRepository();
await tester.pumpWidget(
MaterialApp(home: GasOrderCreatePage(repository: repository)),
);
await tester.pumpAndSettle();
expect(find.text('薛海气站'), findsOneWidget);
expect(find.text('15kg 家用瓶'), findsOneWidget);
expect(find.text('确认下单'), findsOneWidget);
await tester.tap(find.byTooltip('增加').last);
await tester.pump();
await tester.tap(find.text('确认下单'));
await tester.pumpAndSettle();
expect(find.text('确认创建气瓶订单?'), findsOneWidget);
expect(repository.gasOrderCreateCalls, 0);
});
}

View File

@@ -0,0 +1,107 @@
// 功能描述:供气订单事实、合同阅读及本人签收确认和重试;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:user_app/data/services/api_client.dart';
import 'package:user_app/domain/models/gas_order_detail.dart';
import 'package:user_app/ui/core/app_theme.dart';
import 'package:user_app/ui/features/orders/shop_order_detail_page.dart';
import '../support/a1_fixture.dart';
class _Repo extends A1FixtureRepository {
final requests = <String>[];
int contractReads = 0;
bool done = false, failContract = true;
@override
Future<GasOrderDetail> gasOrderDetail(String identity) async {
if (!done) return super.gasOrderDetail(identity);
return GasOrderDetail.fromJson({
'identity': identity,
'order_no': 'G1',
'status_name': '已完成',
'allowed_actions': [],
'product_amount': 12800,
'delivery_fee': 0,
'discount_amount': 0,
'payable_amount': 12800,
'items': [],
'timeline': [],
'payments': [],
}, (s) => s);
}
@override
Future<GasContractDetail> gasContractDetail(String identity) async {
contractReads++;
if (failContract) {
failContract = false;
throw const ApiException(500, '合同暂时读取失败');
}
return super.gasContractDetail(identity);
}
@override
Future<void> confirmGasReceipt(String identity, {required String requestNo}) async {
requests.add(requestNo);
if (requests.length == 1) throw const ApiException(500, '签收结果待确认');
done = true;
}
}
void main() {
testWidgets('读取气站、历史支付与合同正文,签收须确认且重试保持请求号', (tester) async {
final repo = _Repo();
final router = GoRouter(
initialLocation: '/gas/orders/g1',
routes: [
GoRoute(
path: '/gas/orders/:id',
builder: (_, s) => ShopOrderDetailPage(
repository: repo,
identity: s.pathParameters['id']!,
business: 'gas',
),
),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(MaterialApp.router(theme: AppTheme.light(), routerConfig: router));
await tester.pumpAndSettle();
expect(find.text('薛海气站'), findsOneWidget);
expect(find.text('配送员:李师傅'), findsOneWidget);
expect(repo.requests, isEmpty);
await tester.scrollUntilVisible(
find.text('查看合同'),
200,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
await tester.tap(find.text('查看合同'));
await tester.pumpAndSettle();
expect(find.text('加载失败'), findsOneWidget);
await tester.tap(find.text('重新加载'));
await tester.pumpAndSettle();
expect(find.text('测试合同正文,仅用于自动验证,不作为真实合同。'), findsOneWidget);
expect(repo.contractReads, 2);
await tester.tap(find.byWidgetPredicate((w) => w is IconButton && w.tooltip == '关闭合同'));
await tester.pumpAndSettle();
await tester.tap(find.text('确认收货'));
await tester.pumpAndSettle();
await tester.tap(find.text('返回'));
await tester.pumpAndSettle();
expect(repo.requests, isEmpty);
await tester.tap(find.text('确认收货'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, '确认收货').last);
await tester.pumpAndSettle();
expect(find.text('签收结果待确认'), findsOneWidget);
await tester.tap(find.text('确认收货'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, '确认收货').last);
await tester.pumpAndSettle();
expect(repo.requests.length, 2);
expect(repo.requests[0], repo.requests[1]);
expect(find.text('确认收货'), findsNothing);
expect(tester.takeException(), isNull);
});
}

View File

@@ -0,0 +1,35 @@
// 功能描述:验证电子发票二期页面只展示真实订单并明确提示即将开放;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/ui/features/orders/invoice_preview_page.dart';
import '../support/a1_fixture.dart';
void main() {
testWidgets('电子发票页面显示订单事实且不提交申请', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: InvoicePreviewPage(
repository: A1FixtureRepository(),
business: 'gas',
identity: 'gas-1',
),
),
);
await tester.pumpAndSettle();
expect(find.text('二期功能 · 即将开放'), findsOneWidget);
expect(find.textContaining('G202609050920001'), findsOneWidget);
expect(find.text('押金不计入发票金额'), findsOneWidget);
await tester.scrollUntilVisible(
find.text('提交开票申请 · 即将开放'),
300,
scrollable: find.byType(Scrollable).first,
);
await tester.tap(find.text('提交开票申请 · 即将开放'));
await tester.pumpAndSettle();
expect(find.text('电子发票即将开放'), findsOneWidget);
expect(find.textContaining('不会提交开票申请'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}

View File

@@ -14,6 +14,14 @@ class _RecordingUserSession extends UserSession {
int loginCallCount = 0;
String? lastPhone;
String? lastPassword;
String? lastCode;
int codeCalls = 0;
@override
Future<String> sendCode(String phone, String purpose) async {
codeCalls++;
return 'request-1';
}
@override
Future<void> login({
@@ -25,30 +33,84 @@ class _RecordingUserSession extends UserSession {
loginCallCount += 1;
lastPhone = phone;
lastPassword = password;
lastCode = verificationCode;
}
}
/// 模拟服务端确认短信未实际发送,验证页面不会显示虚假倒计时。
class _UnavailableCodeSession extends _RecordingUserSession {
@override
Future<String> sendCode(String phone, String purpose) async {
throw const ApiException(2410, '短信验证码暂未开放');
}
}
/// 验证用户端登录界面的关键交互。
void main() {
testWidgets('显隐保留密码和焦点,重复选择当前登录方式不清空输入', (tester) async {
await tester.pumpWidget(MaterialApp(home: LoginPage(session: _RecordingUserSession())));
await tester.tap(find.text('密码登录'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).at(1), 'Input-test-123');
await tester.tap(find.byTooltip('显示密码'));
await tester.pumpAndSettle();
var field = tester.widget<TextField>(find.byType(TextField).at(1));
expect(field.controller!.text, 'Input-test-123');
expect(field.obscureText, isFalse);
expect(field.focusNode!.hasFocus, isTrue);
await tester.tap(find.text('密码登录'));
await tester.pumpAndSettle();
expect(
tester.widget<TextField>(find.byType(TextField).at(1)).controller!.text,
'Input-test-123',
);
await tester.tap(find.byTooltip('隐藏密码'));
await tester.pumpAndSettle();
field = tester.widget<TextField>(find.byType(TextField).at(1));
expect(field.obscureText, isTrue);
expect(field.controller!.text, 'Input-test-123');
expect(tester.takeException(), isNull);
});
testWidgets('有焦点时切换验证码建立新连接并清除上一种凭据', (tester) async {
await tester.pumpWidget(MaterialApp(home: LoginPage(session: _RecordingUserSession())));
await tester.tap(find.text('密码登录'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).at(1), 'Input-test-123');
await tester.tap(find.text('验证码登录'));
await tester.pumpAndSettle();
final field = tester.widget<TextField>(find.byType(TextField).at(1));
expect(field.controller!.text, isEmpty);
expect(field.keyboardType, TextInputType.number);
expect(field.obscureText, isFalse);
expect(field.focusNode!.hasFocus, isTrue);
await tester.enterText(find.byType(TextField).at(1), '314159');
expect(field.controller!.text, '314159');
expect(tester.takeException(), isNull);
});
testWidgets('login page exposes phone and password fields', (tester) async {
final session = UserSession(SecureSessionStore());
await tester.pumpWidget(MaterialApp(home: LoginPage(session: session)));
expect(find.text('瓶安芯'), findsOneWidget);
expect(find.bySemanticsLabel('瓶安芯'), findsOneWidget);
expect(find.byType(TextField), findsNWidgets(2));
expect(find.text('安全登录'), findsOneWidget);
expect(find.text('登录'), findsOneWidget);
});
testWidgets('手机号错误且密码为空时同时提示并阻止登录调用', (tester) async {
final session = _RecordingUserSession();
await tester.pumpWidget(MaterialApp(home: LoginPage(session: session)));
await tester.tap(find.text('密码登录'));
await tester.pump();
await tester.enterText(find.byType(TextField).first, '+8613800138000');
await tester.tap(find.text('安全登录'));
await tester.ensureVisible(find.text('登录'));
await tester.tap(find.text('登录'));
await tester.pump();
expect(find.text('请输入正确的11位手机号'), findsOneWidget);
expect(find.text('请输入密码'), findsOneWidget);
expect(tester.widget<TextField>(find.byType(TextField).at(1)).decoration?.errorText, '请输入密码');
expect(find.text('Invalid Argument'), findsNothing);
expect(session.loginCallCount, 0);
expect(tester.widget<TextField>(find.byType(TextField).first).focusNode?.hasFocus, isTrue);
@@ -57,13 +119,16 @@ void main() {
testWidgets('合法手机号配空密码时提示密码必填并阻止登录调用', (tester) async {
final session = _RecordingUserSession();
await tester.pumpWidget(MaterialApp(home: LoginPage(session: session)));
await tester.tap(find.text('密码登录'));
await tester.pump();
await tester.enterText(find.byType(TextField).first, '13800138000');
await tester.tap(find.text('安全登录'));
await tester.ensureVisible(find.text('登录'));
await tester.tap(find.text('登录'));
await tester.pump();
expect(find.text('请输入正确的11位手机号'), findsNothing);
expect(find.text('请输入密码'), findsOneWidget);
expect(tester.widget<TextField>(find.byType(TextField).at(1)).decoration?.errorText, '请输入密码');
expect(session.loginCallCount, 0);
expect(tester.widget<TextField>(find.byType(TextField).at(1)).focusNode?.hasFocus, isTrue);
});
@@ -71,18 +136,21 @@ void main() {
testWidgets('修改字段清除对应错误并由回车原样提交密码', (tester) async {
final session = _RecordingUserSession();
await tester.pumpWidget(MaterialApp(home: LoginPage(session: session)));
await tester.tap(find.text('密码登录'));
await tester.pump();
await tester.tap(find.text('安全登录'));
await tester.ensureVisible(find.text('登录'));
await tester.tap(find.text('登录'));
await tester.pump();
await tester.enterText(find.byType(TextField).first, '13800138000');
await tester.pump();
expect(find.text('请输入正确的11位手机号'), findsNothing);
expect(find.text('请输入密码'), findsOneWidget);
expect(tester.widget<TextField>(find.byType(TextField).at(1)).decoration?.errorText, '请输入密码');
await tester.enterText(find.byType(TextField).at(1), ' 密码123 ');
await tester.pump();
expect(find.text('请输入密码'), findsNothing);
expect(tester.widget<TextField>(find.byType(TextField).at(1)).decoration?.errorText, isNull);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
@@ -95,7 +163,33 @@ void main() {
test('接口错误优先按错误码中文化并屏蔽未知英文', () {
expect(localizeApiErrorMessage(1704, 'Invalid Argument'), '请求参数错误');
expect(localizeApiErrorMessage(1108, 'Password Incorrect'), '密码错误');
expect(localizeApiErrorMessage(2410, 'service unavailable'), '短信验证码暂未开放');
expect(localizeApiErrorMessage(9999, '业务处理失败'), '业务处理失败');
expect(localizeApiErrorMessage(9999, 'Unexpected Error'), '操作失败,请稍后重试');
});
testWidgets('验证码发送后修改手机号不得复用旧请求', (tester) async {
final session = _RecordingUserSession();
await tester.pumpWidget(MaterialApp(home: LoginPage(session: session)));
await tester.enterText(find.byType(TextField).first, '13800138000');
await tester.tap(find.text('获取验证码'));
await tester.pump();
await tester.enterText(find.byType(TextField).first, '13800138001');
await tester.enterText(find.byType(TextField).at(1), '123456');
await tester.ensureVisible(find.text('登录'));
await tester.tap(find.text('登录'));
await tester.pump();
expect(find.text('请先获取当前手机号的验证码'), findsOneWidget);
expect(session.loginCallCount, 0);
await tester.pumpWidget(const SizedBox());
});
testWidgets('短信未实际发送时明确提示暂未开放且不启动倒计时', (tester) async {
await tester.pumpWidget(MaterialApp(home: LoginPage(session: _UnavailableCodeSession())));
await tester.enterText(find.byType(TextField).first, '13800138000');
await tester.tap(find.text('获取验证码'));
await tester.pump();
expect(find.text('短信验证码暂未开放'), findsOneWidget);
expect(find.text('60s'), findsNothing);
});
}

View File

@@ -0,0 +1,31 @@
// 功能描述:验证消息分类、筛选和全部已读使用真实仓储动作;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/ui/features/profile/message_center_page.dart';
import '../support/a1_fixture.dart';
void main() {
testWidgets('消息中心展示分类并执行全部已读', (tester) async {
final repository = A1FixtureRepository();
await tester.pumpWidget(MaterialApp(home: MessageCenterPage(repository: repository)));
await tester.pumpAndSettle();
expect(find.text('安全告警'), findsOneWidget);
expect(find.text('厨房燃气浓度异常'), findsOneWidget);
await tester.tap(find.widgetWithText(TextButton, '全部已读'));
await tester.pumpAndSettle();
expect(repository.markedMessageKeys, containsAll(['safety-1', 'order-1']));
});
testWidgets('未读筛选只展示未读消息', (tester) async {
await tester.pumpWidget(
MaterialApp(home: MessageCenterPage(repository: A1FixtureRepository())),
);
await tester.pumpAndSettle();
await tester.tap(find.text('未读'));
await tester.pumpAndSettle();
expect(find.text('厨房燃气浓度异常'), findsOneWidget);
expect(find.text('配送员已出发'), findsOneWidget);
expect(find.text('秋季安全用气提醒'), findsNothing);
});
}

View File

@@ -16,29 +16,73 @@ class _FakeRepository extends ClientRepository {
int refundCalls = 0;
int ticketCalls = 0;
int createRefundCalls = 0;
int cancellations = 0, gasCancellations = 0, receipts = 0;
bool serveGas = false;
bool completed = false;
List<String> actions = ['refund'];
@override
Future<List<ClientRecord>> shopOrders() async {
shopCalls += 1;
return const [
return [
ClientRecord(
identity: 'shop-1',
title: '商城订单一',
subtitle: '',
status: 18,
status: completed ? 21 : 18,
raw: {
'order_no': 'EC-SEARCH-001',
'status_name': completed ? '已完成' : '待履约',
'allowed_actions': actions,
'items': [
{'identity': 'item-1', 'quantity': 1},
{
'identity': 'item-1',
'quantity': 1,
'product_snapshot': '{"name":"燃气报警器"}',
},
],
},
),
];
}
@override
Future<void> cancelShopOrder(String identity) async {
cancellations++;
actions = [];
}
@override
Future<void> confirmShopReceipt(String identity) async {
receipts++;
actions = [];
}
@override
Future<List<ClientRecord>> gasOrders() async {
gasCalls += 1;
return const [];
if (!serveGas) return const [];
return [
ClientRecord(
identity: 'gas-1',
title: '气瓶订单一',
subtitle: '',
status: 16,
raw: {
'status_name': '待付款',
'allowed_actions': actions,
'items': [
{'identity': 'gas-item-1', 'quantity': 1, 'product_type_name': '15kg液化气'},
],
},
),
];
}
@override
Future<void> cancelGasOrder(String identity) async {
gasCancellations++;
actions = [];
}
@override
@@ -66,6 +110,46 @@ class _FakeRepository extends ClientRepository {
}
void main() {
testWidgets('气瓶订单取消调用气瓶接口并刷新气瓶列表', (tester) async {
final repository = _FakeRepository()
..serveGas = true
..actions = ['cancel'];
await tester.pumpWidget(
MaterialApp(home: OrdersPage(repository: repository, initialTab: 1)),
);
await tester.pumpAndSettle();
await tester.tap(find.text('取消订单'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, '确认取消'));
await tester.pumpAndSettle();
expect(repository.gasCancellations, 1);
expect(repository.cancellations, 0);
expect(repository.gasCalls, 2);
});
for (final action in ['cancel', 'confirm_receipt']) {
testWidgets('$action 必须确认,完成后刷新动作', (tester) async {
final repository = _FakeRepository()..actions = [action];
await tester.pumpWidget(MaterialApp(home: OrdersPage(repository: repository)));
await tester.pumpAndSettle();
final label = action == 'cancel' ? '取消订单' : '确认收货';
await tester.tap(find.text(label));
await tester.pumpAndSettle();
await tester.tap(find.text('返回'));
await tester.pumpAndSettle();
expect(repository.cancellations + repository.receipts, 0);
await tester.tap(find.text(label));
await tester.pumpAndSettle();
await tester.tap(
find.widgetWithText(FilledButton, action == 'cancel' ? '确认取消' : '确认收货').last,
);
await tester.pumpAndSettle();
expect(repository.cancellations + repository.receipts, 1);
expect(repository.shopCalls, 2);
expect(find.text(label), findsNothing);
expect(tester.takeException(), isNull);
});
}
testWidgets('退款成功后刷新商城订单和退款记录', (tester) async {
final repository = _FakeRepository();
await tester.pumpWidget(
@@ -73,25 +157,62 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(find.text('退款'));
await tester.tap(find.text('退款/售后'));
await tester.pumpAndSettle();
expect(repository.refundCalls, 1);
await tester.tap(find.text('商城'));
await tester.tap(find.byTooltip('关闭退款记录'));
await tester.pumpAndSettle();
expect(repository.shopCalls, 1);
await tester.tap(find.text('商城订单一'));
await tester.tap(find.text('查看操作'));
await tester.pumpAndSettle();
await tester.tap(find.text('申请退款'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '商品问题');
await tester.enterText(
find.descendant(of: find.byType(AlertDialog), matching: find.byType(TextField)),
'商品问题',
);
await tester.tap(find.text('提交'));
await tester.pumpAndSettle();
expect(repository.createRefundCalls, 1);
expect(repository.shopCalls, 2);
await tester.tap(find.text('退款/售后'));
await tester.pumpAndSettle();
expect(repository.refundCalls, 2);
expect(tester.takeException(), isNull);
});
testWidgets('订单搜索按订单号和商品名称过滤,并可关闭清空', (tester) async {
final repository = _FakeRepository()..actions = [];
await tester.pumpWidget(MaterialApp(home: OrdersPage(repository: repository)));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('搜索订单'));
await tester.pumpAndSettle();
expect(find.text('搜索订单号或商品名称'), findsOneWidget);
await tester.enterText(find.byType(TextField), '不存在');
await tester.pumpAndSettle();
expect(find.text('暂无订单'), findsOneWidget);
await tester.enterText(find.byType(TextField), '燃气报警器');
await tester.pumpAndSettle();
expect(find.text('燃气报警器 × 1'), findsOneWidget);
await tester.tap(find.byTooltip('关闭搜索'));
await tester.pumpAndSettle();
expect(find.textContaining('EC-SEARCH-001'), findsOneWidget);
});
testWidgets('已完成订单的二期功能显示即将开放说明', (tester) async {
final repository = _FakeRepository()
..actions = []
..completed = true;
await tester.pumpWidget(MaterialApp(home: OrdersPage(repository: repository)));
await tester.pumpAndSettle();
await tester.tap(find.text('开发票'));
await tester.pumpAndSettle();
expect(find.text('电子发票'), findsOneWidget);
expect(find.text('该功能将在后续版本开放,敬请期待。'), findsOneWidget);
});
}

View File

@@ -0,0 +1,144 @@
// 功能描述:验证图19真实订单金额、余额密码支付和未开放优惠提示;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:user_app/domain/models/shop_order_detail.dart';
import 'package:user_app/domain/models/recharge.dart';
import 'package:user_app/ui/features/orders/payment_confirmation_page.dart';
import '../support/a1_fixture.dart';
class _PaymentFixture extends A1FixtureRepository {
String? channel, password;
@override
Future<ShopOrderDetail> shopOrderDetail(String identity) async => ShopOrderDetail.fromJson({
'identity': identity,
'order_no': 'EC20260911001',
'status_name': '待付款',
'allowed_actions': ['pay', 'cancel'],
'address': '测试地址',
'contact_name': '测试用户',
'contact_phone': '13800000001',
'remark': '',
'logistics_no': '',
'logistics_company': '',
'product_amount': 23800,
'discount_amount': 1000,
'payable_amount': 22800,
'created_at': '2026-09-11T10:00:00+08:00',
'paid_at': null,
'shipped_at': null,
'received_at': null,
'items': [
{
'identity': 'item-1',
'product_identity': 'product-1',
'name': '15kg家用液化气',
'quantity': 1,
'sale_amount': 23800,
'image_url': '',
},
],
}, resolveImageUrl);
@override
Future<Map<String, Object?>> payOrder({
required String business,
required String identity,
required String requestNo,
required String channel,
required String payType,
String openid = '',
String paymentPassword = '',
}) async {
this.channel = channel;
password = paymentPassword;
return {'paid': true};
}
}
/// 模拟生产环境未配置外部商户,验证页面不会把不可用渠道伪装成可支付。
class _UnavailablePaymentFixture extends _PaymentFixture {
@override
Future<RechargeOptions> rechargeOptions() async => const RechargeOptions(
min: 1,
max: 500000,
channels: [
RechargeChannel('wechat', app: false, wap: false),
RechargeChannel('alipay', app: false, wap: false),
],
);
}
void main() {
testWidgets('支付确认页使用服务端金额并通过六位支付密码提交余额支付', (tester) async {
final repository = _PaymentFixture();
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (_, _) => PaymentConfirmationPage(
repository: repository,
business: 'shop',
identity: 'order-1',
),
),
GoRoute(
path: '/shop/orders/order-1',
builder: (_, _) => const Scaffold(body: Text('订单详情')),
),
],
);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.pumpAndSettle();
expect(find.text('¥228.00'), findsWidgets);
expect(find.text('余额 ¥568.00'), findsOneWidget);
expect(find.text('暂未开放'), findsOneWidget);
await tester.ensureVisible(find.text('优惠券'));
await tester.drag(find.byType(ListView), const Offset(0, -140));
await tester.pumpAndSettle();
await tester.tap(find.text('优惠券'));
await tester.pumpAndSettle();
expect(find.text('优惠券暂未开放'), findsOneWidget);
await tester.tap(find.text('知道了'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.byKey(const Key('payment-password')));
await tester.enterText(find.byKey(const Key('payment-password')), '123456');
await tester.tap(find.byKey(const Key('confirm-payment')));
await tester.pumpAndSettle();
expect(find.text('订单号:EC20260911001\n支付方式:余额支付\n支付金额:¥228.00'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, '确认支付'));
await tester.pumpAndSettle();
expect(repository.channel, 'wallet');
expect(repository.password, '123456');
expect(find.text('订单详情'), findsOneWidget);
router.dispose();
});
testWidgets('外部商户未配置时显示暂未开放且不会提交支付', (tester) async {
final repository = _UnavailablePaymentFixture();
await tester.pumpWidget(
MaterialApp(
home: PaymentConfirmationPage(
repository: repository,
business: 'shop',
identity: 'order-1',
),
),
);
await tester.pumpAndSettle();
expect(find.text('暂未开放'), findsNWidgets(3));
final wechat = tester.widget<RadioListTile<String>>(
find.widgetWithText(RadioListTile<String>, '微信支付'),
);
expect(wechat.enabled, isFalse);
expect(repository.channel, isNull);
});
}

View File

@@ -0,0 +1,122 @@
// 功能描述:支付密码状态分支、六位校验、验证码用途和失败重试测试;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/data/services/api_client.dart';
import 'package:user_app/domain/models/client_models.dart';
import 'package:user_app/ui/core/app_theme.dart';
import 'package:user_app/ui/features/settings/payment_password_page.dart';
import '../support/a1_fixture.dart';
class PaymentPasswordFixture extends A1FixtureRepository {
bool? hasPassword = false;
bool failSave = false;
int codes = 0, saves = 0;
bool? resetting;
String? oldPassword, requestId, codeValue;
@override
Future<WalletSummary> wallet() async =>
WalletSummary(balance: 0, withdrawalBalance: 0, paymentPasswordSet: hasPassword);
@override
Future<PaymentCodeRequest> requestPaymentPasswordCode({required bool resetting}) async {
codes++;
this.resetting = resetting;
return const PaymentCodeRequest(
identity: 'test-request',
delivered: false,
maskedPhone: '138****0001',
retryAfter: 60,
);
}
@override
Future<bool> setPaymentPassword({
required String newPassword,
String? currentPassword,
String? requestIdentity,
String? code,
}) async {
saves++;
oldPassword = currentPassword;
requestId = requestIdentity;
codeValue = code;
if (failSave) throw const ApiException(1306, '密码错误');
hasPassword = true;
return true;
}
}
Future<void> openPayment(WidgetTester tester, PaymentPasswordFixture repo) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: PaymentPasswordPage(repository: repo),
),
);
await tester.pumpAndSettle();
}
Future<void> fillNext(WidgetTester tester) async {
await tester.enterText(find.byKey(const ValueKey('payment-next')), '782913');
await tester.enterText(find.byKey(const ValueKey('payment-repeat')), '782913');
}
void main() {
testWidgets('未知支付密码状态不能误当首次设置', (tester) async {
final repo = PaymentPasswordFixture()..hasPassword = null;
await openPayment(tester, repo);
expect(find.text('支付密码状态读取失败'), findsOneWidget);
expect(find.text('获取验证码'), findsNothing);
});
testWidgets('首次设置明确Mock未发送,验证码请求与确认成功后切换状态', (tester) async {
final repo = PaymentPasswordFixture();
await openPayment(tester, repo);
expect(find.text('设置支付密码'), findsOneWidget);
await tester.tap(find.text('获取验证码'));
await tester.pumpAndSettle();
expect(repo.resetting, false);
expect(find.text('验证码请求已建立,但当前环境未发送短信'), findsOneWidget);
expect(find.textContaining('已发送至'), findsNothing);
await tester.enterText(find.byKey(const ValueKey('payment-code')), '314159');
await fillNext(tester);
await tester.tap(find.text('确认'));
await tester.pumpAndSettle();
expect(repo.saves, 1);
expect(repo.oldPassword, isNull);
expect(repo.requestId, 'test-request');
expect(repo.codeValue, '314159');
expect(find.text('支付密码已更新'), findsOneWidget);
expect(find.text('修改支付密码'), findsOneWidget);
});
testWidgets('旧密码修改失败保留表单,找回使用独立用途且不传旧密码', (tester) async {
final repo = PaymentPasswordFixture()
..hasPassword = true
..failSave = true;
await openPayment(tester, repo);
await tester.enterText(find.byKey(const ValueKey('payment-old')), '123');
await fillNext(tester);
await tester.tap(find.text('确认'));
await tester.pumpAndSettle();
expect(repo.saves, 0);
await tester.enterText(find.byKey(const ValueKey('payment-old')), '123456');
await tester.tap(find.text('确认'));
await tester.pumpAndSettle();
expect(repo.saves, 1);
expect(find.text('密码错误'), findsOneWidget);
expect(
tester.widget<TextFormField>(find.byKey(const ValueKey('payment-next'))).controller!.text,
'782913',
);
await tester.tap(find.text('忘记支付密码?'));
await tester.pumpAndSettle();
await tester.tap(find.text('获取验证码'));
await tester.pumpAndSettle();
expect(repo.resetting, true);
await tester.enterText(find.byKey(const ValueKey('payment-code')), '314159');
repo.failSave = false;
await tester.tap(find.text('确认'));
await tester.pumpAndSettle();
expect(repo.saves, 2);
expect(repo.oldPassword, isNull);
expect(repo.requestId, 'test-request');
});
}

View File

@@ -0,0 +1,147 @@
// 功能描述:验证一级页面保留入口、游客数据边界、筛选及刷新错误恢复。
// 版本:1.0.0
import 'package:flutter/material.dart';
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:user_app/domain/models/client_models.dart';
import 'package:user_app/domain/models/order_summary.dart';
import 'package:user_app/ui/features/home/home_page.dart';
import 'package:user_app/ui/features/shop/shop_page.dart';
import 'package:user_app/ui/core/async_content.dart';
import 'package:user_app/ui/core/text_entry_dialog.dart';
import '../support/a1_fixture.dart';
void main() {
testWidgets('刷新失败保留上次数据,较迟的旧请求不覆盖新数据', (tester) async {
final key = GlobalKey<AsyncContentState<String>>();
final pending = <Completer<String>>[];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: AsyncContent<String>(
key: key,
load: () {
final c = Completer<String>();
pending.add(c);
return c.future;
},
builder: (_, value) => ListView(children: [Text(value)]),
),
),
),
);
pending[0].complete('初始数据');
await tester.pumpAndSettle();
final first = key.currentState!.refresh();
final second = key.currentState!.refresh();
pending[2].complete('最新数据');
await second;
await tester.pump();
pending[1].complete('旧请求结果');
await first;
await tester.pump();
expect(find.text('最新数据'), findsOneWidget);
expect(find.text('旧请求结果'), findsNothing);
final failure = key.currentState!.refresh();
pending[3].completeError(Exception('offline'));
await failure;
await tester.pumpAndSettle();
expect(find.text('最新数据'), findsOneWidget);
expect(find.textContaining('刷新失败,当前显示上次数据'), findsOneWidget);
});
testWidgets('输入弹窗关闭动画期间控制器有效,空值阻止提交', (tester) async {
String? result;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () async {
result = await showDialog<String>(
context: context,
builder: (_) => const TextEntryDialog(title: '新增地址', label: '详细地址', action: '保存'),
);
},
child: const Text('打开'),
),
),
),
),
);
await tester.tap(find.text('打开'));
await tester.pumpAndSettle();
await tester.tap(find.text('保存'));
await tester.pump();
expect(find.text('请填写详细地址'), findsOneWidget);
await tester.enterText(find.byType(TextField), '测试地址');
await tester.tap(find.text('保存'));
await tester.pump();
await tester.pumpAndSettle();
expect(result, '测试地址');
expect(tester.takeException(), isNull);
});
testWidgets('游客首页不读取个人归属并保留设备入口', (tester) async {
final source = A1FixtureRepository();
await tester.pumpWidget(MaterialApp(home: HomePage(repository: source, authenticated: false)));
await tester.pumpAndSettle();
expect(source.relationCalls, 0);
await tester.ensureVisible(find.text('扫码添加'));
await tester.tap(find.text('扫码添加'));
await tester.pumpAndSettle();
expect(find.textContaining('该功能暂未开放'), findsOneWidget);
expect(find.text('85%'), findsNothing);
});
testWidgets('首页真实入口可进入设备页且未接入入口明确提示', (tester) async {
final source = A1FixtureRepository();
final router = GoRouter(
initialLocation: '/home',
routes: [
GoRoute(
path: '/home',
builder: (_, _) => HomePage(repository: source),
),
GoRoute(
path: '/devices',
builder: (_, _) => const Scaffold(body: Text('设备页已打开')),
),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.pumpAndSettle();
expect(find.text('查看设备状态与告警记录'), findsOneWidget);
expect(find.text('查看气瓶使用与检测信息'), findsOneWidget);
await tester.tap(find.text('查看详情'));
await tester.pumpAndSettle();
expect(find.text('设备页已打开'), findsOneWidget);
});
testWidgets('首次加载失败可重试恢复真实内容', (tester) async {
final source = A1FixtureRepository()..fail = true;
await tester.pumpWidget(MaterialApp(home: HomePage(repository: source)));
await tester.pumpAndSettle();
expect(find.text('加载失败'), findsOneWidget);
source.fail = false;
await tester.tap(find.text('重新加载'));
await tester.pumpAndSettle();
expect(find.text('当前服务归属'), findsOneWidget);
});
testWidgets('商城搜索与分类联动并显示空结果', (tester) async {
await tester.pumpWidget(MaterialApp(home: ShopPage(repository: A1FixtureRepository())));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '报警');
await tester.pumpAndSettle();
expect(find.text('燃气报警器'), findsOneWidget);
expect(find.text('15kg家用液化气'), findsNothing);
await tester.enterText(find.byType(TextField), '不存在');
await tester.pumpAndSettle();
expect(find.textContaining('暂无符合条件'), findsOneWidget);
});
test('旧响应缺少 allowed_actions 时不从状态码推测权限', () {
final order = OrderSummary.fromRecord(
const ClientRecord(identity: 'o', title: '', subtitle: '', status: 16, raw: {}),
);
expect(order.actions, isEmpty);
expect(order.amount, isNull);
});
}

View File

@@ -0,0 +1,109 @@
// 功能描述:商品详情的公开浏览、售罄与错误边界、数量向结算传递回归。
// 版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:user_app/data/services/api_client.dart';
import 'package:user_app/domain/models/product_detail.dart';
import 'package:user_app/ui/features/shop/product_detail_page.dart';
import '../support/a1_fixture.dart';
class _Repository extends A1FixtureRepository {
int stock = 2;
bool unavailable = false;
@override
Future<ProductDetail> productDetail(String identity) async {
if (unavailable) throw const ApiException(1112, '商品已下架');
return ProductDetail(
identity: identity,
name: '测试商品',
price: 1999,
stock: stock,
attributes: const [(name: '材质', value: '不锈钢')],
);
}
}
void main() {
testWidgets('查看商品与参数,数量限制库存,购买只进入结算', (tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final repo = _Repository();
final router = GoRouter(
initialLocation: '/products/p1',
routes: [
GoRoute(
path: '/products/:identity',
builder: (_, state) =>
ProductDetailPage(repository: repo, identity: state.pathParameters['identity']!),
),
GoRoute(
path: '/checkout/:identity',
builder: (_, state) => Scaffold(
body: Text(
'结算 ${state.pathParameters['identity']} 数量${state.uri.queryParameters['quantity']}',
),
),
),
],
);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.pumpAndSettle();
expect(find.text('测试商品'), findsOneWidget);
expect(find.text('¥19.99'), findsOneWidget);
await tester.tap(find.byTooltip('增加数量'));
await tester.pump();
expect(
tester
.widget<IconButton>(
find.byWidgetPredicate((widget) => widget is IconButton && widget.tooltip == '增加数量'),
)
.onPressed,
isNull,
);
await tester.scrollUntilVisible(
find.textContaining('不锈钢'),
180,
scrollable: find.byType(Scrollable).first,
);
expect(find.textContaining('不锈钢'), findsOneWidget);
await tester.tap(find.text('立即购买'));
await tester.pumpAndSettle();
expect(find.text('结算 p1 数量2'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
router.dispose();
});
testWidgets('售罄可查看,禁止购买;下架响应可重试恢复', (tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final repo = _Repository()..stock = 0;
await tester.pumpWidget(
MaterialApp(
home: ProductDetailPage(repository: repo, identity: 'p1'),
),
);
await tester.pumpAndSettle();
expect(
tester.widget<FilledButton>(find.widgetWithText(FilledButton, '暂时售罄')).onPressed,
isNull,
);
await tester.pumpWidget(const SizedBox());
repo.unavailable = true;
await tester.pumpWidget(
MaterialApp(
home: ProductDetailPage(repository: repo, identity: 'missing'),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('商品已下架'), findsOneWidget);
expect(find.text('立即购买'), findsNothing);
repo.unavailable = false;
await tester.tap(find.text('重新加载'));
await tester.pumpAndSettle();
expect(find.text('测试商品'), findsOneWidget);
});
}

View File

@@ -0,0 +1,195 @@
// 功能描述:推荐分页、加购不重复、购物车刷新及收藏入口回归;版本:1.0.0。
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:user_app/data/services/api_client.dart';
import 'package:user_app/domain/models/cart_item.dart';
import 'package:user_app/domain/models/product_favorite.dart';
import 'package:user_app/domain/models/primary_models.dart';
import 'package:user_app/domain/models/product_recommendations.dart';
import 'package:user_app/ui/core/app_theme.dart';
import 'package:user_app/ui/features/shop/cart_page.dart';
import 'package:user_app/ui/features/shop/favorites_page.dart';
import 'package:user_app/ui/features/shop/product_recommendations.dart';
import '../support/a1_fixture.dart';
/// 模拟请求已写入却丢失响应,确认重试仍发送同一目标。
class _Repo extends A1FixtureRepository {
final requests = <(String, int)>[];
final targets = <int>[];
List<CartItem> items = [];
bool failLoad = false, failWrite = false;
int stock = 5;
Completer<void>? gate;
ProductSummary get product =>
ProductSummary(identity: 'r', name: '真实推荐', price: 3900, stock: stock);
@override
Future<List<ProductFavorite>> favorites() async => [];
@override
Future<ProductRecommendations> recommendations({required String source, int page = 1}) async {
requests.add((source, page));
if (failLoad) {
failLoad = false;
throw const ApiException(500, '暂时无法加载');
}
return ProductRecommendations(
items: [
page == 1
? product
: const ProductSummary(identity: 'other', name: '下一批商品', price: 500, stock: 3),
],
page: page,
hasMore: page == 1,
);
}
@override
Future<List<CartItem>> cart() async => List.of(items);
@override
Future<CartItem> cartItem(String identity) async => items.isNotEmpty
? items.first
: CartItem(
product: product,
quantity: 0,
selected: false,
available: true,
revision: 'original',
);
@override
Future<CartItem> setCartItem(
CartItem item, {
required int quantity,
required bool selected,
}) async {
targets.add(quantity);
await gate?.future;
final updated = CartItem(
product: product,
quantity: quantity,
selected: selected,
available: true,
revision: 'new',
);
items = [updated];
if (failWrite) {
failWrite = false;
throw const ApiException(500, '加入结果待确认');
}
return updated;
}
}
Finder _button(String label) =>
find.byWidgetPredicate((w) => w is IconButton && w.tooltip == label);
Future<void> _show(WidgetTester tester, Widget child) async {
await tester.pumpWidget(MaterialApp(theme: AppTheme.light(), home: child));
await tester.pumpAndSettle();
}
void main() {
testWidgets('从推荐进入详情修改购物车,返回后重读合计', (tester) async {
final repo = _Repo();
final router = GoRouter(
initialLocation: '/cart',
routes: [
GoRoute(
path: '/cart',
builder: (_, _) => CartPage(repository: repo),
),
GoRoute(
path: '/products/:id',
builder: (context, _) => Scaffold(
body: TextButton(
onPressed: () async {
await repo.setCartItem(await repo.cartItem('r'), quantity: 1, selected: true);
if (context.mounted) context.pop();
},
child: const Text('详情加购后返回'),
),
),
),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(MaterialApp.router(theme: AppTheme.light(), routerConfig: router));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('真实推荐'));
await tester.tap(find.text('真实推荐'));
await tester.pumpAndSettle();
await tester.tap(find.text('详情加购后返回'));
await tester.pumpAndSettle();
expect(find.text('合计:¥39.00'), findsOneWidget);
expect(find.text('去结算(1)'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('推荐分页可轮换,失败可重读,加购未知结果复用绝对数量', (tester) async {
final repo = _Repo()
..failLoad = true
..failWrite = true;
var changes = 0;
await _show(
tester,
Scaffold(
body: ProductRecommendationSection(
repository: repo,
source: 'cart',
onCartChanged: () async {
changes++;
},
),
),
);
expect(find.text('暂时无法加载'), findsOneWidget);
await tester.tap(find.text('重新加载'));
await tester.pumpAndSettle();
await tester.tap(find.text('换一换'));
await tester.pumpAndSettle();
expect(find.text('下一批商品'), findsOneWidget);
await tester.tap(find.text('换一换'));
await tester.pumpAndSettle();
expect(repo.requests.map((r) => r.$2), [1, 1, 2, 1]);
await tester.tap(_button('加入购物车 真实推荐'));
await tester.pumpAndSettle();
expect(changes, 0);
expect(find.text('加入结果待确认'), findsOneWidget);
await tester.tap(_button('重试加入 真实推荐'));
await tester.pumpAndSettle();
expect(repo.targets, [1, 1]);
expect(changes, 1);
expect(find.text('已加入购物车'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('加购期间阻止结算,确认后购物车数量与合计刷新', (tester) async {
final repo = _Repo()..gate = Completer<void>();
await _show(tester, CartPage(repository: repo));
await tester.ensureVisible(_button('加入购物车 真实推荐'));
await tester.tap(_button('加入购物车 真实推荐'));
await tester.pump();
expect(tester.widget<FilledButton>(find.widgetWithText(FilledButton, '处理中')).onPressed, isNull);
repo.gate!.complete();
await tester.pumpAndSettle();
expect(find.text('合计:¥39.00'), findsOneWidget);
expect(find.text('去结算(1)'), findsOneWidget);
expect(repo.items.single.quantity, 1);
expect(tester.takeException(), isNull);
});
testWidgets('库存变为零不写购物车,收藏入口只在打开后读取推荐', (tester) async {
final repo = _Repo();
await _show(tester, FavoritesPage(repository: repo));
expect(repo.requests, isEmpty);
await tester.tap(find.text('猜你喜欢'));
await tester.pumpAndSettle();
expect(repo.requests, [('favorites', 1)]);
expect(repo.targets, isEmpty);
repo.stock = 0;
await tester.tap(_button('加入购物车 真实推荐'));
await tester.pumpAndSettle();
expect(find.text('商品库存不足'), findsOneWidget);
expect(repo.targets, isEmpty);
await tester.tap(_button('关闭推荐'));
await tester.pumpAndSettle();
expect(find.byType(ProductRecommendationSection), findsNothing);
expect(tester.takeException(), isNull);
});
}

View File

@@ -0,0 +1,152 @@
// 功能描述:验证个人资料的真实保存、头像草稿、取消及失败重试边界。
// 版本:1.0.0。
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:user_app/data/services/api_client.dart';
import 'package:user_app/domain/models/client_models.dart';
import 'package:user_app/ui/core/app_theme.dart';
import 'package:user_app/ui/features/profile/profile_edit_page.dart';
import '../support/a1_fixture.dart';
/// 记录写入调用,用于确认失败不会误报成功或重复上传。
class ProfileFixture extends A1FixtureRepository {
int uploads = 0, saves = 0;
String? savedName, savedAvatar;
bool failSave = false;
bool hasAvatar = false;
@override
Future<UserProfile> profile() async => UserProfile(
identity: 'user-fixture',
name: '张先生',
phone: '13800005678',
avatar: hasAvatar ? '/uploads/avatars/owned.png' : '',
realName: '张实名',
);
@override
Future<Uint8List?> profileAvatar() async => _png;
@override
Future<List<ClientRecord>> addresses() async => [];
@override
Future<String> uploadAvatar(Uint8List bytes, String filename) async {
uploads++;
return '/uploads/avatars/owned.png';
}
@override
Future<void> updateProfile(String name, {String? avatar}) async {
saves++;
if (failSave) throw const ApiException(500, '保存失败,请重试');
savedName = name;
savedAvatar = avatar;
}
}
/// 测试使用真实有效的 1 像素 PNG,仅用于验证文件选择与请求,不作为产品素材。
final _png = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
);
Future<void> openEditor(WidgetTester tester, ProfileFixture repo, AvatarPicker picker) async {
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => Scaffold(
body: TextButton(
onPressed: () async {
final result = await context.push<bool>('/edit');
if (context.mounted && result == true) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存已确认')));
}
},
child: const Text('编辑'),
),
),
),
GoRoute(
path: '/edit',
builder: (context, state) => ProfileEditPage(repository: repo, pickImage: picker),
),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(MaterialApp.router(routerConfig: router, theme: AppTheme.light()));
await tester.tap(find.text('编辑'));
await tester.pumpAndSettle();
}
Future<void> selectPhoto(WidgetTester tester) async {
await tester.tap(find.text('更换头像'));
await tester.pumpAndSettle();
await tester.tap(find.text('从相册选择'));
await tester.pumpAndSettle();
}
void main() {
testWidgets('服务端清空头像后下拉刷新不残留旧图片', (tester) async {
final repo = ProfileFixture()..hasAvatar = true;
await openEditor(tester, repo, (_) async => null);
expect(find.text('已认证 张实名'), findsOneWidget);
expect(find.byKey(const Key('profile-avatar-image')), findsOneWidget);
repo.hasAvatar = false;
await tester.drag(find.byType(ListView).first, const Offset(0, 350));
await tester.pumpAndSettle();
expect(find.byKey(const Key('profile-avatar-image')), findsNothing);
expect(find.byKey(const Key('profile-avatar-fallback')), findsOneWidget);
});
testWidgets('选图保存失败保留草稿,重试不重复上传,成功才返回', (tester) async {
final repo = ProfileFixture()..failSave = true;
await openEditor(tester, repo, (_) async => XFile.fromData(_png, name: 'test.png'));
await selectPhoto(tester);
await tester.enterText(find.byKey(const Key('profile-name-input')), '新昵称');
await tester.drag(find.byType(ListView).first, const Offset(0, 350));
await tester.pumpAndSettle();
expect(
tester.widget<TextFormField>(find.byKey(const Key('profile-name-input'))).controller!.text,
'新昵称',
);
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
expect(repo.uploads, 1);
expect(repo.saves, 1);
expect(find.text('个人资料'), findsOneWidget);
expect(find.byKey(const Key('profile-save-error')), findsOneWidget);
repo.failSave = false;
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
expect(repo.uploads, 1);
expect(repo.saves, 2);
expect(repo.savedName, '新昵称');
expect(repo.savedAvatar, '/uploads/avatars/owned.png');
expect(find.text('保存已确认'), findsOneWidget);
});
testWidgets('取消选图后昵称保存不覆盖原头像', (tester) async {
final repo = ProfileFixture();
await openEditor(tester, repo, (_) async => null);
await selectPhoto(tester);
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
expect(repo.uploads, 0);
expect(repo.savedAvatar, isNull);
expect(repo.saves, 1);
});
testWidgets('空昵称不能提交;离开有草稿需要确认', (tester) async {
final repo = ProfileFixture();
await openEditor(tester, repo, (_) async => null);
await tester.enterText(find.byKey(const Key('profile-name-input')), ' ');
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
expect(repo.saves, 0);
expect(find.text('昵称不能为空'), findsOneWidget);
await tester.tap(find.byTooltip('返回'));
await tester.pumpAndSettle();
expect(find.text('放弃未保存的修改?'), findsOneWidget);
await tester.tap(find.text('继续编辑'));
await tester.pumpAndSettle();
expect(find.text('个人资料'), findsOneWidget);
});
}

View File

@@ -0,0 +1,133 @@
// 功能描述:充值页面缺少协议保护和待确认请求恢复;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/data/services/recharge_draft_store.dart';
import 'package:user_app/domain/models/recharge.dart';
import 'package:user_app/domain/models/client_models.dart';
import 'package:user_app/ui/features/wallet/recharge_page.dart';
import '../support/a1_fixture.dart';
class _Store implements RechargeDraftStore {
PendingRecharge? draft;
@override
Future<PendingRecharge?> read(String owner) async => draft;
@override
Future<void> write(String owner, PendingRecharge value) async {
draft = value;
}
@override
Future<void> delete(String owner) async {
draft = null;
}
}
class _Repo extends A1FixtureRepository {
bool failBalanceRefresh = false;
int balanceReads = 0;
@override
Future<WalletSummary> wallet() async {
if (++balanceReads > 1 && failBalanceRefresh) throw StateError('余额断网');
return super.wallet();
}
@override
Future<String> rechargeDraftOwner() async => 'api#alice';
@override
Future<RechargeOptions> rechargeOptions() async => const RechargeOptions(
min: 1,
max: 500000,
channels: [RechargeChannel('alipay', app: true, wap: true)],
);
@override
Future<RechargeRecord> rechargeResult(String request) async => RechargeRecord(
identity: 'one',
number: request,
amount: 10000,
status: 23,
channel: 'alipay',
createdAt: DateTime(2026),
);
}
void main() {
for (final width in [320.0, 390.0]) {
testWidgets('充值页窄屏与放大字体无溢出 $width', (tester) async {
tester.view.physicalSize = Size(width, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(1.3)),
child: child!,
),
home: RechargePage(repository: _Repo(), store: _Store()),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
await tester.scrollUntilVisible(
find.byType(FilledButton),
200,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
}
testWidgets('缺少协议不能创建充值,但可恢复并查询已有订单', (tester) async {
final store = _Store()
..draft = const PendingRecharge(
request: 'request-1',
amount: 10000,
channel: 'alipay',
payType: 'app',
);
await tester.pumpWidget(
MaterialApp(
home: RechargePage(repository: _Repo()..failBalanceRefresh = true, store: store),
),
);
await tester.pumpAndSettle();
expect(tester.widget<TextField>(find.byType(TextField)).enabled, isFalse);
await tester.scrollUntilVisible(
find.text('查询到账结果'),
250,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
expect(tester.widget<FilledButton>(find.byType(FilledButton)).onPressed, isNull);
await tester.tap(find.text('查询到账结果'));
await tester.pumpAndSettle();
expect(store.draft, isNull);
expect(find.text('查询到账结果'), findsNothing);
await tester.scrollUntilVisible(
find.byType(TextField),
-250,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
expect(tester.widget<TextField>(find.byType(TextField)).enabled, isTrue);
await tester.tap(find.byTooltip('清空金额'));
await tester.pumpAndSettle();
expect(tester.widget<TextField>(find.byType(TextField)).controller!.text, isEmpty);
await tester.enterText(find.byType(TextField), '200.29');
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text('确认充值 ¥200.29'),
250,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
expect(find.text('确认充值 ¥200.29'), findsOneWidget);
await tester.scrollUntilVisible(
find.text('已到账,余额刷新失败,请重新进入页面刷新'),
-250,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
expect(find.text('暂未确认充值结果,请稍后查询'), findsNothing);
});
}

View File

@@ -0,0 +1,51 @@
// 功能描述:充值记录分页去重和刷新失败恢复;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/domain/models/recharge.dart';
import 'package:user_app/ui/features/wallet/recharge_records_page.dart';
import '../support/a1_fixture.dart';
class _Repository extends A1FixtureRepository {
bool broken = false;
final cursors = <String>[];
@override
Future<RechargeRecordPage> rechargeRecords({String cursor = ''}) async {
cursors.add(cursor);
if (broken) throw Exception('断网');
RechargeRecord item(String id) => RechargeRecord(
identity: id,
number: id,
amount: 10000,
status: 10,
channel: 'wechat',
createdAt: DateTime(2026, 9, 11),
);
return RechargeRecordPage(
cursor.isEmpty ? [item('one')] : [item('one'), item('two')],
cursor.isEmpty ? '7' : '',
);
}
}
void main() {
testWidgets('续页去重,刷新失败保留记录并可重试', (tester) async {
final repo = _Repository();
await tester.pumpWidget(MaterialApp(home: RechargeRecordsPage(repository: repo)));
await tester.pumpAndSettle();
expect(find.text('待确认到账'), findsOneWidget);
await tester.tap(find.text('加载更多'));
await tester.pumpAndSettle();
expect(repo.cursors, ['', '7']);
expect(find.text('充值单号:one'), findsOneWidget);
expect(find.text('充值单号:two'), findsOneWidget);
repo.broken = true;
await tester.tap(find.byTooltip('刷新充值状态'));
await tester.pumpAndSettle();
expect(find.text('充值单号:two'), findsOneWidget);
repo.broken = false;
await tester.tap(find.text('充值记录加载失败,请重试'));
await tester.pumpAndSettle();
expect(find.text('充值单号:two'), findsNothing);
expect(repo.cursors.last, '');
});
}

View File

@@ -0,0 +1,214 @@
// 功能描述:草稿重开恢复、跨账户隔离、提交前持久化与存储失败不发单。
// 版本:1.0.0。
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:user_app/data/repositories/client_repository.dart';
import 'package:user_app/data/services/api_client.dart';
import 'package:user_app/data/services/repair_draft_store.dart';
import 'package:user_app/ui/features/tickets/repair_page.dart';
class _Drafts implements RepairDraftStore {
final records = <String, Map<String, Object?>>{};
bool fail = false;
@override
Future<Map<String, Object?>?> read(String owner) async => records[owner];
@override
Future<void> write(String owner, Map<String, Object?> draft) async {
if (fail) throw Exception('storage');
records[owner] = draft;
}
@override
Future<void> delete(String owner) async {
records.remove(owner);
}
}
class _Repository extends ClientRepository {
_Repository(this.store) : super(ApiClient(() => '', baseUrl: 'https://api.example.com'));
final _Drafts store;
String owner = 'api#alice';
int submissions = 0;
@override
Future<String> repairDraftOwner() async => owner;
@override
Future<Uint8List?> uploadedTicketPhoto(String uri) async => base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1cAAAAASUVORK5CYII=',
);
@override
Future<String> submitRepair({
required String requestNo,
required String description,
required String faultType,
required String addressIdentity,
DateTime? appointment,
List<Map<String, Object?>> photos = const [],
}) async {
expect(requestNo, 'persisted-request');
expect(store.records[owner]!['attempted'], true);
expect(store.records[owner]!['request_no'], requestNo);
expect(photos.length, 1);
submissions++;
if (submissions == 1) throw const ApiException(500, '结果待确认');
return 'ticket-1';
}
}
Map<String, Object?> draft({bool pending = true}) => {
'schema': 1,
'request_no': 'persisted-request',
'description': '已保存故障',
'fault_type': 'valve',
'step': pending ? 2 : 0,
'attempted': pending,
'address': pending
? {
'identity': 'address-1',
'address': '草稿地址',
'contact_name': '联系人',
'contact_phone': '13800000001',
}
: null,
'photos': pending
? [
{
'uri': '/uploads/ticket-photos/owned/photo.png',
'source': 'gallery',
'added_at': '2026-09-07T01:00:00Z',
},
]
: [],
};
GoRouter routerFor(ClientRepository repository, RepairDraftStore store) => GoRouter(
initialLocation: '/repair',
routes: [
GoRoute(
path: '/repair',
builder: (_, s) => RepairPage(repository: repository, draftStore: store),
),
GoRoute(
path: '/me',
builder: (_, s) => const Scaffold(body: Text('已暂存退出')),
),
GoRoute(
path: '/tickets/:identity',
builder: (_, s) => const Scaffold(body: Text('已提交')),
),
GoRoute(
path: '/orders',
builder: (_, s) => const Scaffold(body: Text('工单列表')),
),
],
);
void main() {
testWidgets('损坏草稿不会自动丢弃,明确清除后才可重新填写', (tester) async {
final store = _Drafts()..records['api#alice'] = {'schema': 1};
final repository = _Repository(store);
final router = routerFor(repository, store);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.pumpAndSettle();
await tester.tap(find.text('恢复草稿'));
await tester.pumpAndSettle();
expect(store.records['api#alice'], isNotNull);
await tester.tap(find.text('清除无法读取的草稿'));
await tester.pumpAndSettle();
expect(store.records['api#alice'], isNotNull);
await tester.tap(find.text('清除草稿'));
await tester.pumpAndSettle();
expect(store.records['api#alice'], isNull);
expect(find.byType(TextField), findsOneWidget);
expect(repository.submissions, 0);
await tester.pumpWidget(const SizedBox());
router.dispose();
});
testWidgets('结果未知重开后恢复原请求,成功清理草稿', (tester) async {
final store = _Drafts()..records['api#alice'] = draft();
final repository = _Repository(store);
var router = routerFor(repository, store);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.pumpAndSettle();
expect(repository.submissions, 0);
await tester.tap(find.text('恢复草稿'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('重试提交'));
await tester.tap(find.text('重试提交'));
await tester.pumpAndSettle();
expect(repository.submissions, 1);
expect(store.records['api#alice']!['request_no'], 'persisted-request');
await tester.pumpWidget(const SizedBox());
router.dispose();
router = routerFor(repository, store);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.pumpAndSettle();
await tester.tap(find.text('恢复草稿'));
await tester.pumpAndSettle();
expect(find.text('已保存故障'), findsOneWidget);
expect(repository.submissions, 1);
await tester.ensureVisible(find.text('重试提交'));
await tester.tap(find.text('重试提交'));
await tester.pumpAndSettle();
expect(repository.submissions, 2);
expect(store.records['api#alice'], isNull);
expect(find.text('已提交'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
router.dispose();
});
testWidgets('保存失败不发送工单且保留输入', (tester) async {
final store = _Drafts()
..records['api#alice'] = draft()
..fail = true;
final repository = _Repository(store);
final router = routerFor(repository, store);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.pumpAndSettle();
await tester.tap(find.text('恢复草稿'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('重试提交'));
await tester.tap(find.text('重试提交'));
await tester.pumpAndSettle();
expect(repository.submissions, 0);
expect(find.textContaining('提交前保存失败'), findsOneWidget);
expect(find.text('已保存故障'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
router.dispose();
});
testWidgets('其他账户不会出现已有草稿', (tester) async {
final store = _Drafts()..records['api#alice'] = draft();
final repository = _Repository(store)..owner = 'api#bob';
final router = routerFor(repository, store);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.pumpAndSettle();
expect(find.text('发现报修草稿'), findsNothing);
expect(find.text('已保存故障'), findsNothing);
expect(store.records['api#alice'], isNotNull);
await tester.pumpWidget(const SizedBox());
router.dispose();
});
testWidgets('暂存退出保存编辑和同一个请求号', (tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final store = _Drafts()..records['api#alice'] = draft(pending: false);
final repository = _Repository(store);
final router = routerFor(repository, store);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.pumpAndSettle();
await tester.tap(find.text('恢复草稿'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '修改后暂存');
await tester.ensureVisible(find.text('暂存并退出'));
await tester.tap(find.text('暂存并退出'));
await tester.pumpAndSettle();
expect(find.text('已暂存退出'), findsOneWidget);
expect(store.records['api#alice']!['description'], '修改后暂存');
expect(store.records['api#alice']!['request_no'], 'persisted-request');
expect(repository.submissions, 0);
await tester.pumpWidget(const SizedBox());
router.dispose();
});
}

View File

@@ -0,0 +1,155 @@
// 功能描述:验证报修分步校验、地址返回、显式提交与未知结果重试。
// 版本:1.0.0。
import 'package:flutter/material.dart';
import 'dart:convert';
import 'dart:typed_data';
import 'package:image_picker/image_picker.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:user_app/data/repositories/client_repository.dart';
import 'package:user_app/data/services/api_client.dart';
import 'package:user_app/domain/models/shipping_address.dart';
import 'package:user_app/ui/features/tickets/repair_page.dart';
class _RepairRepository extends ClientRepository {
_RepairRepository() : super(ApiClient(() => '', baseUrl: 'https://api.example.com'));
final requests = <String>[];
int uploads = 0;
@override
Future<String> uploadTicketPhoto(Uint8List bytes, String filename) async {
uploads++;
return '/uploads/ticket-photos/owned/test.png';
}
@override
Future<String> submitRepair({
required String requestNo,
required String description,
required String faultType,
required String addressIdentity,
DateTime? appointment,
List<Map<String, Object?>> photos = const [],
}) async {
expect(description, '阀门异响');
expect(addressIdentity, 'address-1');
expect(faultType, 'valve');
requests.add(requestNo);
expect(photos.length, 1);
expect(photos.first['uri'], '/uploads/ticket-photos/owned/test.png');
if (requests.length == 1) throw const ApiException(500, '结果待确认');
return 'ticket-1';
}
}
void main() {
testWidgets('紧急电话只在用户点击后调用系统拨号入口', (tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
Uri? opened;
final repository = _RepairRepository();
await tester.pumpWidget(
MaterialApp(
home: RepairPage(
repository: repository,
launchEmergency: (uri) async {
opened = uri;
return true;
},
),
),
);
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -500));
await tester.pumpAndSettle();
expect(opened, isNull);
await tester.tap(find.widgetWithText(TextButton, '紧急电话'));
await tester.pumpAndSettle();
expect(opened, Uri(scheme: 'tel', path: '119'));
});
testWidgets('分步校验,失败冻结原请求,重试不重新生成请求号', (tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final repository = _RepairRepository();
final router = GoRouter(
initialLocation: '/repair',
routes: [
GoRoute(
path: '/repair',
builder: (_, state) => RepairPage(
repository: repository,
pickPhoto: (_) async => XFile.fromData(
base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1cAAAAASUVORK5CYII=',
),
name: 'test.png',
),
),
),
GoRoute(
path: '/addresses',
builder: (context, state) => Scaffold(
body: TextButton(
onPressed: () => context.pop(
const ShippingAddress(
identity: 'address-1',
address: '测试路88号',
contactName: '联系人',
contactPhone: '13800000001',
),
),
child: const Text('选这个地址'),
),
),
),
GoRoute(
path: '/tickets/:identity',
builder: (_, state) => const Scaffold(body: Text('已打开工单详情')),
),
],
);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('下一步'));
await tester.tap(find.text('下一步'));
await tester.pumpAndSettle();
expect(find.text('请描述故障现象'), findsWidgets);
await tester.enterText(find.byType(TextField), '阀门异响');
expect(find.text('添加照片'), findsNWidgets(3));
await tester.ensureVisible(find.text('添加照片').first);
await tester.tap(find.text('添加照片').first);
await tester.pumpAndSettle();
await tester.tap(find.text('从相册选择'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('下一步'));
await tester.tap(find.text('下一步'));
await tester.pumpAndSettle();
await tester.tap(find.text('下一步'));
await tester.pumpAndSettle();
expect(find.text('请选择并补齐联系人、电话和地址'), findsOneWidget);
await tester.tap(find.text('选择报修地址'));
await tester.pumpAndSettle();
await tester.tap(find.text('选这个地址'));
await tester.pumpAndSettle();
await tester.tap(find.text('下一步'));
await tester.pumpAndSettle();
expect(repository.requests, isEmpty);
await tester.ensureVisible(find.widgetWithText(FilledButton, '确认提交'));
await tester.tap(find.widgetWithText(FilledButton, '确认提交'));
await tester.pumpAndSettle();
expect(find.text('结果待确认'), findsOneWidget);
await tester.ensureVisible(find.text('重试提交'));
await tester.tap(find.text('重试提交'));
await tester.pumpAndSettle();
expect(repository.requests.length, 2);
expect(repository.uploads, 1);
expect(repository.requests[0], repository.requests[1]);
expect(find.text('已打开工单详情'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
router.dispose();
});
}

View File

@@ -0,0 +1,165 @@
// 功能描述:语音结果选区替换、重复中间结果、拒绝权限、离页取消与长度边界回归。
// 版本:1.0.0。
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/data/services/repair_speech.dart';
import 'package:user_app/ui/features/tickets/repair_speech_button.dart';
class _Speech implements RepairSpeech {
bool available = true;
int cancels = 0, starts = 0;
Completer<bool>? pending;
late void Function(String) words, error;
late void Function() done;
@override
Future<bool> start({
required void Function(String) onWords,
required void Function(String) onError,
required void Function() onDone,
}) async {
starts++;
words = onWords;
error = onError;
done = onDone;
return pending?.future ?? available;
}
@override
Future<void> stop() async {
done();
}
@override
Future<void> cancel() async {
cancels++;
}
}
void main() {
testWidgets('权限弹窗不取消初始化,切后台立即取消', (tester) async {
final speech = _Speech()..pending = Completer<bool>();
final controller = TextEditingController(text: '保留');
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: RepairSpeechButton(controller: controller, onBusy: (_) {}, speech: speech),
),
),
);
await tester.tap(find.text('语音输入'));
await tester.pump();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
await tester.pump();
expect(speech.cancels, 0);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pump();
expect(speech.cancels, 1);
speech.pending!.complete(true);
speech.words('迟到');
await tester.pump();
expect(controller.text, '保留');
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpWidget(const SizedBox());
controller.dispose();
});
testWidgets('选区替换和中间结果不重复追加,结束后可再次录入', (tester) async {
final speech = _Speech(), controller = TextEditingController(text: '前旧后');
controller.selection = const TextSelection(baseOffset: 1, extentOffset: 2);
final busy = <bool>[];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: RepairSpeechButton(controller: controller, onBusy: busy.add, speech: speech),
),
),
);
await tester.tap(find.text('语音输入'));
await tester.pump();
speech.words('阀门');
speech.words('阀门异响');
await tester.pump();
expect(controller.text, '前阀门异响后');
await tester.tap(find.text('结束识别'));
await tester.pump();
speech.words('迟到结果');
expect(controller.text, '前阀门异响后');
expect(busy.last, false);
await tester.tap(find.text('语音输入'));
await tester.pump();
speech.words('补充');
speech.done();
await tester.pump();
expect(controller.text, '前阀门异响补充后');
expect(speech.starts, 2);
await tester.pumpWidget(const SizedBox());
controller.dispose();
});
testWidgets('不可用和权限拒绝保留输入并解除忙碌', (tester) async {
final speech = _Speech()..available = false, controller = TextEditingController(text: '已有内容');
final busy = <bool>[];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: RepairSpeechButton(controller: controller, onBusy: busy.add, speech: speech),
),
),
);
await tester.tap(find.text('语音输入'));
await tester.pump();
expect(find.textContaining('无法使用语音识别'), findsOneWidget);
expect(controller.text, '已有内容');
expect(busy.last, false);
speech.available = true;
await tester.tap(find.text('语音输入'));
await tester.pump();
speech.error('error_permission');
await tester.pump();
expect(find.textContaining('未获得麦克风'), findsOneWidget);
expect(controller.text, '已有内容');
expect(busy.last, false);
await tester.pumpWidget(const SizedBox());
controller.dispose();
});
testWidgets('初始化中离页取消,迟到结果不改变文字', (tester) async {
final speech = _Speech()..pending = Completer<bool>(),
controller = TextEditingController(text: '保留');
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: RepairSpeechButton(controller: controller, onBusy: (_) {}, speech: speech),
),
),
);
await tester.tap(find.text('语音输入'));
await tester.pump();
await tester.pumpWidget(const SizedBox());
speech.pending!.complete(true);
speech.words('迟到');
await tester.pump();
expect(speech.cancels, 1);
expect(controller.text, '保留');
expect(tester.takeException(), isNull);
controller.dispose();
});
testWidgets('识别遵守2000字符上限,不截断表情字符', (tester) async {
final speech = _Speech(),
controller = TextEditingController(text: List.filled(1999, '字').join());
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: RepairSpeechButton(controller: controller, onBusy: (_) {}, speech: speech),
),
),
);
await tester.tap(find.text('语音输入'));
await tester.pump();
speech.words('😀多余');
speech.done();
await tester.pump();
expect(controller.text.characters.length, 2000);
expect(controller.text.endsWith('😀'), true);
await tester.pumpWidget(const SizedBox());
controller.dispose();
});
}

View File

@@ -0,0 +1,43 @@
// 功能:公告中心搜索、协议隔离和下架内容提示回归;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/domain/models/client_models.dart';
import 'package:user_app/data/services/api_client.dart';
import 'package:user_app/ui/features/home/safety_contents_page.dart';
import '../support/a1_fixture.dart';
class _ContentsRepository extends A1FixtureRepository {
@override
Future<ClientRecord> safetyContent(String identity) async => throw const ApiException(1112, '未发布');
@override
Future<List<ClientRecord>> contents() async => const [
ClientRecord(identity: 'notice', title: '配送调整', subtitle: '', raw: {'content_type': 'notice', 'body': '测试正文'}),
ClientRecord(identity: 'agreement', title: '用户协议', subtitle: '', raw: {'content_type': 'agreement'}),
ClientRecord(identity: 'law', title: '测试法规', subtitle: '', raw: {'content_type': 'law', 'body': '测试法规正文'}),
];
}
void main() {
testWidgets('公告中心不展示协议且支持标题搜索', (tester) async {
await tester.pumpWidget(MaterialApp(home: SafetyContentsPage(repository: _ContentsRepository())));
await tester.pumpAndSettle();
expect(find.text('配送调整'), findsOneWidget);
expect(find.text('用户协议'), findsNothing);
await tester.enterText(find.byType(TextField), '无匹配');
await tester.pump();
expect(find.text('暂无符合条件的内容'), findsOneWidget);
});
testWidgets('法律法规分类只显示该类型的已发布内容', (tester) async {
await tester.pumpWidget(MaterialApp(home: SafetyContentsPage(repository: _ContentsRepository())));
await tester.pumpAndSettle();
await tester.tap(find.text('法律法规'));
await tester.pump();
expect(find.text('测试法规'), findsOneWidget);
expect(find.text('配送调整'), findsNothing);
});
testWidgets('未知或非公告标识不能显示协议正文', (tester) async {
await tester.pumpWidget(MaterialApp(home: SafetyContentsPage(repository: _ContentsRepository(), identity: 'agreement')));
await tester.pumpAndSettle();
expect(find.text('内容不存在或已下架'), findsOneWidget);
});
}

View File

@@ -0,0 +1,136 @@
// 功能描述:设置确认操作、附属读取失败降级与登录密码表单回归;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/app/dependencies.dart';
import 'package:user_app/data/services/api_client.dart';
import 'package:user_app/data/services/app_settings_service.dart';
import 'package:user_app/data/services/secure_session_store.dart';
import 'package:user_app/domain/models/client_models.dart';
import 'package:user_app/ui/core/app_theme.dart';
import 'package:user_app/ui/features/settings/settings_page.dart';
import 'package:user_app/ui/features/settings/login_password_page.dart';
import '../support/a1_fixture.dart';
class SettingsSession extends UserSession {
SettingsSession() : super(SecureSessionStore());
int logouts = 0;
@override
Future<void> logout() async {
logouts++;
}
}
class SettingsFixture extends A1FixtureRepository {
bool failProfile = false, failPassword = true;
int changes = 0;
String? receivedCurrent, receivedNext;
@override
Future<UserProfile> profile() async {
if (failProfile) throw const ApiException(500, '资料暂时不可用');
return super.profile();
}
@override
Future<void> changeLoginPassword(String currentPassword, String newPassword) async {
changes++;
receivedCurrent = currentPassword;
receivedNext = newPassword;
if (failPassword) throw const ApiException(1305, '当前密码错误');
}
}
class SettingsServiceFixture extends AppSettingsService {
int clears = 0;
@override
Future<String> version() async => '1.0.0 (1)';
@override
int get imageCacheBytes => clears == 0 ? 1048576 : 0;
@override
void clearImageCache() {
clears++;
}
}
void main() {
testWidgets('资料读取失败仍可清图片缓存,取消不清除,退出需确认', (tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final repo = SettingsFixture()..failProfile = true;
final session = SettingsSession(), service = SettingsServiceFixture();
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: SettingsPage(repository: repo, session: session, service: service),
),
);
await tester.pumpAndSettle();
expect(find.text('读取失败,点击重试'), findsOneWidget);
await tester.scrollUntilVisible(find.text('清除缓存'), 300);
await tester.pumpAndSettle();
await tester.tap(find.text('清除缓存'));
await tester.pumpAndSettle();
await tester.tap(find.text('取消'));
await tester.pumpAndSettle();
expect(service.clears, 0);
await tester.tap(find.text('清除缓存'));
await tester.pumpAndSettle();
await tester.tap(find.text('确认'));
await tester.pumpAndSettle();
expect(service.clears, 1);
expect(find.text('0.0MB 图片缓存'), findsOneWidget);
await tester.scrollUntilVisible(find.text('退出登录'), 300);
await tester.pumpAndSettle();
await tester.tap(find.text('退出登录'));
await tester.pumpAndSettle();
await tester.tap(find.text('取消'));
await tester.pumpAndSettle();
expect(session.logouts, 0);
await tester.tap(find.text('退出登录'));
await tester.pumpAndSettle();
await tester.tap(find.text('确认'));
await tester.pumpAndSettle();
expect(session.logouts, 1);
});
testWidgets('密码校验阻止错误提交,服务端失败保留输入,成功才退出', (tester) async {
final repo = SettingsFixture(), session = SettingsSession();
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: LoginPasswordPage(repository: repo, session: session),
),
);
await tester.tap(find.text('确认修改并重新登录'));
await tester.pumpAndSettle();
expect(repo.changes, 0);
await tester.enterText(find.byKey(const ValueKey('password-0')), 'old-test-password');
await tester.enterText(find.byKey(const ValueKey('password-1')), 'new-test-password');
await tester.enterText(find.byKey(const ValueKey('password-2')), 'mismatch');
await tester.tap(find.text('确认修改并重新登录'));
await tester.pumpAndSettle();
expect(find.text('两次新密码不一致'), findsOneWidget);
expect(repo.changes, 0);
await tester.enterText(find.byKey(const ValueKey('password-2')), 'new-test-password');
await tester.tap(find.text('确认修改并重新登录'));
await tester.pumpAndSettle();
expect(find.text('当前密码错误'), findsOneWidget);
expect(session.logouts, 0);
expect(
tester.widget<TextFormField>(find.byKey(const ValueKey('password-1'))).controller!.text,
'new-test-password',
);
repo.failPassword = false;
await tester.tap(find.text('确认修改并重新登录'));
await tester.pumpAndSettle();
expect(repo.changes, 2);
expect(session.logouts, 1);
expect(repo.receivedCurrent, 'old-test-password');
expect(repo.receivedNext, 'new-test-password');
expect(
tester.widget<TextFormField>(find.byKey(const ValueKey('password-1'))).controller!.text,
'',
);
});
}

View File

@@ -0,0 +1,130 @@
// 功能描述:验证商城显式选择收货地址并提交该地址的联系人,不使用账户联系人。
// 版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:user_app/domain/models/shipping_address.dart';
import 'package:user_app/data/services/api_client.dart';
import 'package:user_app/ui/core/app_theme.dart';
import 'package:user_app/ui/features/shop/shop_page.dart';
import 'package:user_app/ui/features/shop/product_detail_page.dart';
import 'package:user_app/ui/features/shop/checkout_page.dart';
import '../support/a1_fixture.dart';
class CheckoutFixture extends A1FixtureRepository {
String? selected, name, phone;
final requests = <String>[];
int? sentQuantity, sentAmount;
@override
Future<Map<String, Object?>> submitShopOrder({
required String requestNo,
required String productIdentity,
required ShippingAddress address,
required int quantity,
required int expectedAmount,
String remark = '',
}) async {
requests.add(requestNo);
sentQuantity = quantity;
sentAmount = expectedAmount;
if (requests.length == 1) throw const ApiException(500, '提交结果未知');
selected = address.identity;
name = address.contactName;
phone = address.contactPhone;
return {'identity': 'created'};
}
@override
Future<List<ShippingAddress>> shippingAddresses() async => [];
}
void main() {
testWidgets('确认前不下单,提交选中地址的联系人', (tester) async {
final repo = CheckoutFixture();
final router = GoRouter(
initialLocation: '/shop',
routes: [
GoRoute(
path: '/products/:identity',
builder: (_, state) =>
ProductDetailPage(repository: repo, identity: state.pathParameters['identity']!),
),
GoRoute(
path: '/checkout/:identity',
builder: (_, state) =>
CheckoutPage(repository: repo, productIdentity: state.pathParameters['identity']!),
),
GoRoute(
path: '/shop',
builder: (_, state) => ShopPage(repository: repo),
),
GoRoute(
path: '/addresses',
builder: (context, state) => Scaffold(
body: TextButton(
onPressed: () => context.pop(
const ShippingAddress(
identity: 'selected-address',
address: '收货地址',
contactName: '收货联系人',
contactPhone: '13900000001',
),
),
child: const Text('选择此地址'),
),
),
),
GoRoute(
path: '/orders',
builder: (_, state) => const Scaffold(body: Text('订单列表')),
),
GoRoute(
path: '/payment/shop/:identity',
builder: (_, state) => Scaffold(
body: Text('支付订单 ${state.pathParameters['identity']}'),
),
),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(MaterialApp.router(routerConfig: router, theme: AppTheme.light()));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('15kg家用液化气'));
await tester.pumpAndSettle();
await tester.tap(find.text('15kg家用液化气'));
await tester.pumpAndSettle();
expect(repo.requests, isEmpty);
await tester.tap(find.text('立即购买'));
await tester.pumpAndSettle();
expect(repo.selected, isNull);
await tester.tap(find.text('选择收货地址'));
await tester.pumpAndSettle();
await tester.tap(find.text('选择此地址'));
await tester.pumpAndSettle();
expect(find.text('收货联系人 139****0001'), findsOneWidget);
expect(find.text('配送时间'), findsOneWidget);
expect(repo.selected, isNull);
await tester.tap(find.byTooltip('增加数量'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, '提交订单'));
await tester.pumpAndSettle();
expect(find.text('提交结果未知'), findsOneWidget);
expect(
tester
.widget<IconButton>(
find.byWidgetPredicate((widget) => widget is IconButton && widget.tooltip == '增加数量'),
)
.onPressed,
isNull,
);
await tester.tap(find.widgetWithText(FilledButton, '重试提交'));
await tester.pumpAndSettle();
expect(repo.selected, 'selected-address');
expect(repo.name, '收货联系人');
expect(repo.phone, '13900000001');
expect(repo.requests[0], repo.requests[1]);
expect(repo.sentQuantity, 2);
expect(repo.sentAmount, 25600);
expect(find.text('支付订单 created'), findsOneWidget);
});
}

View File

@@ -0,0 +1,36 @@
// 功能描述:验证商城分类支持鼠标、触屏横向拖动及拖动后的筛选。
// 版本:1.0.0
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/ui/features/shop/shop_page.dart';
import '../support/a1_fixture.dart';
void main() {
for (final kind in [PointerDeviceKind.mouse, PointerDeviceKind.touch]) {
testWidgets('分类拖动与筛选:${kind.name}', (tester) async {
tester.view.physicalSize = const Size(320, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(MaterialApp(home: ShopPage(repository: A1FixtureRepository())));
await tester.pumpAndSettle();
final strip = find.byKey(const ValueKey('shop-category-scroll'));
final scrollable = find.descendant(of: strip, matching: find.byType(Scrollable));
final state = tester.state<ScrollableState>(scrollable);
expect(state.position.pixels, 0);
expect(state.position.maxScrollExtent, greaterThan(0));
final gesture = await tester.startGesture(tester.getCenter(strip), kind: kind);
await gesture.moveBy(const Offset(-30, 0));
await tester.pump();
await gesture.moveBy(const Offset(-100, 0));
await gesture.up();
await tester.pumpAndSettle();
expect(state.position.pixels, greaterThan(0));
await tester.tap(find.widgetWithText(ChoiceChip, '燃气配件'));
await tester.pumpAndSettle();
expect(find.text('不锈钢减压阀'), findsOneWidget);
expect(find.text('燃气报警器'), findsNothing);
});
}
}

View File

@@ -0,0 +1,134 @@
// 功能描述:订单深链详情、历史快照、确认操作及状态变化回归;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:user_app/data/services/api_client.dart';
import 'package:user_app/domain/models/shop_order_detail.dart';
import 'package:user_app/ui/core/app_theme.dart';
import 'package:user_app/ui/features/orders/shop_order_detail_page.dart';
import '../support/a1_fixture.dart';
class _Repo extends A1FixtureRepository {
int reads = 0, cancels = 0;
bool failLoad = false, failCancel = false, stale = false;
String status = '待付款';
@override
Future<ShopOrderDetail> shopOrderDetail(String identity) async {
reads++;
if (failLoad) {
failLoad = false;
throw const ApiException(500, '暂时读取失败');
}
return ShopOrderDetail.fromJson({
'identity': identity,
'order_no': 'EC123',
'status_name': status,
'allowed_actions': status == '待付款' && !(stale && reads > 1) ? ['cancel'] : [],
'contact_name': '历史联系人',
'contact_phone': '13800000001',
'address': '成交时地址',
'product_amount': 3998,
'discount_amount': 0,
'payable_amount': 3998,
'remark': '原订单备注',
'created_at': '2026-09-05T09:20:00+08:00',
'paid_at': null,
'items': [
{'identity': 'line', 'name': '成交时商品', 'quantity': 2, 'sale_amount': 1999},
],
}, (s) => s);
}
@override
Future<void> cancelShopOrder(String identity) async {
cancels++;
if (failCancel) {
failCancel = false;
throw const ApiException(500, '取消结果待确认');
}
status = '已取消';
}
}
Future<void> _show(WidgetTester tester, _Repo repo) async {
final router = GoRouter(
initialLocation: '/shop/orders/one',
routes: [
GoRoute(
path: '/shop/orders/:id',
builder: (_, s) => ShopOrderDetailPage(repository: repo, identity: s.pathParameters['id']!),
),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(MaterialApp.router(theme: AppTheme.light(), routerConfig: router));
await tester.pumpAndSettle();
}
void main() {
testWidgets('深链读取快照,失败重试、金额、复制订单号与取消确认', (tester) async {
final repo = _Repo()..failLoad = true;
String? copied;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (
call,
) async {
if (call.method == 'Clipboard.setData') copied = (call.arguments as Map)['text'] as String;
return null;
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
),
);
await _show(tester, repo);
await tester.tap(find.text('重新加载'));
await tester.pumpAndSettle();
expect(find.text('成交时商品'), findsOneWidget);
expect(find.text('成交时地址'), findsOneWidget);
expect(find.text('¥19.99'), findsOneWidget);
expect(find.text('已付款'), findsNothing);
await tester.scrollUntilVisible(find.text('复制'), 250);
await tester.pumpAndSettle();
await tester.tap(find.text('复制'));
await tester.pumpAndSettle();
expect(copied, 'EC123');
await tester.tap(find.text('取消订单'));
await tester.pumpAndSettle();
await tester.tap(find.text('返回'));
await tester.pumpAndSettle();
expect(repo.cancels, 0);
await tester.tap(find.text('取消订单'));
await tester.pumpAndSettle();
await tester.tap(find.text('确认取消'));
await tester.pumpAndSettle();
expect(repo.cancels, 1);
expect(find.text('取消订单'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('刷新后不再允许的动作不会提交,失败操作保留重试', (tester) async {
final repo = _Repo()..stale = true;
await _show(tester, repo);
await tester.tap(find.text('取消订单'));
await tester.pumpAndSettle();
expect(repo.cancels, 0);
expect(find.text('订单状态已变化,请查看最新信息'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await tester.pumpAndSettle();
final retry = _Repo()..failCancel = true;
await _show(tester, retry);
await tester.tap(find.text('取消订单'));
await tester.pumpAndSettle();
await tester.tap(find.text('确认取消'));
await tester.pumpAndSettle();
expect(find.text('取消结果待确认'), findsOneWidget);
expect(find.text('取消订单'), findsOneWidget);
await tester.tap(find.text('取消订单'));
await tester.pumpAndSettle();
await tester.tap(find.text('确认取消'));
await tester.pumpAndSettle();
expect(retry.cancels, 2);
expect(tester.takeException(), isNull);
});
}

View File

@@ -0,0 +1,90 @@
// 功能描述:工单详情动作确认、失败保留、重试刷新与终态隐藏回归。
// 版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/data/repositories/client_repository.dart';
import 'package:user_app/data/services/api_client.dart';
import 'package:user_app/domain/models/client_models.dart';
import 'package:user_app/ui/features/tickets/ticket_detail_page.dart';
class _Tickets extends ClientRepository {
_Tickets() : super(ApiClient(() => '', baseUrl: 'https://api.example.com'));
int calls = 0, writes = 0;
bool fail = false;
List<String> actions = ['cancel', 'confirm'];
@override
Future<ClientRecord> ticket(String identity) async {
calls++;
return ClientRecord(
identity: identity,
title: 'TK测试',
subtitle: '',
raw: {
'status_name': actions.isEmpty ? '已完成' : '待确认',
'allowed_actions': actions,
'description': '阀门异响',
'category': 'repair',
'result': '已更换密封件',
'created_at': '2026-09-07T01:00:00Z',
'address': '测试路88号',
},
);
}
@override
Future<void> cancelTicket(String identity) async => _write();
@override
Future<void> confirmTicket(String identity) async => _write();
void _write() {
writes++;
if (fail) throw const ApiException(500, '网络异常,请重试');
actions = [];
}
}
void main() {
for (final cancel in [true, false]) {
testWidgets('${cancel ? '取消' : '完成'}必须确认,失败后允许幂等重试', (tester) async {
final repository = _Tickets();
await tester.pumpWidget(
MaterialApp(
home: TicketDetailPage(repository: repository, identity: 'owned'),
),
);
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text('已更换密封件'),
250,
scrollable: find.byType(Scrollable).first,
);
expect(find.text('已更换密封件'), findsOneWidget);
final action = find.text(cancel ? '取消工单' : '确认处理完成');
await tester.scrollUntilVisible(action, 200, scrollable: find.byType(Scrollable).first);
await tester.tap(action);
await tester.pumpAndSettle();
await tester.tap(find.text('返回'));
await tester.pumpAndSettle();
expect(repository.writes, 0);
repository.fail = true;
await tester.tap(action);
await tester.pumpAndSettle();
await tester.tap(find.text(cancel ? '确认取消' : '确认完成'));
await tester.pumpAndSettle();
expect(repository.writes, 1);
expect(find.text('网络异常,请重试'), findsOneWidget);
expect(action, findsOneWidget);
repository.fail = false;
// 等待错误提示消失,避免遮挡底部操作按钮。
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
await tester.tap(action);
await tester.pumpAndSettle();
await tester.tap(find.text(cancel ? '确认取消' : '确认完成'));
await tester.pumpAndSettle();
expect(repository.writes, 2);
expect(repository.calls, 2);
expect(find.text('取消工单'), findsNothing);
expect(find.text('确认处理完成'), findsNothing);
});
}
}

View File

@@ -0,0 +1,82 @@
// 功能描述:验证用气统计仅展示权威数据并保留缺失能力提示;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/ui/features/profile/usage_statistics_page.dart';
import 'package:user_app/domain/models/usage_statistics.dart';
import '../support/a1_fixture.dart';
void main() {
testWidgets('日期确认传入查询并在切换周期时保留,取消不查询', (tester) async {
final repository = _DateRepository();
await tester.pumpWidget(MaterialApp(
home: UsageStatisticsPage(repository: repository),
));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('选择日期'));
await tester.pumpAndSettle();
await tester.tap(find.text('取消'));
await tester.pumpAndSettle();
expect(repository.anchors.length, 1);
await tester.tap(find.byTooltip('选择日期'));
await tester.pumpAndSettle();
await tester.tap(find.text('确定'));
await tester.pumpAndSettle();
expect(repository.anchors.last, DateUtils.dateOnly(DateTime.now()));
await tester.tap(find.text('年'));
await tester.pumpAndSettle();
expect(repository.anchors.length, 3);
expect(repository.anchors.last, repository.anchors[1]);
});
testWidgets('用气统计展示口径、趋势和明细', (tester) async {
await tester.pumpWidget(
MaterialApp(home: UsageStatisticsPage(repository: A1FixtureRepository())),
);
await tester.pumpAndSettle();
expect(find.text('用气统计'), findsOneWidget);
expect(find.textContaining('厨房15kg气瓶'), findsOneWidget);
expect(find.text('本期用气总览'), findsOneWidget);
expect(find.text('每日用气量(kg)'), findsOneWidget);
await tester.scrollUntilVisible(
find.text('用气明细'),
300,
scrollable: find.byType(Scrollable).first,
);
expect(find.text('安全趋势暂未开放'), findsOneWidget);
await tester.ensureVisible(find.text('用气明细'));
await tester.pumpAndSettle();
await tester.tap(find.text('用气明细'));
await tester.pumpAndSettle();
expect(find.text('09月30日'), findsWidgets);
expect(find.text('0.230 kg'), findsOneWidget);
});
testWidgets('日周月年切换触发重新读取', (tester) async {
await tester.pumpWidget(
MaterialApp(home: UsageStatisticsPage(repository: A1FixtureRepository())),
);
await tester.pumpAndSettle();
await tester.tap(find.text('年'));
await tester.pumpAndSettle();
expect(find.text('本期用气总览'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
// 记录真实页面传给数据层的日期,避免仅验证弹窗是否出现。
class _DateRepository extends A1FixtureRepository {
final anchors = <DateTime?>[];
@override
Future<UsageStatistics> usageStatistics({
required String deviceIdentity,
String period = 'month',
DateTime? anchor,
}) {
anchors.add(anchor);
return super.usageStatistics(
deviceIdentity: deviceIdentity, period: period, anchor: anchor,
);
}
}

View File

@@ -0,0 +1,52 @@
// 功能描述:验证我的记录聚合真实用气与报修数据,并正确区分首期缺项和二期能力;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/ui/features/profile/user_records_page.dart';
import '../support/a1_fixture.dart';
void main() {
testWidgets('我的记录展示真实记录并可筛选', (tester) async {
await tester.pumpWidget(
MaterialApp(home: UserRecordsPage(repository: A1FixtureRepository())),
);
await tester.pumpAndSettle();
expect(find.text('我的记录'), findsOneWidget);
await tester.scrollUntilVisible(
find.textContaining('15kg家用液化气配送'),
250,
scrollable: find.byType(Scrollable).first,
);
expect(find.textContaining('15kg家用液化气配送'), findsOneWidget);
expect(find.text('报修工单'), findsOneWidget);
await tester.tap(find.text('筛选'));
await tester.pumpAndSettle();
await tester.tap(find.text('报修记录').last);
await tester.pumpAndSettle();
expect(find.text('报修工单'), findsOneWidget);
expect(find.textContaining('15kg家用液化气配送'), findsNothing);
});
testWidgets('首期缺项和二期入口显示不同提示', (tester) async {
await tester.pumpWidget(
MaterialApp(home: UserRecordsPage(repository: A1FixtureRepository())),
);
await tester.pumpAndSettle();
await tester.tap(find.text('角阀操作'));
await tester.pumpAndSettle();
expect(find.text('角阀操作'), findsNWidgets(2));
expect(find.text('该功能暂未开放,目前无法使用。'), findsOneWidget);
await tester.tap(find.text('知道了'));
await tester.pumpAndSettle();
await tester.tap(find.text('发票记录'));
await tester.pumpAndSettle();
expect(find.text('发票记录'), findsNWidgets(2));
expect(find.text('该功能将在后续版本开放,敬请期待。'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}

View File

@@ -0,0 +1,51 @@
// 功能描述:钱包隐私开关、真实空错状态、账单筛选和详情;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/ui/core/app_theme.dart';
import 'package:user_app/ui/features/wallet/wallet_page.dart';
import 'package:user_app/ui/features/wallet/wallet_bills_page.dart';
import '../support/a1_fixture.dart';
void main() {
testWidgets('金额隐藏覆盖余额与最近账单,账单失败可独立重试', (tester) async {
final repo = A1FixtureRepository()..fail = true;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: WalletPage(repository: repo),
),
);
await tester.pumpAndSettle();
expect(find.text('¥568.00'), findsOneWidget);
await tester.tap(find.byTooltip('隐藏金额'));
await tester.pumpAndSettle();
expect(find.text('¥568.00'), findsNothing);
await tester.scrollUntilVisible(
find.text('账单加载失败,点击重试'),
250,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
repo.fail = false;
await tester.tap(find.text('账单加载失败,点击重试'));
await tester.pumpAndSettle();
expect(find.text('-¥228.00'), findsNothing);
expect(find.text('气瓶订单'), findsOneWidget);
});
testWidgets('账单筛选只显示对应方向并能查看记账详情', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: WalletBillsPage(repository: A1FixtureRepository()),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('收入'));
await tester.pumpAndSettle();
expect(find.text('气瓶订单'), findsNothing);
expect(find.text('+¥59.00'), findsOneWidget);
await tester.tap(find.text('退款入账'));
await tester.pumpAndSettle();
expect(find.text('记账后余额:¥568.00'), findsOneWidget);
});
}

View File

@@ -0,0 +1,40 @@
// 功能描述:提现页面真实校验、二次确认和状态语义测试;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/ui/core/app_theme.dart';
import 'package:user_app/ui/features/wallet/withdrawal_page.dart';
import '../support/a1_fixture.dart';
void main() {
testWidgets('提现展示真实可提余额并在二次确认后提交', (tester) async {
final repo = A1FixtureRepository();
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: WithdrawalPage(repository: repo),
),
);
await tester.pumpAndSettle();
expect(find.text('¥568.00'), findsOneWidget);
await tester.enterText(find.byKey(const Key('withdrawal-amount')), '50');
await tester.scrollUntilVisible(
find.text('支付密码验证'),
260,
scrollable: find.byType(Scrollable).first,
);
await tester.enterText(find.byKey(const Key('withdrawal-password')), '123456');
await tester.scrollUntilVisible(
find.text('提交提现申请'),
260,
scrollable: find.byType(Scrollable).first,
);
await tester.tap(find.text('提交提现申请'));
await tester.pumpAndSettle();
expect(find.text('确认提现申请'), findsOneWidget);
await tester.tap(find.text('确认提交'));
await tester.pumpAndSettle();
expect(repo.withdrawalCalls, 1);
expect(find.textContaining('提现申请已提交'), findsOneWidget);
});
}