Files
jiu/client/test/stock_in_screen_test.dart
T
wangjia 979ca3c83b feat(client): 出库明细成本/售价/利润三列重设计 + 建单利润列 + 入库列头统一
- 出库详情抽屉(管理员):数量/成本价/售价/利润 + 合计金额/合计利润 双行;
  operator 见 售价/小计(服务端抹零+前端隐藏双保险);利润负数 danger
- 出库建单:商品名称下带编码(字体同系列列);金额列→利润列(实时);
  底栏合计利润(仅管理员);进价列仅管理员;提交发新 key(cost_price/sale_price)
- 入库建单列头:进价(单瓶)/ 参考售价 / 总进价
- 退单弹窗金额改售价口径(与冲应收一致);打印保留待定价回退成本兼容
- models 字段随后端消歧改名;golden 重打;新增抽屉双形态 widget 测试

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 12:44:22 +08:00

238 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/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,
costTotal: 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);
});
});
}