test(client): 财务往来单位筛选交互与移动视口防裁剪回归用例

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-14 10:24:53 +08:00
parent a2aca2e7fc
commit e499bbc842
@@ -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');
});
});
}