app scaffold
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
// 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')));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user