Files
jiu/client/test/stock_in_screen_test.dart
T
wangjia a3a722689e feat(client): 出入库列表按原型重建(KPI/详情抽屉/详细搜索/工具栏/选中复制/窗口尺寸)
- 列表重建:KPI 卡、状态+时间列、操作改眼睛开右侧详情抽屉、重置右置
- 详情抽屉(order_detail_drawer) 100% 还原原型 + SelectionArea 可选中复制
- 详细搜索弹窗:ComboSearchField/滚轮日期、字段重构、白底盒子按钮
- 工具栏:搜索框×清除+占位精简、供应商/客户下拉取全部、状态菜单收窄
- 加载不白屏(半透明遮罩)、输入框与下拉等高、windows/macOS 默认窗口尺寸调大

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 08:48:01 +08:00

240 lines
8.3 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:jiu_client/core/models/page_result.dart';
import 'package:jiu_client/core/theme/themes.dart';
import 'package:jiu_client/models/license.dart';
import 'package:jiu_client/models/stock_in.dart';
import 'package:jiu_client/providers/license_provider.dart';
import 'package:jiu_client/providers/stock_in_provider.dart';
import 'package:jiu_client/screens/stock_in/stock_in_list_screen.dart';
/// 入库列表屏(照原型重建:单视图无 tab)的行为测试。
/// WriteGuard 内层 watch licenseProvider;覆写成同步 normal 授权,避免真实网络请求。
class _FakeLicenseNotifier extends LicenseNotifier {
@override
Future<LicenseInfo?> build() async => const LicenseInfo(
id: 1,
type: 'annual',
isActive: true,
maxDevices: 3,
phase: 'normal',
);
}
class _FakeStockInNotifier extends StockInListNotifier {
final AsyncValue<PageResult<StockInOrder>> _fixed;
_FakeStockInNotifier(this._fixed);
@override
Future<PageResult<StockInOrder>> build() async {
state = _fixed;
return state.value ??
const PageResult(data: [], total: 0, page: 1, pageSize: 20);
}
// 交互 setter 全部 no-op,避免命中真实 repo。
@override
void reload() {}
@override
void setPage(int page) {}
@override
void setPageSize(int pageSize) {}
@override
void setStatus(String status) {}
@override
void setKeyword(String keyword) {}
@override
void setDateRange(String? startDate, String? endDate) {}
@override
void setDetail(Map<String, String> detail) {}
@override
Future<void> createOrder(Map<String, dynamic> data) async {}
@override
Future<void> submitOrder(int id) async {}
@override
Future<void> approveOrder(int id) async {}
@override
Future<void> rejectOrder(int id) async {}
}
/// 永远停在 loadingbuild 不完成)。
class _FakeLoadingStockInNotifier extends StockInListNotifier {
@override
Future<PageResult<StockInOrder>> build() =>
Completer<PageResult<StockInOrder>>().future;
@override
void reload() {}
}
/// 持久化 provider 里已有残留关键词 'sfs'(模拟用户上次搜索后未清)。
/// build 仍返回数据——用来验证「进页面把残留筛选回填到 UI」而非隐形丢数据。
class _StaleKeywordStockInNotifier extends StockInListNotifier {
@override
Future<PageResult<StockInOrder>> build() async {
state = AsyncValue.data(
PageResult(data: [_makeOrder()], total: 1, page: 1, pageSize: 20));
return state.value!;
}
@override
String get currentKeyword => 'sfs';
@override
void reload() {}
@override
void setStatus(String status) {}
@override
void setKeyword(String keyword) {}
@override
void setDateRange(String? startDate, String? endDate) {}
@override
void setDetail(Map<String, String> detail) {}
}
Widget _appWith(Override overrideNotifier) {
final router = GoRouter(
initialLocation: '/stock-in',
routes: [
GoRoute(
path: '/stock-in',
builder: (_, __) => const Scaffold(body: StockInListScreen()),
),
GoRoute(
path: '/stock-in/new',
builder: (_, __) => const Scaffold(body: Text('new order form')),
),
],
);
return ProviderScope(
overrides: [
licenseProvider.overrideWith(() => _FakeLicenseNotifier()),
overrideNotifier,
],
child: MaterialApp.router(theme: buildTheme('a'), routerConfig: router),
);
}
Widget _buildApp(AsyncValue<PageResult<StockInOrder>> state) => _appWith(
stockInListProvider.overrideWith(() => _FakeStockInNotifier(state)));
Widget _buildLoadingApp() => _appWith(
stockInListProvider.overrideWith(() => _FakeLoadingStockInNotifier()));
StockInOrder _makeOrder({
int id = 1,
String orderNo = 'SI2024010001',
String status = 'draft',
String? partnerName = '张家酒水',
String? warehouseName = '主仓库',
}) =>
StockInOrder(
id: id,
orderNo: orderNo,
warehouseId: 1,
warehouseName: warehouseName,
partnerName: partnerName,
status: status,
totalAmount: 1000.0,
orderDate: '2024-01-10',
);
void _bigView(WidgetTester tester) {
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
}
void main() {
group('StockInListScreen(单视图重建版)', () {
testWidgets('loading 显示进度圈', (tester) async {
_bigView(tester);
await tester.pumpWidget(_buildLoadingApp());
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('error 显示「加载失败」+ 重试', (tester) async {
_bigView(tester);
await tester.pumpWidget(
_buildApp(AsyncValue.error('连接超时', StackTrace.empty)));
await tester.pumpAndSettle();
expect(find.textContaining('加载失败'), findsOneWidget);
expect(find.text('重试'), findsOneWidget);
});
testWidgets('空列表显示空态提示', (tester) async {
_bigView(tester);
const empty =
PageResult<StockInOrder>(data: [], total: 0, page: 1, pageSize: 20);
await tester.pumpWidget(_buildApp(const AsyncValue.data(empty)));
await tester.pumpAndSettle();
expect(find.textContaining('没有匹配的入库单'), findsOneWidget);
});
testWidgets('列表直接展示单号与供应商(无 tab', (tester) async {
_bigView(tester);
final orders = [
_makeOrder(id: 1, orderNo: 'SI2024010001', partnerName: '张家酒水'),
_makeOrder(id: 2, orderNo: 'SI2024010002', partnerName: '李记酒行'),
];
await tester.pumpWidget(_buildApp(
AsyncValue.data(PageResult(data: orders, total: 2, page: 1, pageSize: 20))));
await tester.pumpAndSettle();
expect(find.text('SI2024010001'), findsOneWidget);
expect(find.text('李记酒行'), findsOneWidget);
});
testWidgets('工具栏有「新增入库」并可导航', (tester) async {
_bigView(tester);
const empty =
PageResult<StockInOrder>(data: [], total: 0, page: 1, pageSize: 20);
await tester.pumpWidget(_buildApp(const AsyncValue.data(empty)));
await tester.pumpAndSettle();
final newBtn = find.text('新增入库');
expect(newBtn, findsOneWidget);
await tester.tap(newBtn);
await tester.pumpAndSettle();
expect(find.text('new order form'), findsOneWidget);
});
// 操作已改为「行内只放眼睛,审核/退单/打印移入右侧详情抽屉」。
// 抽屉内容与动作布局由 order_detail_drawer 组件保真验证,这里只验行内行为。
testWidgets('各状态行内只显示眼睛(详情入口),无内联审核按钮', (tester) async {
_bigView(tester);
for (final status in ['draft', 'pending', 'approved']) {
final orders = [_makeOrder(id: 1, status: status)];
await tester.pumpWidget(_buildApp(AsyncValue.data(
PageResult(data: orders, total: 1, page: 1, pageSize: 20))));
await tester.pumpAndSettle();
expect(find.byTooltip('详情'), findsOneWidget); // 眼睛图标
expect(find.byKey(const Key('btn_approve_1')), findsNothing);
expect(find.byKey(const Key('btn_reject_1')), findsNothing);
expect(find.text('提交'), findsNothing);
}
});
testWidgets('进页面把 provider 残留关键词回填到搜索框(不隐形过滤)',
(tester) async {
_bigView(tester);
await tester.pumpWidget(_appWith(
stockInListProvider.overrideWith(() => _StaleKeywordStockInNotifier())));
await tester.pumpAndSettle();
// 残留 'sfs' 必须显示在搜索框里,用户看得见可清除;
// 修复前入库屏无 initState 回填 → 框空但仍按 'sfs' 过滤 → 列表恒空。
expect(find.text('sfs'), findsOneWidget);
});
testWidgets('分页栏显示总数', (tester) async {
_bigView(tester);
final orders = [_makeOrder(id: 1)];
await tester.pumpWidget(_buildApp(
AsyncValue.data(PageResult(data: orders, total: 35, page: 1, pageSize: 20))));
await tester.pumpAndSettle();
expect(find.textContaining('共 35'), findsOneWidget);
});
});
}