Files
jiu/client/test/stock_in_screen_test.dart
T
wangjia 9f94cc97b7 fix(test): 修复测试数据库 schema 和 Flutter 测试与屏幕重构的兼容性
- backend/testutil/setup.go: SQLite shops 表补充 business_hours 列
- stock_in_screen_test: 入库管理双 Tab 重构后,draft/pending 测试改为先切换到「入库审核」Tab
- stock_out_insufficient_stock_flow_test: Inventory 构造参数 productUnit→unit,补充必填 id
- inventory_repository_test: mock 数据改为平铺字段格式,补充必填 id
- docs: 新增 NAS + Gitea 部署文档(Runner 设置、Shadowrocket 中继说明)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:48:21 +08:00

402 lines
13 KiB
Dart

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/models/stock_in.dart';
import 'package:jiu_client/providers/stock_in_provider.dart';
import 'package:jiu_client/screens/stock_in/stock_in_list_screen.dart';
// ---------------------------------------------------------------------------
// Fake notifier
// ---------------------------------------------------------------------------
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);
}
@override
void reload() {}
@override
void setPage(int page) {}
@override
void setStatus(String status) {}
@override
void setDateRange(String? startDate, String? endDate) {}
@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 {}
}
/// A notifier that stays permanently in loading state (build never completes).
class _FakeLoadingStockInNotifier extends StockInListNotifier {
@override
Future<PageResult<StockInOrder>> build() {
// Never completes — keeps the widget in CircularProgressIndicator state.
return Completer<PageResult<StockInOrder>>().future;
}
@override
void reload() {}
@override
void setPage(int page) {}
@override
void setStatus(String status) {}
@override
void setDateRange(String? startDate, String? endDate) {}
@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 {}
}
/// Build a testable app. GoRouter is needed because the screen uses
/// `context.go('/stock-in/new')`.
Widget _buildApp(AsyncValue<PageResult<StockInOrder>> state) {
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: [
stockInListProvider.overrideWith(() => _FakeStockInNotifier(state)),
],
child: MaterialApp.router(routerConfig: router),
);
}
Widget _buildLoadingApp() {
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: [
stockInListProvider.overrideWith(() => _FakeLoadingStockInNotifier()),
],
child: MaterialApp.router(routerConfig: router),
);
}
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',
);
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
group('StockInListScreen', () {
testWidgets('shows CircularProgressIndicator while loading', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
await tester.pumpWidget(_buildLoadingApp());
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('shows error message and retry button on failure', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
await tester.pumpWidget(
_buildApp(AsyncValue.error('连接超时', StackTrace.empty)),
);
await tester.pump();
expect(find.text('暂无数据,网络不可用'), findsOneWidget);
expect(find.text('重试'), findsOneWidget);
});
testWidgets('shows 暂无入库单 when list is empty', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
const empty =
PageResult<StockInOrder>(data: [], total: 0, page: 1, pageSize: 20);
await tester.pumpWidget(_buildApp(const AsyncValue.data(empty)));
await tester.pump();
expect(find.text('暂无入库单'), findsOneWidget);
});
testWidgets('shows order list with order number and supplier', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
final orders = [
_makeOrder(id: 1, orderNo: 'SI2024010001', partnerName: '张家酒水'),
_makeOrder(id: 2, orderNo: 'SI2024010002', partnerName: '李记酒行'),
];
final result =
PageResult<StockInOrder>(data: orders, total: 2, page: 1, pageSize: 20);
await tester.pumpWidget(_buildApp(AsyncValue.data(result)));
await tester.pump();
// draft orders only appear in the "入库审核" tab (index 1)
await tester.tap(find.text('入库审核'));
await tester.pumpAndSettle();
expect(find.text('SI2024010001'), findsOneWidget);
expect(find.text('SI2024010002'), findsOneWidget);
expect(find.text('张家酒水'), findsOneWidget);
expect(find.text('李记酒行'), findsOneWidget);
});
testWidgets('shows 新建入库单 button in toolbar', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
const empty =
PageResult<StockInOrder>(data: [], total: 0, page: 1, pageSize: 20);
await tester.pumpWidget(_buildApp(const AsyncValue.data(empty)));
await tester.pump();
// "新建入库审核单" button only appears in the "入库审核" tab (index 1)
await tester.tap(find.text('入库审核'));
await tester.pumpAndSettle();
expect(find.text('新建入库审核单'), findsOneWidget);
});
testWidgets('tapping 新建入库单 navigates to new order route', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
const empty =
PageResult<StockInOrder>(data: [], total: 0, page: 1, pageSize: 20);
await tester.pumpWidget(_buildApp(const AsyncValue.data(empty)));
await tester.pump();
// button only exists in the "入库审核" tab (index 1)
await tester.tap(find.text('入库审核'));
await tester.pumpAndSettle();
await tester.tap(find.text('新建入库审核单'));
await tester.pumpAndSettle();
expect(find.text('new order form'), findsOneWidget);
});
testWidgets('draft order shows 提交 button', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
final orders = [_makeOrder(id: 1, status: 'draft')];
final result =
PageResult<StockInOrder>(data: orders, total: 1, page: 1, pageSize: 20);
await tester.pumpWidget(_buildApp(AsyncValue.data(result)));
await tester.pump();
// draft orders only appear in the "入库审核" tab (index 1)
await tester.tap(find.text('入库审核'));
await tester.pumpAndSettle();
expect(find.text('提交'), findsOneWidget);
});
testWidgets('pending order shows 通过 and 拒绝 buttons', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
final orders = [_makeOrder(id: 1, status: 'pending')];
final result =
PageResult<StockInOrder>(data: orders, total: 1, page: 1, pageSize: 20);
await tester.pumpWidget(_buildApp(AsyncValue.data(result)));
await tester.pump();
// pending orders only appear in the "入库审核" tab (index 1)
await tester.tap(find.text('入库审核'));
await tester.pumpAndSettle();
expect(find.byKey(const Key('btn_approve_1')), findsOneWidget);
expect(find.byKey(const Key('btn_reject_1')), findsOneWidget);
});
testWidgets('approved order shows no action buttons', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
final orders = [_makeOrder(id: 1, status: 'approved')];
final result =
PageResult<StockInOrder>(data: orders, total: 1, page: 1, pageSize: 20);
await tester.pumpWidget(_buildApp(AsyncValue.data(result)));
await tester.pump();
expect(find.byKey(const Key('btn_approve_1')), findsNothing);
expect(find.byKey(const Key('btn_reject_1')), findsNothing);
expect(find.text('提交'), findsNothing);
});
testWidgets('tapping approve shows confirmation dialog', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
final orders = [
_makeOrder(id: 1, status: 'pending', orderNo: 'SI2024010001')
];
final result =
PageResult<StockInOrder>(data: orders, total: 1, page: 1, pageSize: 20);
await tester.pumpWidget(_buildApp(AsyncValue.data(result)));
await tester.pump();
// pending orders only appear in the "入库审核" tab (index 1)
await tester.tap(find.text('入库审核'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('btn_approve_1')));
await tester.pumpAndSettle();
expect(find.text('审核确认'), findsOneWidget);
expect(find.textContaining('SI2024010001'), findsAtLeastNWidgets(1));
expect(find.text('通过'), findsAtLeastNWidgets(1));
});
testWidgets('tapping reject shows confirmation dialog', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
final orders = [
_makeOrder(id: 1, status: 'pending', orderNo: 'SI2024010001')
];
final result =
PageResult<StockInOrder>(data: orders, total: 1, page: 1, pageSize: 20);
await tester.pumpWidget(_buildApp(AsyncValue.data(result)));
await tester.pump();
// pending orders only appear in the "入库审核" tab (index 1)
await tester.tap(find.text('入库审核'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('btn_reject_1')));
await tester.pumpAndSettle();
expect(find.text('拒绝确认'), findsOneWidget);
expect(find.textContaining('SI2024010001'), findsAtLeastNWidgets(1));
expect(find.text('拒绝'), findsAtLeastNWidgets(1));
});
testWidgets('shows status filter dropdown in first tab toolbar', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
const empty =
PageResult<StockInOrder>(data: [], total: 0, page: 1, pageSize: 20);
await tester.pumpWidget(_buildApp(const AsyncValue.data(empty)));
await tester.pump();
await tester.tap(find.text('入库单'));
await tester.pumpAndSettle();
expect(find.text('全部状态'), findsOneWidget);
});
testWidgets('shows total record count in pagination bar', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
final orders = [_makeOrder(id: 1)];
final result =
PageResult<StockInOrder>(data: orders, total: 35, page: 1, pageSize: 20);
await tester.pumpWidget(_buildApp(AsyncValue.data(result)));
await tester.pump();
expect(find.textContaining('共 35 条记录'), findsOneWidget);
});
});
}