feat(apps): 重构 UI 架构为 presentation 层并新增 l10n 国际化支持

This commit is contained in:
qzl
2026-03-27 14:05:03 +08:00
parent b1f0eb8921
commit c592cc7854
178 changed files with 10748 additions and 5764 deletions
@@ -1,52 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:dio/dio.dart';
import 'package:social_app/core/api/api_exception.dart';
void main() {
group('ApiException', () {
test('creates from DioException with 400 status', () {
final dioException = Exception('Bad request');
final apiException = ApiException.fromDioError(dioException);
expect(apiException, isA<ApiException>());
expect(apiException.message, contains('网络错误'));
});
test('UnauthorizedException has default message', () {
const exception = UnauthorizedException();
expect(exception.message, '请重新登录');
});
test('429 returns backend detail message', () {
final dioException = DioException(
requestOptions: RequestOptions(path: '/api/v1/agent/runs'),
response: Response<dynamic>(
requestOptions: RequestOptions(path: '/api/v1/agent/runs'),
statusCode: 429,
data: <String, dynamic>{'detail': 'Too many SSE connections'},
),
);
final apiException = ApiException.fromDioError(dioException);
expect(apiException.statusCode, 429);
expect(apiException.message, 'Too many SSE connections');
});
test('429 parses detail from string json body', () {
final dioException = DioException(
requestOptions: RequestOptions(path: '/api/v1/agent/runs'),
response: Response<dynamic>(
requestOptions: RequestOptions(path: '/api/v1/agent/runs'),
statusCode: 429,
data: '{"detail":"Too many SSE connections"}',
),
);
final apiException = ApiException.fromDioError(dioException);
expect(apiException.statusCode, 429);
expect(apiException.message, 'Too many SSE connections');
});
});
}
@@ -1,143 +0,0 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:social_app/core/api/api_interceptor.dart';
import 'package:social_app/core/storage/token_storage.dart';
class MockTokenStorage extends Mock implements TokenStorage {}
class MockErrorInterceptorHandler extends Mock
implements ErrorInterceptorHandler {}
void main() {
late MockTokenStorage tokenStorage;
late MockErrorInterceptorHandler handler;
late ApiInterceptor interceptor;
setUpAll(() {
registerFallbackValue(
DioException(requestOptions: RequestOptions(path: '/fallback')),
);
registerFallbackValue(
Response<dynamic>(requestOptions: RequestOptions(path: '/fallback')),
);
});
setUp(() {
tokenStorage = MockTokenStorage();
handler = MockErrorInterceptorHandler();
interceptor = ApiInterceptor(
tokenStorage: tokenStorage,
dio: Dio(),
refreshFailureCooldown: const Duration(milliseconds: 80),
);
when(() => handler.next(any())).thenReturn(null);
when(() => handler.resolve(any())).thenReturn(null);
when(() => tokenStorage.getAccessToken()).thenAnswer((_) async => null);
});
DioException _unauthorized(String path, {bool withAuthHeader = false}) {
final requestOptions = RequestOptions(
path: path,
headers: withAuthHeader
? <String, dynamic>{'Authorization': 'Bearer expired'}
: null,
);
return DioException(
requestOptions: requestOptions,
response: Response<dynamic>(
requestOptions: requestOptions,
statusCode: 401,
),
type: DioExceptionType.badResponse,
);
}
test('401并发请求仅触发一次refresh', () async {
var refreshCalls = 0;
interceptor.onTokenRefresh = () async {
refreshCalls += 1;
await Future<void>.delayed(const Duration(milliseconds: 20));
return false;
};
interceptor.onError(_unauthorized('/api/v1/agent/history'), handler);
interceptor.onError(_unauthorized('/api/v1/agent/history'), handler);
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(refreshCalls, 1);
});
test('refresh接口401不应再次触发refresh', () async {
var refreshCalls = 0;
interceptor.onTokenRefresh = () async {
refreshCalls += 1;
return false;
};
interceptor.onError(
_unauthorized('/api/v1/auth/sessions/refresh'),
handler,
);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(refreshCalls, 0);
});
test('refresh接口带query时401也不应再次触发refresh', () async {
var refreshCalls = 0;
interceptor.onTokenRefresh = () async {
refreshCalls += 1;
return false;
};
interceptor.onError(
_unauthorized('/api/v1/auth/sessions/refresh?source=boot'),
handler,
);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(refreshCalls, 0);
});
test('refresh失败冷却期内不应重复触发refresh', () async {
var refreshCalls = 0;
interceptor.onTokenRefresh = () async {
refreshCalls += 1;
return false;
};
interceptor.onError(_unauthorized('/api/v1/agent/history'), handler);
await Future<void>.delayed(const Duration(milliseconds: 20));
interceptor.onError(_unauthorized('/api/v1/agent/history'), handler);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(refreshCalls, 1);
});
test('并发401刷新失败仅触发一次auth failure回调', () async {
var refreshCalls = 0;
var authFailureCalls = 0;
interceptor.onTokenRefresh = () async {
refreshCalls += 1;
await Future<void>.delayed(const Duration(milliseconds: 20));
return false;
};
interceptor.onAuthFailure = () async {
authFailureCalls += 1;
};
interceptor.onError(
_unauthorized('/api/v1/agent/history', withAuthHeader: true),
handler,
);
interceptor.onError(
_unauthorized('/api/v1/agent/history', withAuthHeader: true),
handler,
);
await Future<void>.delayed(const Duration(milliseconds: 80));
expect(refreshCalls, 1);
expect(authFailureCalls, 1);
});
}
-11
View File
@@ -1,11 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/cache/cache_invalidator.dart';
void main() {
test('invalidate calendar day should also invalidate month key', () {
final inv = CacheInvalidator();
inv.invalidateCalendarDay(DateTime(2026, 3, 20));
expect(inv.wasInvalidated('calendar:day:2026-03-20'), true);
expect(inv.wasInvalidated('calendar:month:2026-03'), true);
});
}
-19
View File
@@ -1,19 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/cache/cache_policy.dart';
void main() {
test('soft expired should allow stale read with background refresh', () {
final now = DateTime(2026, 3, 20, 12);
final policy = CachePolicy(
softTtl: const Duration(minutes: 2),
hardTtl: const Duration(minutes: 30),
minRefreshInterval: const Duration(minutes: 1),
);
final fetchedAt = now.subtract(const Duration(minutes: 3));
final decision = policy.evaluate(now: now, fetchedAt: fetchedAt);
expect(decision.canUseCached, true);
expect(decision.shouldRefreshInBackground, true);
expect(decision.mustBlockForNetwork, false);
});
}
@@ -1,27 +0,0 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/cache/cache_refresh_coordinator.dart';
void main() {
test('resume should trigger refresh only when min interval elapsed', () {
var calls = 0;
var now = DateTime(2026, 3, 20, 10, 0);
final coordinator = CacheRefreshCoordinator(
minInterval: const Duration(minutes: 5),
onRefresh: () => calls += 1,
now: () => now,
);
coordinator.didChangeAppLifecycleState(AppLifecycleState.resumed);
expect(calls, 1);
now = DateTime(2026, 3, 20, 10, 3);
coordinator.didChangeAppLifecycleState(AppLifecycleState.resumed);
expect(calls, 1);
now = DateTime(2026, 3, 20, 10, 6);
coordinator.didChangeAppLifecycleState(AppLifecycleState.resumed);
expect(calls, 2);
});
}
-27
View File
@@ -1,27 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/cache/hybrid_cache_store.dart';
import 'package:social_app/core/cache/memory_cache_store.dart';
import 'package:social_app/core/cache/persistent_cache_store.dart';
void main() {
test('same key concurrent load should execute loader once', () async {
var calls = 0;
final store = HybridCacheStore(
memory: MemoryCacheStore(),
persistent: PersistentCacheStore(),
);
Future<String> loader() async {
calls += 1;
await Future<void>.delayed(const Duration(milliseconds: 20));
return 'ok';
}
await Future.wait([
store.getOrLoad<String>('k', loader: loader),
store.getOrLoad<String>('k', loader: loader),
]);
expect(calls, 1);
});
}
@@ -1,44 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:social_app/core/notifications/ios_notification_payload_bridge.dart';
import 'dart:convert';
void main() {
group('IOSNotificationPayloadBridge', () {
test('启动时读取待处理的 notification payload', () async {
SharedPreferences.setMockInitialValues({
'pending_notification_payload': jsonEncode({
'eventId': 'evt_123',
'title': 'Test Event',
'startAt': '2026-03-20T10:00:00Z',
'mode': 'single',
}),
});
final prefs = await SharedPreferences.getInstance();
final bridge = IOSNotificationPayloadBridge(prefs);
final payload = await bridge.getPendingPayload();
expect(payload?.eventId, 'evt_123');
expect(payload?.title, 'Test Event');
});
test('处理完成后清理 UserDefaults', () async {
SharedPreferences.setMockInitialValues({
'pending_notification_payload': jsonEncode({
'eventId': 'evt_123',
'title': 'Test Event',
'startAt': '2026-03-20T10:00:00Z',
'mode': 'single',
}),
});
final prefs = await SharedPreferences.getInstance();
final bridge = IOSNotificationPayloadBridge(prefs);
await bridge.clearPendingPayload();
final remaining = prefs.getString('pending_notification_payload');
expect(remaining, isNull);
});
});
}
@@ -1,16 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/router/app_routes.dart';
void main() {
test('calendar and todo route builders generate concrete paths', () {
expect(
AppRoutes.calendarEventEdit('evt_123'),
'/calendar/events/evt_123/edit',
);
expect(
AppRoutes.calendarEventShare('evt_123'),
'/calendar/events/evt_123/share',
);
expect(AppRoutes.todoEdit('todo_123'), '/todo/todo_123/edit');
});
}
-105
View File
@@ -1,105 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/schemas/ui_schema.dart';
void main() {
group('ui_schema protocol stability', () {
test('UiSchemaDocument.fromJson keeps enum fallback defaults', () {
final doc = UiSchemaDocument.fromJson({
'version': '1.0',
'schemaType': 'unknown_type',
'status': 'unknown_status',
'nodes': const [],
});
expect(doc.schemaType, SchemaType.toolResult);
expect(doc.status, UiStatus.info);
});
test('actionSpecFromJson covers known and unknown branches', () {
final navigation = actionSpecFromJson({
'type': 'navigation',
'path': '/calendar/dayweek',
'params': {'from': 'home'},
});
expect(navigation, isA<NavigateAction>());
final unknown = actionSpecFromJson({'type': 'not_supported'});
expect(unknown, isA<EventAction>());
expect((unknown as EventAction).event, 'unknown');
});
test('UiNode.fromJson returns text fallback for unknown node type', () {
final node = UiNode.fromJson({'type': 'mystery'});
expect(node, isA<UiTextNode>());
expect((node as UiTextNode).content, 'Unknown node type: mystery');
});
test(
'buildSuccessDocument and buildErrorDocument keep status semantics',
() {
final success = buildSuccessDocument(const [
UiTextNode(content: 'ok'),
], schemaType: SchemaType.agentResponse);
final error = buildErrorDocument(const [UiTextNode(content: 'bad')]);
expect(success.status, UiStatus.success);
expect(success.schemaType, SchemaType.agentResponse);
expect(success.locale, 'zh-CN');
expect(error.status, UiStatus.error);
expect(error.schemaType, SchemaType.toolResult);
expect(error.version, '1.0');
},
);
test('UiSchemaDocument round-trip keeps critical fields stable', () {
final original = UiSchemaDocument(
version: '1.0',
schemaType: SchemaType.toolResult,
docId: 'doc_1',
timestamp: '2026-03-19T10:00:00Z',
locale: 'zh-CN',
status: UiStatus.success,
renderer: const RendererConfig(theme: RendererTheme.light),
meta: const DocumentMeta(requestId: 'req_1', toolId: 'tool_1'),
nodes: const [
UiContainerNode(
direction: ContainerDirection.vertical,
children: [
UiTextNode(content: 'hello', format: TextFormat.markdown),
],
),
],
);
final encoded = original.toJson();
final decoded = UiSchemaDocument.fromJson(encoded);
expect(decoded.version, '1.0');
expect(decoded.schemaType, SchemaType.toolResult);
expect(decoded.docId, 'doc_1');
expect(decoded.status, UiStatus.success);
expect(decoded.renderer?.theme, RendererTheme.light);
expect(decoded.nodes, hasLength(1));
expect(decoded.nodes.first, isA<UiContainerNode>());
});
test('toJson omits nullable fields as before', () {
const action = UiAction(
id: 'a1',
label: 'open',
action: NavigateAction(path: '/settings'),
);
final json = action.toJson();
expect(json['id'], 'a1');
expect(json['label'], 'open');
expect(json.containsKey('icon'), false);
expect(json.containsKey('style'), false);
expect(json.containsKey('confirm'), false);
expect((json['action'] as Map<String, dynamic>)['path'], '/settings');
});
});
}
@@ -1,44 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:social_app/core/storage/token_storage.dart';
class MockTokenStorage extends Mock implements TokenStorage {}
void main() {
late TokenStorage storage;
setUp(() {
storage = MockTokenStorage();
});
group('TokenStorage', () {
test('saves and retrieves access token', () async {
when(
() => storage.getAccessToken(),
).thenAnswer((_) async => 'test_access');
when(
() =>
storage.saveTokens(access: 'test_access', refresh: 'test_refresh'),
).thenAnswer((_) async {});
await storage.saveTokens(access: 'test_access', refresh: 'test_refresh');
final token = await storage.getAccessToken();
expect(token, 'test_access');
verify(
() =>
storage.saveTokens(access: 'test_access', refresh: 'test_refresh'),
).called(1);
});
test('clear removes all tokens', () async {
when(() => storage.clear()).thenAnswer((_) async {});
when(() => storage.getAccessToken()).thenAnswer((_) async => null);
await storage.clear();
final token = await storage.getAccessToken();
expect(token, isNull);
});
});
}