// feature_home 的高价值断言全部围绕**降级粒度**(12 §四)——工作台的复杂度 // 不在于渲染,而在于"四个数据源里挂了一个的时候,屏幕上还剩下什么"。 // 1. 待办挂了:菜单入口必须还在、角标消失、不能整页报错。 // 2. 后端下发了本端没有的菜单编码:隐藏入口 + 上报,而不是留个死格子。 import 'package:core_analytics/core_analytics.dart'; import 'package:core_auth/core_auth.dart'; import 'package:core_foundation/core_foundation.dart'; import 'package:core_ui/core_ui.dart'; import 'package:feature_home/feature_home.dart'; // 页面和内部 provider 不对外导出,测试走 src。 import 'package:feature_home/src/presentation/home_page.dart'; import 'package:feature_home/src/presentation/home_providers.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; const UserContext _user = UserContext( userId: 'U1', employeeId: 'E1', phone: '13800000000', roleCode: 'CLERK', channel: 'RETAIL', permissions: {}, ); StoreContext _store(List menus) => StoreContext(storeId: 1, storeCode: 'S001', storeName: '朝阳店', orgId: 9, menus: menus); class _FakeSession extends SessionNotifier { _FakeSession(this.session); final AppSession session; @override Future build() async => session; } class _FakeHomeRepository implements HomeRepository { _FakeHomeRepository({this.todosError = false}); final bool todosError; @override Future> fetchTodos() async { if (todosError) { throw const ServerException('boom'); } return const [TodoItem(code: 'PURCHASE_ORDER', title: '待收货', count: 3)]; } @override Future> fetchAlerts() async => const [ AlertItem(title: '库存不足', detail: '3 个 SKU'), ]; } class _RecordingAnalytics implements Analytics { final List<(String, Map)> events = <(String, Map)>[]; @override void track(String event, [Map params = const {}]) => events.add((event, params)); @override void registerSuperProperties(Map props) {} @override void identify(String userId) {} @override void reset() {} } Widget _host(AppSession session, HomeRepository repo, {Analytics? analytics}) { return ProviderScope( overrides: [ sessionProvider.overrideWith(() => _FakeSession(session)), homeRepositoryProvider.overrideWithValue(repo), if (analytics != null) analyticsProvider.overrideWithValue(analytics), ], child: const MaterialApp(home: HomePage()), ); } void main() { testWidgets('待办接口挂了,菜单入口和预警照常显示,只有待办区块降级', (WidgetTester tester) async { final AppSession session = SessionActive( user: _user, store: _store(const [MenuItem(code: 'PURCHASE_ORDER', name: '采购下单')]), ); await tester.pumpWidget(_host(session, _FakeHomeRepository(todosError: true))); await tester.pumpAndSettle(); // 第 2 档:待办自己显示局部错误态。 expect(find.byType(TileErrorView), findsOneWidget); // 第 1 档没有触发:整页错误态不能出现。 expect(find.byType(ErrorView), findsNothing); // 菜单入口还在——这是这条用例的重点,用户仍然能进去干活。 expect(find.byKey(const Key('home_menu_PURCHASE_ORDER')), findsOneWidget); // 第 3 档:角标拿不到就不显示,不是显示 0,更不是显示错误。 expect(find.text('3'), findsNothing); // 相邻区块不受牵连——这正是不能用 Future.wait 的原因。 expect(find.text('库存不足'), findsOneWidget); }); testWidgets('后端下发本端没有的菜单编码时隐藏入口并上报', (WidgetTester tester) async { final _RecordingAnalytics analytics = _RecordingAnalytics(); final AppSession session = SessionActive( user: _user, store: _store(const [ MenuItem(code: 'PURCHASE_ORDER', name: '采购下单'), MenuItem(code: 'BRAND_NEW_THING', name: '未来功能'), ]), ); await tester.pumpWidget(_host(session, _FakeHomeRepository(), analytics: analytics)); await tester.pumpAndSettle(); expect(find.byKey(const Key('home_menu_PURCHASE_ORDER')), findsOneWidget); // 点了没反应的格子比看不见更糟:灰度期后端先配菜单、App 后发版是常态。 expect(find.text('未来功能'), findsNothing); expect(analytics.events, hasLength(1)); expect(analytics.events.single.$1, AnalyticsEvent.menuCodeUnsupported); expect(analytics.events.single.$2[AnalyticsParam.code], 'BRAND_NEW_THING'); }); test('菜单解析只保留本端路由表里有的编码', () async { final ProviderContainer container = ProviderContainer( overrides: [ sessionProvider.overrideWith( () => _FakeSession( SessionActive( user: _user, store: _store(const [ MenuItem(code: 'QUOTE_ORDER', name: '报价单'), MenuItem(code: 'NOPE', name: '不存在'), ]), ), ), ), ], ); addTearDown(container.dispose); // sessionProvider 是异步的:不等它 resolve 就读,菜单是空的。 await container.read(sessionProvider.future); final List entries = container.read(homeMenuEntriesProvider); expect(entries.map((MenuEntry e) => e.code), ['QUOTE_ORDER']); // 路由里只带 target 编码,绝不出现后端下发的 URL(04 的安全约定)。 expect(entries.single.route, isNot(contains('http'))); }); }