Files

159 lines
5.7 KiB
Dart
Raw Permalink Normal View History

2026-08-17 15:29:55 +08:00
// 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: <String>{},
);
StoreContext _store(List<MenuItem> menus) =>
StoreContext(storeId: 1, storeCode: 'S001', storeName: '朝阳店', orgId: 9, menus: menus);
class _FakeSession extends SessionNotifier {
_FakeSession(this.session);
final AppSession session;
@override
Future<AppSession> build() async => session;
}
class _FakeHomeRepository implements HomeRepository {
_FakeHomeRepository({this.todosError = false});
final bool todosError;
@override
Future<List<TodoItem>> fetchTodos() async {
if (todosError) {
throw const ServerException('boom');
}
return const <TodoItem>[TodoItem(code: 'PURCHASE_ORDER', title: '待收货', count: 3)];
}
@override
Future<List<AlertItem>> fetchAlerts() async => const <AlertItem>[
AlertItem(title: '库存不足', detail: '3 个 SKU'),
];
}
class _RecordingAnalytics implements Analytics {
final List<(String, Map<String, Object?>)> events = <(String, Map<String, Object?>)>[];
@override
void track(String event, [Map<String, Object?> params = const <String, Object?>{}]) =>
events.add((event, params));
@override
void registerSuperProperties(Map<String, Object?> 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>[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>[
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>[
MenuItem(code: 'QUOTE_ORDER', name: '报价单'),
MenuItem(code: 'NOPE', name: '不存在'),
]),
),
),
),
],
);
addTearDown(container.dispose);
// sessionProvider 是异步的:不等它 resolve 就读,菜单是空的。
await container.read(sessionProvider.future);
final List<MenuEntry> entries = container.read(homeMenuEntriesProvider);
expect(entries.map((MenuEntry e) => e.code), <String>['QUOTE_ORDER']);
// 路由里只带 target 编码,绝不出现后端下发的 URL(04 的安全约定)。
expect(entries.single.route, isNot(contains('http')));
});
}