test(client): 出入库状态多选语义与默认仓库填充回归用例

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-14 11:14:32 +08:00
parent f555b99565
commit 9f620e9fc7
3 changed files with 603 additions and 0 deletions
+120
View File
@@ -2,13 +2,18 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:jiu_client/core/api/api_client.dart';
import 'package:jiu_client/core/auth/auth_state.dart';
import 'package:jiu_client/core/theme/themes.dart';
import 'package:jiu_client/models/product_option.dart';
import 'package:jiu_client/models/stock_in.dart';
import 'package:jiu_client/models/warehouse.dart';
import 'package:jiu_client/providers/stock_in_provider.dart';
import 'package:jiu_client/providers/warehouse_provider.dart';
import 'package:jiu_client/providers/product_option_provider.dart';
import 'package:jiu_client/repositories/stock_in_repository.dart';
import 'package:jiu_client/screens/stock_in/stock_in_form_screen.dart';
import 'package:jiu_client/widgets/ds/grid_combo_cell.dart';
class _FakeWarehouses extends WarehouseListNotifier {
@override
@@ -16,6 +21,23 @@ class _FakeWarehouses extends WarehouseListNotifier {
const [Warehouse(id: 1, name: '主仓', isDefault: true)];
}
/// 可配置仓库列表的假 notifier(T5 默认仓填充回归用)。
class _ConfigurableWarehouses extends WarehouseListNotifier {
final List<Warehouse> _list;
_ConfigurableWarehouses(this._list);
@override
Future<List<Warehouse>> build() async => _list;
}
/// 编辑模式假仓库:get() 返回固定单据(供 _loadEditOrder 消费)。
class _FakeStockInRepoForEdit extends StockInRepository {
final StockInOrder order;
_FakeStockInRepoForEdit(this.order) : super(ApiClient(token: 't'));
@override
Future<StockInOrder> get(int id) async => order;
}
class _FakeNames extends ProductNameListNotifier {
@override
Future<List<ProductNameOption>> build() async => const [
@@ -85,4 +107,102 @@ void main() {
expect(find.text('选择商品名称'), findsNothing,
reason: 'hint should be replaced by the picked name');
});
// ── T5:新建入库单默认填充默认仓库(2026-07-14)──────────────────────
//
// _initWarehouseDefault 语义:仓库列表加载完成后,
// isDefault 的仓 优先;无默认时仅剩 1 个仓才取它;否则保持未选(null)。
// 编辑模式不受影响(_loadEditOrder 已从单据自身仓库回填)。
int? _warehouseSelectedId(WidgetTester tester) => tester
.widgetList<GridComboCell>(find.byType(GridComboCell))
.firstWhere((c) => c.hint == '选择仓库…')
.selectedId;
Future<void> _pumpForm(WidgetTester tester, List<Override> overrides,
{int? editOrderId}) async {
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
await tester.pumpWidget(ProviderScope(
overrides: overrides,
child: MaterialApp(
theme: buildTheme('a'),
home: Scaffold(body: StockInFormScreen(editOrderId: editOrderId)),
),
));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// _initWarehouseDefault / _loadEditOrder 都是异步(await provider.future),
// 多 pump 几拍确保 setState 落地。
await tester.pump(const Duration(milliseconds: 100));
}
testWidgets('新建入库单:有默认仓(isDefault=true)时自动选中默认仓',
(tester) async {
await _pumpForm(tester, [
warehouseListProvider.overrideWith(() => _FakeWarehouses()), // 主仓 isDefault
productNameListProvider.overrideWith(() => _FakeNames()),
productSeriesListProvider.overrideWith(() => _FakeSeries()),
productSpecListProvider.overrideWith(() => _FakeSpecs()),
isReadonlyProvider.overrideWithValue(false),
]);
expect(_warehouseSelectedId(tester), 1);
});
testWidgets('新建入库单:无默认仓且多仓时保持未选', (tester) async {
await _pumpForm(tester, [
warehouseListProvider.overrideWith(() => _ConfigurableWarehouses(const [
Warehouse(id: 1, name: '主仓', isDefault: false),
Warehouse(id: 2, name: '分仓', isDefault: false),
])),
productNameListProvider.overrideWith(() => _FakeNames()),
productSeriesListProvider.overrideWith(() => _FakeSeries()),
productSpecListProvider.overrideWith(() => _FakeSpecs()),
isReadonlyProvider.overrideWithValue(false),
]);
expect(_warehouseSelectedId(tester), isNull);
});
testWidgets('新建入库单:无默认仓但仅有 1 个仓时自动选中该仓', (tester) async {
await _pumpForm(tester, [
warehouseListProvider.overrideWith(() => _ConfigurableWarehouses(const [
Warehouse(id: 7, name: '唯一仓', isDefault: false),
])),
productNameListProvider.overrideWith(() => _FakeNames()),
productSeriesListProvider.overrideWith(() => _FakeSeries()),
productSpecListProvider.overrideWith(() => _FakeSpecs()),
isReadonlyProvider.overrideWithValue(false),
]);
expect(_warehouseSelectedId(tester), 7);
});
testWidgets('编辑模式:不被默认仓覆盖,仍显示单据原仓库', (tester) async {
const order = StockInOrder(
id: 99,
orderNo: 'SI2024010099',
warehouseId: 2, // 单据原仓库 = 分仓(非默认仓)
status: 'draft',
items: [],
);
await _pumpForm(
tester,
[
// 主仓(id=1isDefault=true,若默认仓填充逻辑误在编辑模式生效会把它选中,
// 但单据自身仓库是 id=2,回归断言二者不一致时以单据为准。
warehouseListProvider.overrideWith(() => _ConfigurableWarehouses(const [
Warehouse(id: 1, name: '主仓', isDefault: true),
Warehouse(id: 2, name: '分仓', isDefault: false),
])),
stockInRepositoryProvider
.overrideWithValue(_FakeStockInRepoForEdit(order)),
productNameListProvider.overrideWith(() => _FakeNames()),
productSeriesListProvider.overrideWith(() => _FakeSeries()),
productSpecListProvider.overrideWith(() => _FakeSpecs()),
isReadonlyProvider.overrideWithValue(false),
],
editOrderId: 99,
);
expect(_warehouseSelectedId(tester), 2);
});
}
@@ -0,0 +1,279 @@
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/api/api_client.dart';
import 'package:jiu_client/core/models/page_result.dart';
import 'package:jiu_client/core/theme/themes.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/repositories/stock_in_repository.dart';
import 'package:jiu_client/screens/stock_in/stock_in_list_screen.dart';
import 'package:jiu_client/widgets/ds/ds_atoms.dart';
/// 出入库状态多选语义回归(T4)——深测入库屏,出库屏走冒烟(见
/// stock_out_status_filter_smoke_test.dart),两屏 initState 归一逻辑同构。
///
/// 背景:状态筛选 2026-07-14 改多选 + 默认「草稿+待审核」,initState 里逗号拆分
/// 逐值校验归一的逻辑历史上出过回归(默认值刚设就被误清)。这里只测逻辑(provider
/// 状态归一 / 多选交互结果),不碰像素。
/// 授权检查覆写:同步返回正常授权,避免 WriteGuard 命中真实网络请求。
class _FakeLicenseNotifier extends LicenseNotifier {
@override
Future<LicenseInfo?> build() async => const LicenseInfo(
id: 1,
type: 'annual',
isActive: true,
maxDevices: 3,
phase: 'normal',
);
}
/// 记录 provider 收到的 setStatus/setDetail 调用参数的假 notifier
/// build() 直接返回空列表数据、其余交互 setter 全部 no-op 避免命中真实网络。
/// [initialStatus] 模拟「进屏前 provider 里已生效的状态筛选」,
/// 用于驱动 initState 归一分支(关键词/详细搜索本组测试不涉及,固定空)。
class _RecordingStockInNotifier extends StockInListNotifier {
final String initialStatus;
String? lastSetStatus;
Map<String, String>? lastSetDetail;
_RecordingStockInNotifier({this.initialStatus = 'draft,pending'});
@override
Future<PageResult<StockInOrder>> build() async {
state = const AsyncValue.data(
PageResult<StockInOrder>(data: [], total: 0, page: 1, pageSize: 20));
return state.value!;
}
@override
String get currentStatus => initialStatus;
@override
String get currentKeyword => '';
@override
Map<String, String> get currentDetail => const {};
@override
void reload() {}
@override
void setPage(int page) {}
@override
void setPageSize(int pageSize) {}
@override
void setKeyword(String keyword) {}
@override
void setDateRange(String? startDate, String? endDate) {}
@override
void setStatus(String status) {
lastSetStatus = status;
}
@override
void setDetail(Map<String, String> detail) {
lastSetDetail = detail;
}
}
Widget _appWith(_RecordingStockInNotifier notifier) {
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()),
stockInListProvider.overrideWith(() => notifier),
],
child: MaterialApp.router(theme: buildTheme('a'), routerConfig: router),
);
}
void _bigView(WidgetTester tester) {
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
}
/// 工具栏「状态」DsChip(多选摘要 chip,唯一 label='状态')。
DsChip _statusChip(WidgetTester tester) => tester
.widgetList<DsChip>(find.byType(DsChip))
.firstWhere((c) => c.label == '状态');
/// 统计 list() 调用参数的假仓库,用于 provider 单测(不经过屏幕)。
class _CountingStockInRepo extends StockInRepository {
String? lastStatus;
_CountingStockInRepo() : super(ApiClient(token: 't'));
@override
Future<PageResult<StockInOrder>> list({
String? status,
String? startDate,
String? endDate,
String? keyword,
Map<String, String>? detail,
int page = 1,
int pageSize = 20,
}) async {
lastStatus = status;
return const PageResult<StockInOrder>(
data: [], total: 0, page: 1, pageSize: 20);
}
}
void main() {
group('1. StockInListNotifier 默认状态', () {
test('未做任何设置时 currentStatus == draft,pending,且首次拉取即带上该值', () async {
final repo = _CountingStockInRepo();
final container = ProviderContainer(
overrides: [stockInRepositoryProvider.overrideWithValue(repo)],
);
addTearDown(container.dispose);
await container.read(stockInListProvider.future);
expect(
container.read(stockInListProvider.notifier).currentStatus,
'draft,pending');
expect(repo.lastStatus, 'draft,pending');
});
});
group('2. initState 归一逻辑', () {
testWidgets('合法多值 pending,draft(顺序不同)原样保留为 2 项,不回写 provider',
(tester) async {
_bigView(tester);
final notifier =
_RecordingStockInNotifier(initialStatus: 'pending,draft');
await tester.pumpWidget(_appWith(notifier));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(_statusChip(tester).value, '2 项');
expect(notifier.lastSetStatus, isNull,
reason: '合法值不应触发归一回写');
});
testWidgets("'' 保留为「全部」(不视为非法、不被重置为默认)", (tester) async {
_bigView(tester);
final notifier = _RecordingStockInNotifier(initialStatus: '');
await tester.pumpWidget(_appWith(notifier));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(_statusChip(tester).value, isNull, reason: '空值=全部,chip 不应显示选中值');
expect(notifier.lastSetStatus, isNull);
});
testWidgets('全部非法(如 foo)→ 归默认 draft,pending 并回写 provider', (tester) async {
_bigView(tester);
final notifier = _RecordingStockInNotifier(initialStatus: 'foo');
await tester.pumpWidget(_appWith(notifier));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(_statusChip(tester).value, '2 项');
expect(notifier.lastSetStatus, 'draft,pending');
});
testWidgets('部分非法(如 foo,draft)→ 按实现语义只保留合法子集,不回写 provider',
(tester) async {
_bigView(tester);
final notifier = _RecordingStockInNotifier(initialStatus: 'foo,draft');
await tester.pumpWidget(_appWith(notifier));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
// 实现里 valid 非空即直接采用该合法子集,不触发 setStatus 纠正——
// 这里按真实代码语义断言,避免测试臆造「归默认」预期。
expect(_statusChip(tester).value, '草稿');
expect(notifier.lastSetStatus, isNull);
});
});
group('3. 多选面板全不选', () {
testWidgets('依次取消草稿+待审核 → provider 收到空串(=全部)', (tester) async {
_bigView(tester);
final notifier = _RecordingStockInNotifier(); // 默认 draft,pending
await tester.pumpWidget(_appWith(notifier));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(_statusChip(tester).value, '2 项');
// 打开状态多选菜单。
await tester.tap(find.byWidgetPredicate(
(w) => w is DsChip && w.label == '状态'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
// .last:菜单项与 chip 摘要值可能同文案(如切到单值后 chip 显示该状态名),
// 菜单项在覆盖层里后挂载,取 .last 精确定位菜单项而非 chip 本身。
await tester.tap(find.text('草稿').last);
await tester.pump();
expect(notifier.lastSetStatus, 'pending');
await tester.tap(find.text('待审核').last);
await tester.pump();
expect(notifier.lastSetStatus, '');
});
});
group('4. KPI 点击覆盖为单值', () {
testWidgets('先多选(默认草稿+待审核)再点「待审核」KPI → 变单值 pending',
(tester) async {
_bigView(tester);
final notifier = _RecordingStockInNotifier(); // 默认多选 draft,pending
await tester.pumpWidget(_appWith(notifier));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(_statusChip(tester).value, '2 项', reason: '起点须为多选态');
await tester.tap(find.text('待审核单数 · 点击筛选'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(notifier.lastSetStatus, 'pending');
expect(_statusChip(tester).value, '待审核');
});
});
group('5. 退单互斥(双向)', () {
testWidgets('选 ret:partial 清主状态集;再选主状态清 ret:*', (tester) async {
_bigView(tester);
final notifier = _RecordingStockInNotifier(); // 默认多选 draft,pending
await tester.pumpWidget(_appWith(notifier));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
await tester.tap(find.byWidgetPredicate(
(w) => w is DsChip && w.label == '状态'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
// 5a:选退单态 → 清空主状态集。
await tester.tap(find.text('部分退单').last);
await tester.pump();
expect(notifier.lastSetStatus, '',
reason: '选中退单伪状态后主状态应清空(互斥)');
expect(notifier.lastSetDetail, {'return_state': 'partial'});
// 5b:再选主状态 → 清掉 return_state。
await tester.tap(find.text('草稿').last);
await tester.pump();
expect(notifier.lastSetStatus, 'draft');
expect(notifier.lastSetDetail, isNot(contains('return_state')));
});
});
}
@@ -0,0 +1,204 @@
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/api/api_client.dart';
import 'package:jiu_client/core/models/page_result.dart';
import 'package:jiu_client/core/theme/themes.dart';
import 'package:jiu_client/models/stock_out.dart';
import 'package:jiu_client/providers/license_provider.dart';
import 'package:jiu_client/providers/stock_out_provider.dart';
import 'package:jiu_client/repositories/stock_out_repository.dart';
import 'package:jiu_client/screens/stock_out/stock_out_list_screen.dart';
import 'package:jiu_client/widgets/ds/ds_atoms.dart';
/// 出库屏状态多选语义冒烟测试(T4)。深测在入库屏
/// stock_in_status_filter_test.dart(两屏 initState 归一逻辑同构,
/// 出库屏只做最小抽样:默认值 + 归一分支 + 一次多选/KPI/退单互斥的组合冒烟)。
class _FakeLicenseNotifier extends LicenseNotifier {
@override
Future<LicenseInfo?> build() async => const LicenseInfo(
id: 1,
type: 'annual',
isActive: true,
maxDevices: 3,
phase: 'normal',
);
}
class _RecordingStockOutNotifier extends StockOutListNotifier {
final String initialStatus;
String? lastSetStatus;
Map<String, String>? lastSetDetail;
_RecordingStockOutNotifier({this.initialStatus = 'draft,pending'});
@override
Future<PageResult<StockOutOrder>> build() async {
state = const AsyncValue.data(
PageResult<StockOutOrder>(data: [], total: 0, page: 1, pageSize: 20));
return state.value!;
}
@override
String get currentStatus => initialStatus;
@override
String get currentKeyword => '';
@override
Map<String, String> get currentDetail => const {};
@override
void reload() {}
@override
void setPage(int page) {}
@override
void setPageSize(int pageSize) {}
@override
void setKeyword(String keyword) {}
@override
void setProductCode(String code) {}
@override
void setDateRange(String? startDate, String? endDate) {}
@override
void setStatus(String status) {
lastSetStatus = status;
}
@override
void setDetail(Map<String, String> detail) {
lastSetDetail = detail;
}
}
class _CountingStockOutRepo extends StockOutRepository {
String? lastStatus;
_CountingStockOutRepo() : super(ApiClient(token: 't'));
@override
Future<PageResult<StockOutOrder>> list({
String? status,
String? startDate,
String? endDate,
String? keyword,
String? productCode,
Map<String, String>? detail,
int page = 1,
int pageSize = 20,
}) async {
lastStatus = status;
return const PageResult<StockOutOrder>(
data: [], total: 0, page: 1, pageSize: 20);
}
}
Widget _appWith(_RecordingStockOutNotifier notifier) {
final router = GoRouter(
initialLocation: '/stock-out',
routes: [
GoRoute(
path: '/stock-out',
builder: (_, __) => const Scaffold(body: StockOutListScreen()),
),
],
);
return ProviderScope(
overrides: [
licenseProvider.overrideWith(() => _FakeLicenseNotifier()),
stockOutListProvider.overrideWith(() => notifier),
],
child: MaterialApp.router(theme: buildTheme('a'), routerConfig: router),
);
}
void _bigView(WidgetTester tester) {
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
}
DsChip _statusChip(WidgetTester tester) => tester
.widgetList<DsChip>(find.byType(DsChip))
.firstWhere((c) => c.label == '状态');
void main() {
test('1. StockOutListNotifier 默认状态 == draft,pending', () async {
final repo = _CountingStockOutRepo();
final container = ProviderContainer(
overrides: [stockOutRepositoryProvider.overrideWithValue(repo)],
);
addTearDown(container.dispose);
await container.read(stockOutListProvider.future);
expect(container.read(stockOutListProvider.notifier).currentStatus,
'draft,pending');
expect(repo.lastStatus, 'draft,pending');
});
testWidgets('2. initState 归一:全部非法 → 归默认并回写;合法多值原样保留',
(tester) async {
_bigView(tester);
final invalid = _RecordingStockOutNotifier(initialStatus: 'foo');
await tester.pumpWidget(_appWith(invalid));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(_statusChip(tester).value, '2 项');
expect(invalid.lastSetStatus, 'draft,pending');
});
testWidgets("2b. initState 归一:'' 保留为全部", (tester) async {
_bigView(tester);
final all = _RecordingStockOutNotifier(initialStatus: '');
await tester.pumpWidget(_appWith(all));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(_statusChip(tester).value, isNull);
expect(all.lastSetStatus, isNull);
});
testWidgets('3+4+5 组合冒烟:多选全清→KPI 覆盖单值→退单互斥双向', (tester) async {
_bigView(tester);
final notifier = _RecordingStockOutNotifier(); // 默认 draft,pending
await tester.pumpWidget(_appWith(notifier));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(_statusChip(tester).value, '2 项');
// 3:多选面板全不选 → provider 收到空串。
await tester.tap(find.byWidgetPredicate(
(w) => w is DsChip && w.label == '状态'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
await tester.tap(find.text('草稿').last);
await tester.pump();
await tester.tap(find.text('待审核').last);
await tester.pump();
expect(notifier.lastSetStatus, '');
// 关闭菜单,回到干净状态。
await tester.tapAt(const Offset(10, 10));
await tester.pump();
// 4KPI 点击覆盖为单值。
await tester.tap(find.text('待审核单数 · 点击筛选'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(notifier.lastSetStatus, 'pending');
expect(_statusChip(tester).value, '待审核');
// 5:退单互斥双向。
await tester.tap(find.byWidgetPredicate(
(w) => w is DsChip && w.label == '状态'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
await tester.tap(find.text('部分退单').last);
await tester.pump();
expect(notifier.lastSetStatus, '', reason: '选中退单态应清空主状态(互斥)');
expect(notifier.lastSetDetail, {'return_state': 'partial'});
await tester.tap(find.text('草稿').last);
await tester.pump();
expect(notifier.lastSetStatus, 'draft');
expect(notifier.lastSetDetail, isNot(contains('return_state')));
});
}