feat(apps): 重构 UI 架构为 presentation 层并新增 l10n 国际化支持
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user