feat: 实现 Auth 全局状态机与 401 统一处理机制
- 新增 AuthSessionInvalidated 事件处理 token 失效场景 - ApiInterceptor 新增 authFailureCallback 单飞机制 - AuthBloc 区分 manual logout 与 auto expiry 语义 - 新增 startup recovery fallback 防止启动卡死 feat: 重构 Calendar DayWeek 视图事件布局引擎 - 新增 DayEventLayoutEngine 解耦事件计算与渲染 - 新增 DayTimelineMetrics 统一时间轴常量 - 新增 DayViewScale 支持捏合缩放 feat: 新增 Settings 页面共享 UI 组件 - 新增 BackTitlePageHeader 统一页面 header - 新增 DetailHeaderActionMenu 统一操作菜单 - 新增 DestructiveActionSheet 统一删除确认 - 新增 AppToggleSwitch 统一开关组件 feat: Chat UI Schema 支持导航操作 - 支持 navigation 类型 action 触发内部路由跳转 - 新增路径验证与参数处理 chore: 更新相关测试覆盖 auth 失效路径
This commit is contained in:
@@ -98,6 +98,18 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'clearSessionLocalOnly clears local tokens without api revoke',
|
||||
() async {
|
||||
when(() => mockStorage.clear()).thenAnswer((_) async {});
|
||||
|
||||
await repository.clearSessionLocalOnly();
|
||||
|
||||
verify(() => mockStorage.clear()).called(1);
|
||||
verifyNever(() => mockApi.deleteSession(any()));
|
||||
},
|
||||
);
|
||||
|
||||
test('refreshSession saves new tokens', () async {
|
||||
when(() => mockApi.refreshSession(any())).thenAnswer(
|
||||
(_) async => AuthResponse(
|
||||
|
||||
@@ -32,7 +32,10 @@ void main() {
|
||||
return authBloc;
|
||||
},
|
||||
act: (bloc) => bloc.add(AuthStarted()),
|
||||
expect: () => [AuthLoading(), AuthUnauthenticated()],
|
||||
expect: () => [
|
||||
AuthLoading(),
|
||||
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<AuthBloc, AuthState>(
|
||||
@@ -65,11 +68,38 @@ void main() {
|
||||
when(
|
||||
() => mockRepository.refreshSession('expired_refresh'),
|
||||
).thenThrow(Exception('Invalid refresh token'));
|
||||
when(() => mockRepository.deleteSession()).thenAnswer((_) async {});
|
||||
when(
|
||||
() => mockRepository.clearSessionLocalOnly(),
|
||||
).thenAnswer((_) async {});
|
||||
return authBloc;
|
||||
},
|
||||
act: (bloc) => bloc.add(AuthStarted()),
|
||||
expect: () => [AuthLoading(), AuthUnauthenticated()],
|
||||
expect: () => [
|
||||
AuthLoading(),
|
||||
const AuthUnauthenticated(
|
||||
reason: AuthUnauthenticatedReason.startupRecoveryFailed,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<AuthBloc, AuthState>(
|
||||
'emits startupRecoveryFailed when storage read throws',
|
||||
build: () {
|
||||
when(
|
||||
() => mockRepository.getRefreshToken(),
|
||||
).thenThrow(Exception('storage failed'));
|
||||
when(
|
||||
() => mockRepository.clearSessionLocalOnly(),
|
||||
).thenAnswer((_) async {});
|
||||
return authBloc;
|
||||
},
|
||||
act: (bloc) => bloc.add(AuthStarted()),
|
||||
expect: () => [
|
||||
AuthLoading(),
|
||||
const AuthUnauthenticated(
|
||||
reason: AuthUnauthenticatedReason.startupRecoveryFailed,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<AuthBloc, AuthState>(
|
||||
@@ -93,7 +123,30 @@ void main() {
|
||||
user: const AuthUser(id: '1', email: 'test@example.com'),
|
||||
),
|
||||
act: (bloc) => bloc.add(AuthLoggedOut()),
|
||||
expect: () => [AuthUnauthenticated()],
|
||||
expect: () => [
|
||||
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<AuthBloc, AuthState>(
|
||||
'emits expired unauthenticated when session invalidated',
|
||||
build: () {
|
||||
when(
|
||||
() => mockRepository.clearSessionLocalOnly(),
|
||||
).thenAnswer((_) async {});
|
||||
return authBloc;
|
||||
},
|
||||
seed: () => AuthAuthenticated(
|
||||
user: const AuthUser(id: '1', email: 'test@example.com'),
|
||||
),
|
||||
act: (bloc) => bloc.add(
|
||||
const AuthSessionInvalidated(
|
||||
source: AuthInvalidationSource.unauthorized401,
|
||||
),
|
||||
),
|
||||
expect: () => [
|
||||
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.expired),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/calendar/data/models/schedule_item_model.dart';
|
||||
import 'package:social_app/features/calendar/ui/widgets/create_event_sheet.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('编辑日程时支持非默认提醒值', (tester) async {
|
||||
final event = ScheduleItemModel(
|
||||
id: 'evt_1',
|
||||
ownerId: 'user_1',
|
||||
title: '测试日程',
|
||||
startAt: DateTime(2026, 3, 18, 10, 0),
|
||||
endAt: DateTime(2026, 3, 18, 11, 0),
|
||||
metadata: ScheduleMetadata(reminderMinutes: 20),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(body: CreateEventSheet(editingEvent: event)),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('进阶'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('开始前20分钟'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/calendar/data/models/schedule_item_model.dart';
|
||||
import 'package:social_app/features/calendar/ui/dayweek/day_event_layout_engine.dart';
|
||||
import 'package:social_app/features/calendar/ui/dayweek/day_timeline_metrics.dart';
|
||||
import 'package:social_app/features/calendar/ui/dayweek/day_view_scale.dart';
|
||||
|
||||
void main() {
|
||||
group('DayEventLayoutEngine', () {
|
||||
const engine = DayEventLayoutEngine();
|
||||
const scale = DayViewScale(hourHeight: 60);
|
||||
|
||||
test('maps event top and height by exact minutes', () {
|
||||
final event = _event(
|
||||
id: 'a',
|
||||
start: DateTime(2026, 3, 18, 10, 15),
|
||||
end: DateTime(2026, 3, 18, 11, 45),
|
||||
);
|
||||
|
||||
final layouts = engine.layout(
|
||||
events: [event],
|
||||
scale: scale,
|
||||
eventAreaLeft: DayTimelineMetrics.eventAreaLeft(),
|
||||
eventAreaWidth: 200,
|
||||
);
|
||||
|
||||
expect(layouts, hasLength(1));
|
||||
expect(layouts.first.startMinutes, 615);
|
||||
expect(layouts.first.endMinutes, 705);
|
||||
expect(layouts.first.top, 615);
|
||||
expect(layouts.first.geometryHeight, 90);
|
||||
expect(layouts.first.visualHeight, 90);
|
||||
});
|
||||
|
||||
test('splits overlapped events into columns', () {
|
||||
final e1 = _event(
|
||||
id: 'a',
|
||||
start: DateTime(2026, 3, 18, 9, 0),
|
||||
end: DateTime(2026, 3, 18, 10, 0),
|
||||
);
|
||||
final e2 = _event(
|
||||
id: 'b',
|
||||
start: DateTime(2026, 3, 18, 9, 30),
|
||||
end: DateTime(2026, 3, 18, 10, 30),
|
||||
);
|
||||
|
||||
final layouts = engine.layout(
|
||||
events: [e1, e2],
|
||||
scale: scale,
|
||||
eventAreaLeft: DayTimelineMetrics.eventAreaLeft(),
|
||||
eventAreaWidth: 200,
|
||||
);
|
||||
|
||||
expect(layouts, hasLength(2));
|
||||
expect(layouts[0].columnCount, 2);
|
||||
expect(layouts[1].columnCount, 2);
|
||||
expect(layouts[0].width, closeTo(98, 0.001));
|
||||
expect(layouts[1].width, closeTo(98, 0.001));
|
||||
expect(layouts[0].left, DayTimelineMetrics.eventAreaLeft());
|
||||
expect(
|
||||
layouts[1].left,
|
||||
closeTo(DayTimelineMetrics.eventAreaLeft() + 102, 0.001),
|
||||
);
|
||||
});
|
||||
|
||||
test('uses 1 pixel minimum visual height but preserves geometry', () {
|
||||
final event = _event(
|
||||
id: 'a',
|
||||
start: DateTime(2026, 3, 18, 9, 0),
|
||||
end: DateTime(2026, 3, 18, 9, 1),
|
||||
);
|
||||
|
||||
final tinyScale = const DayViewScale(
|
||||
hourHeight: DayViewScale.minHourHeight,
|
||||
);
|
||||
final layouts = engine.layout(
|
||||
events: [event],
|
||||
scale: tinyScale,
|
||||
eventAreaLeft: DayTimelineMetrics.eventAreaLeft(),
|
||||
eventAreaWidth: 200,
|
||||
);
|
||||
|
||||
expect(layouts, hasLength(1));
|
||||
expect(
|
||||
layouts.first.geometryHeight,
|
||||
closeTo(tinyScale.pixelsForMinutes(1), 0.001),
|
||||
);
|
||||
expect(layouts.first.visualHeight, greaterThanOrEqualTo(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ScheduleItemModel _event({
|
||||
required String id,
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
}) {
|
||||
return ScheduleItemModel(
|
||||
id: id,
|
||||
ownerId: 'owner',
|
||||
title: 'event-$id',
|
||||
startAt: start,
|
||||
endAt: end,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/calendar/ui/dayweek/day_view_scale.dart';
|
||||
|
||||
void main() {
|
||||
group('DayViewScale', () {
|
||||
test('maps minutes to pixels and back', () {
|
||||
const scale = DayViewScale(hourHeight: 60);
|
||||
|
||||
expect(scale.pixelsForMinutes(30), 30);
|
||||
expect(scale.pixelsForMinutes(75), 75);
|
||||
expect(scale.minutesForPixels(90), 90);
|
||||
});
|
||||
|
||||
test('clamps zoom height at boundaries', () {
|
||||
const scale = DayViewScale(hourHeight: 34);
|
||||
|
||||
final zoomIn = scale.zoomByFactor(20);
|
||||
final zoomOut = scale.zoomByFactor(0.01);
|
||||
|
||||
expect(zoomIn.hourHeight, DayViewScale.maxHourHeight);
|
||||
expect(zoomOut.hourHeight, DayViewScale.minHourHeight);
|
||||
});
|
||||
|
||||
test('ignores invalid zoom factor', () {
|
||||
const scale = DayViewScale(hourHeight: 34);
|
||||
|
||||
expect(scale.zoomByFactor(0).hourHeight, 34);
|
||||
expect(scale.zoomByFactor(-1).hourHeight, 34);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:social_app/features/chat/ui/widgets/ui_schema_renderer.dart';
|
||||
|
||||
void main() {
|
||||
@@ -128,5 +129,93 @@ void main() {
|
||||
|
||||
expect(find.textContaining('无效 UI Schema'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('handles navigation action and jumps by path', (tester) async {
|
||||
final schema = {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'plain',
|
||||
'children': [
|
||||
{
|
||||
'type': 'button',
|
||||
'label': '查看待办',
|
||||
'style': 'primary',
|
||||
'action': {
|
||||
'type': 'navigation',
|
||||
'path': '/todo/123',
|
||||
'params': {'from': 'assistant'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) =>
|
||||
Scaffold(body: UiSchemaRenderer.renderSchema(schema)),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/todo/:id',
|
||||
builder: (context, state) => Text(
|
||||
'todo detail ${state.pathParameters['id']} from ${state.uri.queryParameters['from']}',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
|
||||
await tester.tap(find.text('查看待办'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('todo detail 123 from assistant'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('does not navigate for placeholder path', (tester) async {
|
||||
final schema = {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'plain',
|
||||
'children': [
|
||||
{
|
||||
'type': 'button',
|
||||
'label': '坏路径',
|
||||
'style': 'primary',
|
||||
'action': {'type': 'navigation', 'path': '/todo/:id'},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) =>
|
||||
Scaffold(body: UiSchemaRenderer.renderSchema(schema)),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/todo/:id',
|
||||
builder: (context, state) => const Text('detail'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
|
||||
await tester.tap(find.text('坏路径'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
|
||||
expect(find.text('坏路径'), findsOneWidget);
|
||||
expect(find.text('detail'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user