diff --git a/client/test/stock_in_form_pick_test.dart b/client/test/stock_in_form_pick_test.dart index 56d9f63..6c22242 100644 --- a/client/test/stock_in_form_pick_test.dart +++ b/client/test/stock_in_form_pick_test.dart @@ -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 _list; + _ConfigurableWarehouses(this._list); + @override + Future> build() async => _list; +} + +/// 编辑模式假仓库:get() 返回固定单据(供 _loadEditOrder 消费)。 +class _FakeStockInRepoForEdit extends StockInRepository { + final StockInOrder order; + _FakeStockInRepoForEdit(this.order) : super(ApiClient(token: 't')); + + @override + Future get(int id) async => order; +} + class _FakeNames extends ProductNameListNotifier { @override Future> 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(find.byType(GridComboCell)) + .firstWhere((c) => c.hint == '选择仓库…') + .selectedId; + + Future _pumpForm(WidgetTester tester, List 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=1)isDefault=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); + }); } diff --git a/client/test/stock_in_status_filter_test.dart b/client/test/stock_in_status_filter_test.dart new file mode 100644 index 0000000..f71a213 --- /dev/null +++ b/client/test/stock_in_status_filter_test.dart @@ -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 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? lastSetDetail; + + _RecordingStockInNotifier({this.initialStatus = 'draft,pending'}); + + @override + Future> build() async { + state = const AsyncValue.data( + PageResult(data: [], total: 0, page: 1, pageSize: 20)); + return state.value!; + } + + @override + String get currentStatus => initialStatus; + @override + String get currentKeyword => ''; + @override + Map 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 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(find.byType(DsChip)) + .firstWhere((c) => c.label == '状态'); + +/// 统计 list() 调用参数的假仓库,用于 provider 单测(不经过屏幕)。 +class _CountingStockInRepo extends StockInRepository { + String? lastStatus; + _CountingStockInRepo() : super(ApiClient(token: 't')); + + @override + Future> list({ + String? status, + String? startDate, + String? endDate, + String? keyword, + Map? detail, + int page = 1, + int pageSize = 20, + }) async { + lastStatus = status; + return const PageResult( + 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'))); + }); + }); +} diff --git a/client/test/stock_out_status_filter_smoke_test.dart b/client/test/stock_out_status_filter_smoke_test.dart new file mode 100644 index 0000000..3087ebb --- /dev/null +++ b/client/test/stock_out_status_filter_smoke_test.dart @@ -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 build() async => const LicenseInfo( + id: 1, + type: 'annual', + isActive: true, + maxDevices: 3, + phase: 'normal', + ); +} + +class _RecordingStockOutNotifier extends StockOutListNotifier { + final String initialStatus; + + String? lastSetStatus; + Map? lastSetDetail; + + _RecordingStockOutNotifier({this.initialStatus = 'draft,pending'}); + + @override + Future> build() async { + state = const AsyncValue.data( + PageResult(data: [], total: 0, page: 1, pageSize: 20)); + return state.value!; + } + + @override + String get currentStatus => initialStatus; + @override + String get currentKeyword => ''; + @override + Map 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 detail) { + lastSetDetail = detail; + } +} + +class _CountingStockOutRepo extends StockOutRepository { + String? lastStatus; + _CountingStockOutRepo() : super(ApiClient(token: 't')); + + @override + Future> list({ + String? status, + String? startDate, + String? endDate, + String? keyword, + String? productCode, + Map? detail, + int page = 1, + int pageSize = 20, + }) async { + lastStatus = status; + return const PageResult( + 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(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(); + + // 4:KPI 点击覆盖为单值。 + 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'))); + }); +}