test(client): 财务往来单位筛选交互与移动视口防裁剪回归用例
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
// test/finance_partner_filter_test.dart — 财务「往来单位」筛选回归(Task 6 补审查)。
|
||||
//
|
||||
// 覆盖两类回归防护:
|
||||
// 1. 交互行为:桌面 ComboSearchField 路径 + 移动 chip→sheet 路径,均驱动到
|
||||
// financeListProvider 真实的 setPartner → repository.listRecords(partnerId:…)
|
||||
// 请求参数链路;清除(×/「全部往来单位」)后回 null 并触发新一轮请求。
|
||||
// 2. 视口防裁剪:移动财务屏(390 宽)首屏(未手动滚动)渲染后,直接用
|
||||
// RenderBox 探针断言「往来单位」chip 的全局 bounds 落在视口宽度内——
|
||||
// 对齐 task-6-report.md「自审发现」记录的方法:analyze/test 全绿、
|
||||
// widget 树里组件确实存在,但因父级裁剪导致用户实际看不到,
|
||||
// 这类回归只有量像素位置才能抓到。
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.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/models/page_result.dart';
|
||||
import 'package:jiu_client/core/theme/themes.dart';
|
||||
import 'package:jiu_client/models/finance.dart';
|
||||
import 'package:jiu_client/models/partner.dart';
|
||||
import 'package:jiu_client/models/stock_summary.dart';
|
||||
import 'package:jiu_client/providers/finance_provider.dart';
|
||||
import 'package:jiu_client/providers/partner_provider.dart';
|
||||
import 'package:jiu_client/providers/stock_in_provider.dart';
|
||||
import 'package:jiu_client/providers/stock_out_provider.dart';
|
||||
import 'package:jiu_client/repositories/finance_repository.dart';
|
||||
import 'package:jiu_client/screens/finance/finance_screen.dart';
|
||||
import 'package:jiu_client/widgets/combo_search_field.dart';
|
||||
import 'package:jiu_client/widgets/ds/ds_atoms.dart' show DsChip;
|
||||
|
||||
/// 不打真实网络:仅用于满足 FinanceRepository 构造签名,方法全部被覆写。
|
||||
class _DummyApiClient extends ApiClient {
|
||||
_DummyApiClient() : super(token: 'test-token');
|
||||
}
|
||||
|
||||
/// 记录每次 listRecords 调用实际收到的 partnerId 实参——用来断言
|
||||
/// financeListProvider.setPartner 真的把筛选值透传到了请求参数(partner_id)。
|
||||
class _RecordingFinanceRepository extends FinanceRepository {
|
||||
_RecordingFinanceRepository() : super(_DummyApiClient());
|
||||
|
||||
final List<int?> partnerIdCalls = [];
|
||||
|
||||
@override
|
||||
Future<PageResult<FinanceRecord>> listRecords({
|
||||
String? type,
|
||||
String? month,
|
||||
String? startDate,
|
||||
String? endDate,
|
||||
int? partnerId,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
}) async {
|
||||
partnerIdCalls.add(partnerId);
|
||||
return const PageResult(data: [], total: 0, page: 1, pageSize: 50);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PartnerFinanceSummary>> partnerSummary() async => const [];
|
||||
|
||||
@override
|
||||
Future<List<TrendPoint>> trend({int months = 6}) async => const [];
|
||||
}
|
||||
|
||||
/// allPartnersProvider fixture:桌面 ComboSearchField / 移动 sheet 共用同一
|
||||
/// 数据源,固定 2 个往来单位供选择。
|
||||
class _FakeAllPartners extends PartnerListNotifier {
|
||||
@override
|
||||
Future<PageResult<Partner>> build() async => const PageResult(
|
||||
data: [
|
||||
Partner(id: 1, name: '华东酒类批发', type: 'customer'),
|
||||
Partner(id: 2, name: '金樽商贸', type: 'customer'),
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
pageSize: 1000,
|
||||
);
|
||||
}
|
||||
|
||||
List<Override> _overrides(_RecordingFinanceRepository repo) => [
|
||||
financeRepositoryProvider.overrideWith((ref) => repo),
|
||||
allPartnersProvider.overrideWith(() => _FakeAllPartners()),
|
||||
stockOutSummaryProvider.overrideWith(
|
||||
(ref) async => const StockSummary(monthAmount: 0, lastMonthAmount: 0)),
|
||||
stockInSummaryProvider.overrideWith(
|
||||
(ref) async => const StockSummary(monthAmount: 0, lastMonthAmount: 0)),
|
||||
isReadonlyProvider.overrideWithValue(false),
|
||||
];
|
||||
|
||||
Future<void> _pumpFinance(
|
||||
WidgetTester tester,
|
||||
List<Override> overrides, {
|
||||
required Size size,
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
overrides: overrides,
|
||||
child: MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildTheme('a'),
|
||||
home: const Scaffold(body: FinanceScreen()),
|
||||
),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
group('桌面 ComboSearchField 往来单位筛选', () {
|
||||
testWidgets('选中往来单位后请求带 partner_id,清除(×)后回 null', (tester) async {
|
||||
final repo = _RecordingFinanceRepository();
|
||||
await _pumpFinance(tester, _overrides(repo), size: const Size(1280, 900));
|
||||
|
||||
// 初始一次 build 请求:未选往来单位,partnerId 应为 null。
|
||||
expect(repo.partnerIdCalls, isNotEmpty);
|
||||
expect(repo.partnerIdCalls.last, isNull);
|
||||
|
||||
// 工具栏 ComboSearchField 位于流水表底部,先滚到视口内(留足空间给
|
||||
// 下方展开的弹层,否则弹层会渲染到 root 视口之外,tap 无法命中)。
|
||||
final comboFinder = find.widgetWithText(ComboSearchField, '往来单位');
|
||||
await tester.ensureVisible(comboFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 打开工具栏 ComboSearchField 弹层,选中「华东酒类批发」(id=1)。
|
||||
await tester.tap(comboFinder);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('华东酒类批发'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repo.partnerIdCalls.last, 1);
|
||||
|
||||
// 点触发器上的 × 清除选择 → 应回 null 并触发新一轮请求。
|
||||
final clearIcon = find.descendant(
|
||||
of: find.byType(ComboSearchField),
|
||||
matching: find.byIcon(LucideIcons.x),
|
||||
);
|
||||
expect(clearIcon, findsOneWidget);
|
||||
await tester.tap(clearIcon);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repo.partnerIdCalls.last, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('移动端 chip → sheet 往来单位筛选', () {
|
||||
testWidgets('选中往来单位后请求带 partner_id,选「全部往来单位」清除后回 null',
|
||||
(tester) async {
|
||||
final repo = _RecordingFinanceRepository();
|
||||
await _pumpFinance(tester, _overrides(repo), size: const Size(390, 1600));
|
||||
|
||||
expect(repo.partnerIdCalls, isNotEmpty);
|
||||
expect(repo.partnerIdCalls.last, isNull);
|
||||
|
||||
final chipFinder = find.widgetWithText(DsChip, '往来单位');
|
||||
expect(chipFinder, findsOneWidget);
|
||||
|
||||
// 时间行整行横向可滚(Task 6 报告记录的布局取舍):先滚到可见再点击。
|
||||
await tester.ensureVisible(chipFinder);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(chipFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// sheet 打开:点选「华东酒类批发」。
|
||||
await tester.tap(find.text('华东酒类批发'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repo.partnerIdCalls.last, 1);
|
||||
|
||||
// 再次打开 sheet,选「全部往来单位」清除(-1 → null 约定)。
|
||||
await tester.ensureVisible(chipFinder);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(chipFinder);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('全部往来单位'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repo.partnerIdCalls.last, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('移动财务屏视口防裁剪', () {
|
||||
testWidgets('首屏(未手动滚动)「往来单位」chip 全局 bounds 落在 390 宽视口内',
|
||||
(tester) async {
|
||||
final repo = _RecordingFinanceRepository();
|
||||
const width = 390.0;
|
||||
await _pumpFinance(tester, _overrides(repo), size: const Size(width, 1600));
|
||||
|
||||
final chipFinder = find.widgetWithText(DsChip, '往来单位');
|
||||
expect(chipFinder, findsOneWidget);
|
||||
|
||||
// 不调用 ensureVisible / drag:直接量首屏(默认滚动位置 0)渲染出的位置,
|
||||
// 复刻 task-6-report.md 记录的 RenderBox 探针方法——
|
||||
// 曾经「登记按钮钉死 + chips 区域 Expanded 压缩」的实现下,该 chip 的
|
||||
// 全局 x 区间会完全落在 [0,390) 之外(100% 裁剪不可见),但
|
||||
// analyze/golden diff 均判定通过,只有量像素位置才能抓到。
|
||||
final box = tester.renderObject<RenderBox>(chipFinder);
|
||||
final topLeft = box.localToGlobal(Offset.zero);
|
||||
final rectRight = topLeft.dx + box.size.width;
|
||||
|
||||
expect(box.size.width, greaterThan(0));
|
||||
expect(topLeft.dx, greaterThanOrEqualTo(0),
|
||||
reason: 'chip 左边界不应裁剪出视口左侧');
|
||||
expect(rectRight, lessThanOrEqualTo(width),
|
||||
reason: '「往来单位」chip 右边界超出 390 视口,首屏会被裁剪不可见——'
|
||||
'回归到 task-6-report 记录过的隐蔽 bug');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user