diff --git a/client/test/finance_partner_filter_test.dart b/client/test/finance_partner_filter_test.dart new file mode 100644 index 0000000..99ce0ba --- /dev/null +++ b/client/test/finance_partner_filter_test.dart @@ -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 partnerIdCalls = []; + + @override + Future> 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> partnerSummary() async => const []; + + @override + Future> trend({int months = 6}) async => const []; +} + +/// allPartnersProvider fixture:桌面 ComboSearchField / 移动 sheet 共用同一 +/// 数据源,固定 2 个往来单位供选择。 +class _FakeAllPartners extends PartnerListNotifier { + @override + Future> build() async => const PageResult( + data: [ + Partner(id: 1, name: '华东酒类批发', type: 'customer'), + Partner(id: 2, name: '金樽商贸', type: 'customer'), + ], + total: 2, + page: 1, + pageSize: 1000, + ); +} + +List _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 _pumpFinance( + WidgetTester tester, + List 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(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'); + }); + }); +}