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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:social_app/features/auth/data/auth_api.dart';
|
||||
import 'package:social_app/features/auth/data/auth_repository_impl.dart';
|
||||
import 'package:social_app/features/auth/data/models/signup_request.dart';
|
||||
import 'package:social_app/features/auth/data/models/login_request.dart';
|
||||
import 'package:social_app/features/auth/data/models/auth_response.dart';
|
||||
import 'package:social_app/core/storage/token_storage.dart';
|
||||
|
||||
class MockAuthApi extends Mock implements AuthApi {}
|
||||
|
||||
class MockTokenStorage extends Mock implements TokenStorage {}
|
||||
|
||||
void main() {
|
||||
late AuthRepositoryImpl repository;
|
||||
late MockAuthApi mockApi;
|
||||
late MockTokenStorage mockStorage;
|
||||
|
||||
setUp(() {
|
||||
mockApi = MockAuthApi();
|
||||
mockStorage = MockTokenStorage();
|
||||
repository = AuthRepositoryImpl(api: mockApi, tokenStorage: mockStorage);
|
||||
registerFallbackValue(const OtpSendRequest(phone: ''));
|
||||
registerFallbackValue(const LoginRequest(phone: '', token: ''));
|
||||
registerFallbackValue(const LogoutRequest(refreshToken: ''));
|
||||
registerFallbackValue(const RefreshRequest(refreshToken: ''));
|
||||
});
|
||||
|
||||
group('AuthRepositoryImpl', () {
|
||||
test('sendOtp calls api', () async {
|
||||
when(() => mockApi.sendOtp(any())).thenAnswer((_) async {});
|
||||
|
||||
await repository.sendOtp('+8613812345678');
|
||||
|
||||
verify(() => mockApi.sendOtp(any())).called(1);
|
||||
});
|
||||
|
||||
test('createPhoneSession calls api and saves tokens', () async {
|
||||
when(() => mockApi.createPhoneSession(any())).thenAnswer(
|
||||
(_) async => AuthResponse(
|
||||
accessToken: 'access_token',
|
||||
refreshToken: 'refresh_token',
|
||||
expiresIn: 3600,
|
||||
tokenType: 'bearer',
|
||||
user: const AuthUser(id: '123', phone: '+8613812345678'),
|
||||
),
|
||||
);
|
||||
when(
|
||||
() => mockStorage.saveTokens(
|
||||
access: any(named: 'access'),
|
||||
refresh: any(named: 'refresh'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
final result = await repository.createPhoneSession(
|
||||
phone: '+8613812345678',
|
||||
token: '123456',
|
||||
);
|
||||
|
||||
expect(result.accessToken, 'access_token');
|
||||
verify(
|
||||
() => mockStorage.saveTokens(
|
||||
access: 'access_token',
|
||||
refresh: 'refresh_token',
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test(
|
||||
'deleteSession calls api with refresh token and clears storage',
|
||||
() async {
|
||||
when(
|
||||
() => mockStorage.getRefreshToken(),
|
||||
).thenAnswer((_) async => 'refresh_token');
|
||||
when(() => mockApi.deleteSession(any())).thenAnswer((_) async {});
|
||||
when(() => mockStorage.clear()).thenAnswer((_) async {});
|
||||
|
||||
await repository.deleteSession();
|
||||
|
||||
verify(() => mockApi.deleteSession(any())).called(1);
|
||||
verify(() => mockStorage.clear()).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
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(
|
||||
accessToken: 'new_access',
|
||||
refreshToken: 'new_refresh',
|
||||
expiresIn: 3600,
|
||||
tokenType: 'bearer',
|
||||
user: const AuthUser(id: '123', phone: '+8613812345678'),
|
||||
),
|
||||
);
|
||||
when(
|
||||
() => mockStorage.saveTokens(
|
||||
access: any(named: 'access'),
|
||||
refresh: any(named: 'refresh'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
final result = await repository.refreshSession('old_refresh');
|
||||
|
||||
expect(result.accessToken, 'new_access');
|
||||
verify(
|
||||
() => mockStorage.saveTokens(
|
||||
access: 'new_access',
|
||||
refresh: 'new_refresh',
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/auth/data/models/signup_request.dart';
|
||||
import 'package:social_app/features/auth/data/models/login_request.dart';
|
||||
import 'package:social_app/features/auth/data/models/auth_response.dart';
|
||||
|
||||
void main() {
|
||||
group('OtpSendRequest', () {
|
||||
test('serializes e164 phone to JSON', () {
|
||||
final request = OtpSendRequest(phone: '+14155552671');
|
||||
|
||||
final json = request.toJson();
|
||||
|
||||
expect(json['phone'], '+14155552671');
|
||||
});
|
||||
|
||||
test('normalizes 00 prefix to plus', () {
|
||||
final request = OtpSendRequest(phone: '0014155552671');
|
||||
|
||||
final json = request.toJson();
|
||||
|
||||
expect(json['phone'], '+14155552671');
|
||||
});
|
||||
});
|
||||
|
||||
group('LoginRequest', () {
|
||||
test('serializes e164 to JSON', () {
|
||||
final request = LoginRequest(phone: '+14155552671', token: '123456');
|
||||
|
||||
final json = request.toJson();
|
||||
|
||||
expect(json['phone'], '+14155552671');
|
||||
expect(json['token'], '123456');
|
||||
});
|
||||
});
|
||||
|
||||
group('AuthResponse', () {
|
||||
test('parses from JSON', () {
|
||||
final json = {
|
||||
'access_token': 'test_access',
|
||||
'refresh_token': 'test_refresh',
|
||||
'expires_in': 3600,
|
||||
'token_type': 'bearer',
|
||||
'user': {'id': '123', 'phone': '+8613812345678'},
|
||||
};
|
||||
|
||||
final response = AuthResponse.fromJson(json);
|
||||
|
||||
expect(response.accessToken, 'test_access');
|
||||
expect(response.refreshToken, 'test_refresh');
|
||||
expect(response.expiresIn, 3600);
|
||||
expect(response.user.id, '123');
|
||||
expect(response.user.phone, '+8613812345678');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import 'package:bloc_test/bloc_test.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:social_app/features/auth/data/auth_repository.dart';
|
||||
import 'package:social_app/features/auth/data/models/auth_response.dart';
|
||||
import 'package:social_app/features/auth/presentation/bloc/auth_bloc.dart';
|
||||
import 'package:social_app/features/auth/presentation/bloc/auth_event.dart';
|
||||
import 'package:social_app/features/auth/presentation/bloc/auth_state.dart';
|
||||
|
||||
class MockAuthRepository extends Mock implements AuthRepository {}
|
||||
|
||||
void main() {
|
||||
late AuthBloc authBloc;
|
||||
late MockAuthRepository mockRepository;
|
||||
|
||||
setUp(() {
|
||||
mockRepository = MockAuthRepository();
|
||||
authBloc = AuthBloc(mockRepository);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
authBloc.close();
|
||||
});
|
||||
|
||||
group('AuthBloc', () {
|
||||
blocTest<AuthBloc, AuthState>(
|
||||
'emits [AuthLoading, AuthUnauthenticated] when AuthStarted and no refresh token',
|
||||
build: () {
|
||||
when(
|
||||
() => mockRepository.getRefreshToken(),
|
||||
).thenAnswer((_) async => null);
|
||||
return authBloc;
|
||||
},
|
||||
act: (bloc) => bloc.add(AuthStarted()),
|
||||
expect: () => [
|
||||
AuthLoading(),
|
||||
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<AuthBloc, AuthState>(
|
||||
'emits [AuthLoading, AuthAuthenticated] when AuthStarted with valid refresh token',
|
||||
build: () {
|
||||
when(
|
||||
() => mockRepository.getRefreshToken(),
|
||||
).thenAnswer((_) async => 'valid_refresh');
|
||||
when(() => mockRepository.refreshSession('valid_refresh')).thenAnswer(
|
||||
(_) async => AuthResponse(
|
||||
accessToken: 'new_access',
|
||||
refreshToken: 'new_refresh',
|
||||
expiresIn: 3600,
|
||||
tokenType: 'bearer',
|
||||
user: const AuthUser(id: '123', phone: '+8613812345678'),
|
||||
),
|
||||
);
|
||||
return authBloc;
|
||||
},
|
||||
act: (bloc) => bloc.add(AuthStarted()),
|
||||
expect: () => [AuthLoading(), isA<AuthAuthenticated>()],
|
||||
);
|
||||
|
||||
blocTest<AuthBloc, AuthState>(
|
||||
'emits [AuthLoading, AuthUnauthenticated] when refresh token expired',
|
||||
build: () {
|
||||
when(
|
||||
() => mockRepository.getRefreshToken(),
|
||||
).thenAnswer((_) async => 'expired_refresh');
|
||||
when(
|
||||
() => mockRepository.refreshSession('expired_refresh'),
|
||||
).thenThrow(Exception('Invalid refresh token'));
|
||||
when(
|
||||
() => mockRepository.clearSessionLocalOnly(),
|
||||
).thenAnswer((_) async {});
|
||||
return authBloc;
|
||||
},
|
||||
act: (bloc) => bloc.add(AuthStarted()),
|
||||
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>(
|
||||
'emits [AuthAuthenticated] when AuthLoggedIn',
|
||||
build: () => authBloc,
|
||||
act: (bloc) => bloc.add(
|
||||
AuthLoggedIn(
|
||||
user: const AuthUser(id: '1', phone: '+8613812345678'),
|
||||
),
|
||||
),
|
||||
expect: () => [isA<AuthAuthenticated>()],
|
||||
);
|
||||
|
||||
blocTest<AuthBloc, AuthState>(
|
||||
'emits [AuthUnauthenticated] when AuthLoggedOut',
|
||||
build: () {
|
||||
when(() => mockRepository.deleteSession()).thenAnswer((_) async {});
|
||||
return authBloc;
|
||||
},
|
||||
seed: () => AuthAuthenticated(
|
||||
user: const AuthUser(id: '1', phone: '+8613812345678'),
|
||||
),
|
||||
act: (bloc) => bloc.add(AuthLoggedOut()),
|
||||
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', phone: '+8613812345678'),
|
||||
),
|
||||
act: (bloc) => bloc.add(
|
||||
const AuthSessionInvalidated(
|
||||
source: AuthInvalidationSource.unauthorized401,
|
||||
),
|
||||
),
|
||||
expect: () => [
|
||||
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.expired),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import 'package:bloc_test/bloc_test.dart';
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:formz/formz.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:social_app/features/auth/data/auth_repository.dart';
|
||||
import 'package:social_app/features/auth/presentation/cubits/login_cubit.dart';
|
||||
|
||||
class MockAuthRepository extends Mock implements AuthRepository {}
|
||||
|
||||
void main() {
|
||||
late LoginCubit cubit;
|
||||
late MockAuthRepository mockRepository;
|
||||
|
||||
setUp(() {
|
||||
mockRepository = MockAuthRepository();
|
||||
cubit = LoginCubit(mockRepository);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
cubit.close();
|
||||
});
|
||||
|
||||
group('LoginCubit', () {
|
||||
test('initial state has pure status', () {
|
||||
expect(cubit.state.status, FormzSubmissionStatus.initial);
|
||||
});
|
||||
|
||||
blocTest<LoginCubit, LoginState>(
|
||||
'phoneChanged updates phone',
|
||||
build: () => cubit,
|
||||
act: (c) => c.phoneChanged('+8613812345678'),
|
||||
expect: () => [isA<LoginState>()],
|
||||
);
|
||||
|
||||
blocTest<LoginCubit, LoginState>(
|
||||
'codeChanged updates code',
|
||||
build: () => cubit,
|
||||
act: (c) => c.codeChanged('123456'),
|
||||
expect: () => [isA<LoginState>()],
|
||||
);
|
||||
|
||||
test('sendCode success starts 60s cooldown', () {
|
||||
when(() => mockRepository.sendOtp(any())).thenAnswer((_) async {});
|
||||
|
||||
fakeAsync((async) {
|
||||
cubit.phoneChanged('13812345678');
|
||||
|
||||
cubit.sendCode();
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(cubit.state.resendCooldownSeconds, 60);
|
||||
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(cubit.state.resendCooldownSeconds, 59);
|
||||
|
||||
async.elapse(const Duration(seconds: 59));
|
||||
expect(cubit.state.resendCooldownSeconds, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('sendCode is blocked during cooldown', () async {
|
||||
when(() => mockRepository.sendOtp(any())).thenAnswer((_) async {});
|
||||
cubit.phoneChanged('13812345678');
|
||||
|
||||
final first = await cubit.sendCode();
|
||||
final second = await cubit.sendCode();
|
||||
|
||||
expect(first, isTrue);
|
||||
expect(second, isFalse);
|
||||
verify(() => mockRepository.sendOtp(any())).called(1);
|
||||
});
|
||||
|
||||
test('phone change resets cooldown and code state', () {
|
||||
when(() => mockRepository.sendOtp(any())).thenAnswer((_) async {});
|
||||
|
||||
fakeAsync((async) {
|
||||
cubit.phoneChanged('13812345678');
|
||||
cubit.codeChanged('123456');
|
||||
cubit.sendCode();
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(cubit.state.resendCooldownSeconds, 60);
|
||||
expect(cubit.state.codeSent, isTrue);
|
||||
|
||||
cubit.phoneChanged('14155552671');
|
||||
|
||||
expect(cubit.state.resendCooldownSeconds, 0);
|
||||
expect(cubit.state.codeSent, isFalse);
|
||||
expect(cubit.state.code.value, '');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/core/cache/cache_entry.dart';
|
||||
import 'package:social_app/core/cache/cache_policy.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';
|
||||
import 'package:social_app/features/calendar/data/models/schedule_item_model.dart';
|
||||
import 'package:social_app/features/calendar/data/services/calendar_repository.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'getDayEvents returns cache immediately and refreshes in background',
|
||||
() async {
|
||||
final store = HybridCacheStore(
|
||||
memory: MemoryCacheStore(),
|
||||
persistent: PersistentCacheStore(),
|
||||
);
|
||||
final date = DateTime(2026, 3, 20);
|
||||
final key = CalendarRepository.dayKey(date);
|
||||
await store.persistent.write<CacheEntry<List<ScheduleItemModel>>>(
|
||||
key,
|
||||
CacheEntry(
|
||||
value: [
|
||||
ScheduleItemModel(
|
||||
id: 'evt_cached',
|
||||
ownerId: 'owner_1',
|
||||
title: 'cached',
|
||||
startAt: DateTime(2026, 3, 20, 10),
|
||||
endAt: DateTime(2026, 3, 20, 11),
|
||||
status: ScheduleStatus.active,
|
||||
),
|
||||
],
|
||||
fetchedAt: DateTime(2026, 3, 20, 11, 0),
|
||||
),
|
||||
);
|
||||
|
||||
var remoteCalls = 0;
|
||||
final repository = CalendarRepository(
|
||||
store: store,
|
||||
now: () => DateTime(2026, 3, 20, 11, 5),
|
||||
policy: const CachePolicy(
|
||||
softTtl: Duration(minutes: 2),
|
||||
hardTtl: Duration(minutes: 30),
|
||||
minRefreshInterval: Duration(minutes: 1),
|
||||
),
|
||||
loadDayFromRemote: (_) async {
|
||||
remoteCalls += 1;
|
||||
return const <ScheduleItemModel>[];
|
||||
},
|
||||
loadMonthFromRemote: (start, end) async => const <ScheduleItemModel>[],
|
||||
);
|
||||
|
||||
final result = await repository.getDayEvents(date);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
expect(result.first.id, 'evt_cached');
|
||||
expect(remoteCalls, 1);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/calendar/reminders/models/reminder_payload.dart';
|
||||
|
||||
void main() {
|
||||
group('ReminderPayload', () {
|
||||
test('round-trips single payload', () {
|
||||
final payload = ReminderPayload(
|
||||
eventId: 'evt_1',
|
||||
title: 'Daily Sync',
|
||||
startAt: DateTime.parse('2026-03-18T16:00:00+08:00'),
|
||||
endAt: DateTime.parse('2026-03-18T17:00:00+08:00'),
|
||||
timezone: 'Asia/Shanghai',
|
||||
location: 'A101',
|
||||
notes: 'Bring docs',
|
||||
color: '#3B82F6',
|
||||
mode: ReminderPayloadMode.single,
|
||||
aggregateIds: const [],
|
||||
version: 1,
|
||||
);
|
||||
|
||||
final decoded = ReminderPayload.fromJson(payload.toJson());
|
||||
expect(decoded, payload);
|
||||
});
|
||||
|
||||
test('round-trips aggregate payload', () {
|
||||
final payload = ReminderPayload(
|
||||
eventId: 'evt_group',
|
||||
title: 'Overlap Reminder',
|
||||
startAt: DateTime.parse('2026-03-18T16:00:00+08:00'),
|
||||
timezone: 'Asia/Shanghai',
|
||||
mode: ReminderPayloadMode.aggregate,
|
||||
aggregateIds: const ['evt_1', 'evt_2'],
|
||||
version: 1,
|
||||
);
|
||||
|
||||
final decoded = ReminderPayload.fromJson(payload.toJson());
|
||||
expect(decoded.mode, ReminderPayloadMode.aggregate);
|
||||
expect(decoded.aggregateIds, const ['evt_1', 'evt_2']);
|
||||
expect(decoded, payload);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:social_app/core/notifications/local_notification_service.dart';
|
||||
import 'package:social_app/features/calendar/data/models/schedule_item_model.dart';
|
||||
import 'package:social_app/features/calendar/data/services/calendar_service.dart';
|
||||
import 'package:social_app/features/calendar/reminders/models/reminder_action.dart';
|
||||
import 'package:social_app/features/calendar/reminders/models/reminder_payload.dart';
|
||||
import 'package:social_app/features/calendar/reminders/reminder_action_executor.dart';
|
||||
|
||||
class MockCalendarService extends Mock implements CalendarService {}
|
||||
|
||||
class MockLocalNotificationService extends Mock
|
||||
implements LocalNotificationService {}
|
||||
|
||||
void main() {
|
||||
late MockCalendarService calendarService;
|
||||
late MockLocalNotificationService notificationService;
|
||||
late ReminderActionExecutor executor;
|
||||
|
||||
setUp(() {
|
||||
calendarService = MockCalendarService();
|
||||
notificationService = MockLocalNotificationService();
|
||||
executor = ReminderActionExecutor(
|
||||
calendarService: calendarService,
|
||||
notificationService: notificationService,
|
||||
);
|
||||
});
|
||||
|
||||
test('archive archives remotely and cancels local reminder', () async {
|
||||
when(
|
||||
() => notificationService.cancelEventReminder('evt_1'),
|
||||
).thenAnswer((_) async {});
|
||||
when(
|
||||
() => calendarService.archiveEvent('evt_1'),
|
||||
).thenAnswer((_) async => null);
|
||||
|
||||
await executor.handleAction(
|
||||
action: ReminderAction.archive,
|
||||
payload: ReminderPayload(
|
||||
eventId: 'evt_1',
|
||||
title: 'sync',
|
||||
startAt: DateTime.parse('2026-03-18T16:00:00+08:00'),
|
||||
timezone: 'Asia/Shanghai',
|
||||
),
|
||||
);
|
||||
|
||||
verify(() => notificationService.cancelEventReminder('evt_1')).called(1);
|
||||
verify(() => calendarService.archiveEvent('evt_1')).called(1);
|
||||
});
|
||||
|
||||
test('snooze reschedules +10m when event not expired', () async {
|
||||
final now = DateTime.now();
|
||||
final event = ScheduleItemModel(
|
||||
id: 'evt_1',
|
||||
ownerId: 'u1',
|
||||
title: 'sync',
|
||||
startAt: now.add(const Duration(minutes: 1)),
|
||||
endAt: now.add(const Duration(hours: 1)),
|
||||
metadata: ScheduleMetadata(reminderMinutes: 15),
|
||||
);
|
||||
when(
|
||||
() => calendarService.getEventById('evt_1'),
|
||||
).thenAnswer((_) async => event);
|
||||
when(
|
||||
() => notificationService.scheduleReminderAt(event, any()),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
await executor.handleAction(
|
||||
action: ReminderAction.snooze10m,
|
||||
payload: ReminderPayload(
|
||||
eventId: 'evt_1',
|
||||
title: 'sync',
|
||||
startAt: event.startAt,
|
||||
endAt: event.endAt,
|
||||
timezone: 'Asia/Shanghai',
|
||||
),
|
||||
);
|
||||
|
||||
verify(
|
||||
() => notificationService.scheduleReminderAt(event, any()),
|
||||
).called(1);
|
||||
verifyNever(() => calendarService.archiveEvent(any()));
|
||||
});
|
||||
|
||||
test('fromValue throws on unknown action', () {
|
||||
expect(
|
||||
() => ReminderAction.fromValue('unknown_action'),
|
||||
throwsA(isA<ArgumentError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'aggregate action falls back to eventId when aggregateIds is empty',
|
||||
() async {
|
||||
when(
|
||||
() => notificationService.cancelEventReminder('evt_fallback'),
|
||||
).thenAnswer((_) async {});
|
||||
when(
|
||||
() => calendarService.archiveEvent('evt_fallback'),
|
||||
).thenAnswer((_) async => null);
|
||||
|
||||
await executor.handleAction(
|
||||
action: ReminderAction.archive,
|
||||
payload: ReminderPayload(
|
||||
eventId: 'evt_fallback',
|
||||
title: 'sync',
|
||||
startAt: DateTime.parse('2026-03-18T16:00:00+08:00'),
|
||||
timezone: 'Asia/Shanghai',
|
||||
mode: ReminderPayloadMode.aggregate,
|
||||
aggregateIds: const <String>[],
|
||||
),
|
||||
);
|
||||
|
||||
verify(
|
||||
() => notificationService.cancelEventReminder('evt_fallback'),
|
||||
).called(1);
|
||||
verify(() => calendarService.archiveEvent('evt_fallback')).called(1);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:social_app/core/notifications/reminder_notification_callbacks.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
await ReminderNotificationCallbacks.resetForTest();
|
||||
});
|
||||
|
||||
test('contains top-level vm entry-point background callback', () async {
|
||||
final source = await File(
|
||||
'lib/core/notifications/reminder_notification_callbacks.dart',
|
||||
).readAsString();
|
||||
|
||||
expect(source, contains("@pragma('vm:entry-point')"));
|
||||
expect(source, contains('Future<void> reminderNotificationTapBackground('));
|
||||
});
|
||||
|
||||
test(
|
||||
'dispatches foreground and background responses to bound handler',
|
||||
() async {
|
||||
final handledIds = <int?>[];
|
||||
await ReminderNotificationCallbacks.bindResponseHandler((response) async {
|
||||
handledIds.add(response.id);
|
||||
});
|
||||
|
||||
await ReminderNotificationCallbacks.onForegroundResponse(
|
||||
NotificationResponse(
|
||||
notificationResponseType:
|
||||
NotificationResponseType.selectedNotificationAction,
|
||||
id: 10,
|
||||
),
|
||||
);
|
||||
await reminderNotificationTapBackground(
|
||||
NotificationResponse(
|
||||
notificationResponseType:
|
||||
NotificationResponseType.selectedNotificationAction,
|
||||
id: 20,
|
||||
),
|
||||
);
|
||||
|
||||
expect(handledIds, <int?>[10, 20]);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'queues background response when handler is unbound and drains later',
|
||||
() async {
|
||||
await reminderNotificationTapBackground(
|
||||
NotificationResponse(
|
||||
notificationResponseType:
|
||||
NotificationResponseType.selectedNotificationAction,
|
||||
id: 99,
|
||||
),
|
||||
);
|
||||
|
||||
final handledIds = <int?>[];
|
||||
await ReminderNotificationCallbacks.bindResponseHandler((response) async {
|
||||
handledIds.add(response.id);
|
||||
});
|
||||
|
||||
expect(handledIds, <int?>[99]);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'queues foreground response when handler is unbound and drains later',
|
||||
() async {
|
||||
await ReminderNotificationCallbacks.onForegroundResponse(
|
||||
NotificationResponse(
|
||||
notificationResponseType:
|
||||
NotificationResponseType.selectedNotificationAction,
|
||||
id: 55,
|
||||
),
|
||||
);
|
||||
|
||||
final handledIds = <int?>[];
|
||||
await ReminderNotificationCallbacks.bindResponseHandler((response) async {
|
||||
handledIds.add(response.id);
|
||||
});
|
||||
|
||||
expect(handledIds, <int?>[55]);
|
||||
},
|
||||
);
|
||||
|
||||
test('failed pending item stays queued for next bind retry', () async {
|
||||
await reminderNotificationTapBackground(
|
||||
NotificationResponse(
|
||||
notificationResponseType:
|
||||
NotificationResponseType.selectedNotificationAction,
|
||||
id: 77,
|
||||
),
|
||||
);
|
||||
|
||||
var firstAttempt = true;
|
||||
await ReminderNotificationCallbacks.bindResponseHandler((response) async {
|
||||
if (firstAttempt) {
|
||||
firstAttempt = false;
|
||||
throw Exception('temporary failure');
|
||||
}
|
||||
});
|
||||
|
||||
final handledIds = <int?>[];
|
||||
await ReminderNotificationCallbacks.bindResponseHandler((response) async {
|
||||
handledIds.add(response.id);
|
||||
});
|
||||
|
||||
expect(handledIds, <int?>[77]);
|
||||
});
|
||||
|
||||
test(
|
||||
'background handler failure while bound is enqueued for retry',
|
||||
() async {
|
||||
var firstAttempt = true;
|
||||
await ReminderNotificationCallbacks.bindResponseHandler((response) async {
|
||||
if (firstAttempt) {
|
||||
firstAttempt = false;
|
||||
throw Exception('temporary failure');
|
||||
}
|
||||
});
|
||||
|
||||
await reminderNotificationTapBackground(
|
||||
NotificationResponse(
|
||||
notificationResponseType:
|
||||
NotificationResponseType.selectedNotificationAction,
|
||||
id: 123,
|
||||
),
|
||||
);
|
||||
|
||||
final handledIds = <int?>[];
|
||||
await ReminderNotificationCallbacks.bindResponseHandler((response) async {
|
||||
handledIds.add(response.id);
|
||||
});
|
||||
|
||||
expect(handledIds, <int?>[123]);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:social_app/features/calendar/reminders/ui/reminder_overlay.dart';
|
||||
import 'package:social_app/features/calendar/reminders/reminder_queue_manager.dart';
|
||||
import 'package:social_app/features/calendar/reminders/models/reminder_payload.dart';
|
||||
|
||||
void main() {
|
||||
group('ReminderOverlay', () {
|
||||
late ReminderQueueManager queueManager;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
queueManager = ReminderQueueManager();
|
||||
});
|
||||
|
||||
testWidgets('显示日程标题和当前时间', (tester) async {
|
||||
final payload = ReminderPayload(
|
||||
eventId: '1',
|
||||
title: 'Test Meeting',
|
||||
startAt: DateTime(2026, 3, 20, 10, 0),
|
||||
endAt: DateTime(2026, 3, 20, 11, 0),
|
||||
timezone: 'Asia/Shanghai',
|
||||
mode: ReminderPayloadMode.single,
|
||||
);
|
||||
queueManager.enqueueFromClick(payload);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: ReminderOverlay(
|
||||
queueManager: queueManager,
|
||||
onComplete: () {},
|
||||
onSnooze: (minutes) {},
|
||||
onArchive: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Test Meeting'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('点击完成按钮触发归档', (tester) async {
|
||||
bool archiveCalled = false;
|
||||
final payload = ReminderPayload(
|
||||
eventId: '1',
|
||||
title: 'Test Meeting',
|
||||
startAt: DateTime(2026, 3, 20, 10, 0),
|
||||
endAt: DateTime(2026, 3, 20, 11, 0),
|
||||
timezone: 'Asia/Shanghai',
|
||||
mode: ReminderPayloadMode.single,
|
||||
);
|
||||
queueManager.enqueueFromClick(payload);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: ReminderOverlay(
|
||||
queueManager: queueManager,
|
||||
onComplete: () {},
|
||||
onSnooze: (minutes) {},
|
||||
onArchive: () => archiveCalled = true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('完成'));
|
||||
await tester.pump();
|
||||
|
||||
expect(archiveCalled, true);
|
||||
});
|
||||
|
||||
testWidgets('点击稍后提醒显示下拉选项', (tester) async {
|
||||
final payload = ReminderPayload(
|
||||
eventId: '1',
|
||||
title: 'Test Meeting',
|
||||
startAt: DateTime(2026, 3, 20, 10, 0),
|
||||
endAt: DateTime(2026, 3, 20, 11, 0),
|
||||
timezone: 'Asia/Shanghai',
|
||||
mode: ReminderPayloadMode.single,
|
||||
);
|
||||
queueManager.enqueueFromClick(payload);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: ReminderOverlay(
|
||||
queueManager: queueManager,
|
||||
onComplete: () {},
|
||||
onSnooze: (minutes) {},
|
||||
onArchive: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('稍后提醒'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('5 分钟'), findsOneWidget);
|
||||
expect(find.text('15 分钟'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/calendar/reminders/reminder_queue_manager.dart';
|
||||
import 'package:social_app/features/calendar/reminders/models/reminder_payload.dart';
|
||||
|
||||
void main() {
|
||||
group('ReminderQueueManager', () {
|
||||
test('按点击顺序处理,第一条处理完后处理剩余的按时间排序', () {
|
||||
final manager = ReminderQueueManager();
|
||||
|
||||
final event1 = ReminderPayload(
|
||||
eventId: '1',
|
||||
title: 'Event 1',
|
||||
startAt: DateTime(2026, 3, 20, 10, 1),
|
||||
timezone: 'UTC',
|
||||
mode: ReminderPayloadMode.single,
|
||||
);
|
||||
final event2 = ReminderPayload(
|
||||
eventId: '2',
|
||||
title: 'Event 2',
|
||||
startAt: DateTime(2026, 3, 20, 10, 2),
|
||||
timezone: 'UTC',
|
||||
mode: ReminderPayloadMode.single,
|
||||
);
|
||||
final event3 = ReminderPayload(
|
||||
eventId: '3',
|
||||
title: 'Event 3',
|
||||
startAt: DateTime(2026, 3, 20, 10, 3),
|
||||
timezone: 'UTC',
|
||||
mode: ReminderPayloadMode.single,
|
||||
);
|
||||
|
||||
manager.enqueueFromClick(event2);
|
||||
manager.enqueuePending([event1, event3]);
|
||||
|
||||
expect(manager.currentPayload?.eventId, '2');
|
||||
manager.dequeueCurrent();
|
||||
expect(manager.currentPayload?.eventId, '1');
|
||||
manager.dequeueCurrent();
|
||||
expect(manager.currentPayload?.eventId, '3');
|
||||
manager.dequeueCurrent();
|
||||
expect(manager.isEmpty, true);
|
||||
});
|
||||
|
||||
test('单条通知处理完直接清空', () {
|
||||
final manager = ReminderQueueManager();
|
||||
final event = ReminderPayload(
|
||||
eventId: '1',
|
||||
title: 'Event 1',
|
||||
startAt: DateTime.now(),
|
||||
timezone: 'UTC',
|
||||
mode: ReminderPayloadMode.single,
|
||||
);
|
||||
|
||||
manager.enqueueFromClick(event);
|
||||
expect(manager.isEmpty, false);
|
||||
manager.dequeueCurrent();
|
||||
expect(manager.isEmpty, true);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
|
||||
|
||||
void main() {
|
||||
group('AgUiEvent parsing', () {
|
||||
test('parses TEXT_MESSAGE_END with ui_schema payload', () {
|
||||
final event = AgUiEvent.fromJson({
|
||||
'type': 'TEXT_MESSAGE_END',
|
||||
'messageId': 'msg_1',
|
||||
'answer': '你好',
|
||||
'role': 'assistant',
|
||||
'status': 'success',
|
||||
'ui_schema': {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'card',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'title', 'content': '创建成功'},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(event, isA<TextMessageEndEvent>());
|
||||
final textEnd = event as TextMessageEndEvent;
|
||||
expect(textEnd.messageId, 'msg_1');
|
||||
expect(textEnd.answer, '你好');
|
||||
expect(textEnd.uiSchema?['version'], '2.0');
|
||||
});
|
||||
|
||||
test('parses TOOL_CALL_RESULT snake_case fields', () {
|
||||
final event = AgUiEvent.fromJson({
|
||||
'type': 'TOOL_CALL_RESULT',
|
||||
'messageId': 'tool_1',
|
||||
'tool_call_id': 'call_1',
|
||||
'tool_name': 'calendar_read',
|
||||
'status': 'success',
|
||||
'result': '找到 2 条结果',
|
||||
});
|
||||
|
||||
expect(event, isA<ToolCallResultEvent>());
|
||||
final result = event as ToolCallResultEvent;
|
||||
expect(result.toolCallId, 'call_1');
|
||||
expect(result.toolName, 'calendar_read');
|
||||
expect(result.resultSummary, '找到 2 条结果');
|
||||
expect(result.status, 'success');
|
||||
});
|
||||
|
||||
test('parses history snapshot with ui_schema', () {
|
||||
final snapshot = HistorySnapshot.fromJson({
|
||||
'scope': 'history_day',
|
||||
'threadId': 'thread_1',
|
||||
'day': '2026-03-16',
|
||||
'hasMore': false,
|
||||
'messages': [
|
||||
{
|
||||
'id': 'm1',
|
||||
'seq': 1,
|
||||
'role': 'assistant',
|
||||
'content': '已处理',
|
||||
'ui_schema': {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'card',
|
||||
'children': [],
|
||||
},
|
||||
},
|
||||
'timestamp': '2026-03-16T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(snapshot.scope, 'history_day');
|
||||
expect(snapshot.messages, hasLength(1));
|
||||
expect(snapshot.messages.first.uiSchema, isNotNull);
|
||||
});
|
||||
|
||||
test('parses history user attachments list', () {
|
||||
final snapshot = HistorySnapshot.fromJson({
|
||||
'scope': 'history_day',
|
||||
'threadId': 'thread_1',
|
||||
'day': '2026-03-16',
|
||||
'hasMore': false,
|
||||
'messages': [
|
||||
{
|
||||
'id': 'm1',
|
||||
'seq': 1,
|
||||
'role': 'user',
|
||||
'content': '请看图',
|
||||
'attachments': [
|
||||
{'url': 'https://signed.example/a.png', 'mimeType': 'image/png'},
|
||||
{'url': 'https://signed.example/b.jpg', 'mimeType': 'image/jpeg'},
|
||||
],
|
||||
'timestamp': '2026-03-16T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
final userMessage = snapshot.messages.first;
|
||||
expect(userMessage.attachments, hasLength(2));
|
||||
expect(userMessage.attachments.first.url, 'https://signed.example/a.png');
|
||||
expect(userMessage.attachments.last.mimeType, 'image/jpeg');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/core/api/i_api_client.dart';
|
||||
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
|
||||
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
|
||||
|
||||
class _FakeApiClient implements IApiClient {
|
||||
_FakeApiClient({
|
||||
required this.sseLines,
|
||||
this.sseLineStreamFactory,
|
||||
this.runIdFactory,
|
||||
});
|
||||
|
||||
final List<String> sseLines;
|
||||
final Stream<String> Function()? sseLineStreamFactory;
|
||||
final String Function()? runIdFactory;
|
||||
final List<String> postPaths = <String>[];
|
||||
|
||||
@override
|
||||
Future<Response<T>> delete<T>(String path, {data, Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> get<T>(String path, {Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Stream<String>> getSseLines(
|
||||
String path, {
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
final streamFactory = sseLineStreamFactory;
|
||||
if (streamFactory != null) {
|
||||
return streamFactory();
|
||||
}
|
||||
return Stream<String>.fromIterable(sseLines);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> patch<T>(String path, {data, Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> put<T>(String path, {data, Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> post<T>(String path, {data, Options? options}) async {
|
||||
postPaths.add(path);
|
||||
if (path.contains('/cancel?runId=')) {
|
||||
final payload = <String, dynamic>{
|
||||
'threadId': 'thread-1',
|
||||
'runId': 'run-new',
|
||||
'accepted': true,
|
||||
};
|
||||
return Response<T>(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
data: payload as T,
|
||||
statusCode: 202,
|
||||
);
|
||||
}
|
||||
final runIdFactory = this.runIdFactory;
|
||||
final payload = <String, dynamic>{
|
||||
'taskId': 'task-1',
|
||||
'threadId': 'thread-1',
|
||||
'runId': runIdFactory != null ? runIdFactory() : 'run-new',
|
||||
'created': true,
|
||||
};
|
||||
return Response<T>(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
data: payload as T,
|
||||
statusCode: 202,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _buildSseEvent({
|
||||
required String id,
|
||||
required String type,
|
||||
required String payload,
|
||||
}) {
|
||||
return <String>['id: $id', 'event: $type', 'data: $payload', ''];
|
||||
}
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'sendMessage ignores stale run events and waits for expected run',
|
||||
() async {
|
||||
final oldRunLines = _buildSseEvent(
|
||||
id: '1',
|
||||
type: AgUiEventTypeWire.runStarted,
|
||||
payload:
|
||||
'{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-old"}',
|
||||
);
|
||||
final oldFinishedLines = _buildSseEvent(
|
||||
id: '2',
|
||||
type: AgUiEventTypeWire.runFinished,
|
||||
payload:
|
||||
'{"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-old"}',
|
||||
);
|
||||
final newRunLines = _buildSseEvent(
|
||||
id: '3',
|
||||
type: AgUiEventTypeWire.runStarted,
|
||||
payload:
|
||||
'{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}',
|
||||
);
|
||||
final newFinishedLines = _buildSseEvent(
|
||||
id: '4',
|
||||
type: AgUiEventTypeWire.runFinished,
|
||||
payload:
|
||||
'{"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-new"}',
|
||||
);
|
||||
|
||||
final service = AgUiService(
|
||||
apiClient: _FakeApiClient(
|
||||
sseLines: <String>[
|
||||
...oldRunLines,
|
||||
...oldFinishedLines,
|
||||
...newRunLines,
|
||||
...newFinishedLines,
|
||||
],
|
||||
),
|
||||
);
|
||||
final events = <AgUiEvent>[];
|
||||
service.onEvent = events.add;
|
||||
|
||||
await service.sendMessage('hello');
|
||||
|
||||
expect(events, hasLength(2));
|
||||
expect(events.first, isA<RunStartedEvent>());
|
||||
expect((events.first as RunStartedEvent).runId, 'run-new');
|
||||
expect(events.last, isA<RunFinishedEvent>());
|
||||
expect((events.last as RunFinishedEvent).runId, 'run-new');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'sendMessage accepts in-run terminal event without runId after binding',
|
||||
() async {
|
||||
final newRunLines = _buildSseEvent(
|
||||
id: '11',
|
||||
type: AgUiEventTypeWire.runStarted,
|
||||
payload:
|
||||
'{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}',
|
||||
);
|
||||
final noRunIdTextLines = _buildSseEvent(
|
||||
id: '12',
|
||||
type: AgUiEventTypeWire.textMessageEnd,
|
||||
payload:
|
||||
'{"type":"TEXT_MESSAGE_END","threadId":"thread-1","messageId":"m1","answer":"ok","role":"assistant","status":"success"}',
|
||||
);
|
||||
final noRunIdFinishedLines = _buildSseEvent(
|
||||
id: '13',
|
||||
type: AgUiEventTypeWire.runFinished,
|
||||
payload: '{"type":"RUN_FINISHED","threadId":"thread-1"}',
|
||||
);
|
||||
|
||||
final service = AgUiService(
|
||||
apiClient: _FakeApiClient(
|
||||
sseLines: <String>[
|
||||
...newRunLines,
|
||||
...noRunIdTextLines,
|
||||
...noRunIdFinishedLines,
|
||||
],
|
||||
),
|
||||
);
|
||||
final events = <AgUiEvent>[];
|
||||
service.onEvent = events.add;
|
||||
|
||||
await service.sendMessage('hello');
|
||||
|
||||
expect(events, hasLength(3));
|
||||
expect(events[0], isA<RunStartedEvent>());
|
||||
expect(events[1], isA<TextMessageEndEvent>());
|
||||
expect(events[2], isA<RunFinishedEvent>());
|
||||
},
|
||||
);
|
||||
|
||||
test('cancelCurrentRun actively closes current SSE subscription', () async {
|
||||
var streamCancelled = false;
|
||||
final streamController = StreamController<String>(
|
||||
onCancel: () {
|
||||
streamCancelled = true;
|
||||
},
|
||||
);
|
||||
|
||||
final service = AgUiService(
|
||||
apiClient: _FakeApiClient(
|
||||
sseLines: const <String>[],
|
||||
sseLineStreamFactory: () => streamController.stream,
|
||||
),
|
||||
);
|
||||
|
||||
final sendFuture = service.sendMessage('hello');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await service.cancelCurrentRun();
|
||||
|
||||
await sendFuture;
|
||||
expect(streamCancelled, isTrue);
|
||||
await streamController.close();
|
||||
});
|
||||
|
||||
test(
|
||||
'cancelCurrentRun calls backend cancel endpoint for active run',
|
||||
() async {
|
||||
final streamController = StreamController<String>();
|
||||
final fakeApi = _FakeApiClient(
|
||||
sseLines: const <String>[],
|
||||
sseLineStreamFactory: () => streamController.stream,
|
||||
);
|
||||
final service = AgUiService(apiClient: fakeApi);
|
||||
|
||||
final sendFuture = service.sendMessage('hello');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
for (final line in _buildSseEvent(
|
||||
id: '51',
|
||||
type: AgUiEventTypeWire.runStarted,
|
||||
payload:
|
||||
'{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}',
|
||||
)) {
|
||||
streamController.add(line);
|
||||
}
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
await service.cancelCurrentRun();
|
||||
await sendFuture;
|
||||
|
||||
expect(
|
||||
fakeApi.postPaths,
|
||||
contains('/api/v1/agent/runs/thread-1/cancel?runId=run-new'),
|
||||
);
|
||||
await streamController.close();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'new sendMessage cancels previous SSE subscription explicitly',
|
||||
() async {
|
||||
var firstStreamCancelled = false;
|
||||
final firstController = StreamController<String>(
|
||||
onCancel: () {
|
||||
firstStreamCancelled = true;
|
||||
},
|
||||
);
|
||||
final secondController = StreamController<String>();
|
||||
final streamQueue = <StreamController<String>>[
|
||||
firstController,
|
||||
secondController,
|
||||
];
|
||||
var streamIndex = 0;
|
||||
var runIndex = 0;
|
||||
|
||||
final service = AgUiService(
|
||||
apiClient: _FakeApiClient(
|
||||
sseLines: const <String>[],
|
||||
sseLineStreamFactory: () => streamQueue[streamIndex++].stream,
|
||||
runIdFactory: () {
|
||||
runIndex += 1;
|
||||
return 'run-$runIndex';
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final firstSendFuture = service.sendMessage('first');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final secondSendFuture = service.sendMessage('second');
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
for (final line in _buildSseEvent(
|
||||
id: '21',
|
||||
type: AgUiEventTypeWire.runStarted,
|
||||
payload: '{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-2"}',
|
||||
)) {
|
||||
secondController.add(line);
|
||||
}
|
||||
for (final line in _buildSseEvent(
|
||||
id: '22',
|
||||
type: AgUiEventTypeWire.runFinished,
|
||||
payload:
|
||||
'{"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-2"}',
|
||||
)) {
|
||||
secondController.add(line);
|
||||
}
|
||||
await secondController.close();
|
||||
|
||||
await firstSendFuture;
|
||||
await secondSendFuture;
|
||||
|
||||
expect(firstStreamCancelled, isTrue);
|
||||
await firstController.close();
|
||||
},
|
||||
);
|
||||
|
||||
test('sendMessage surfaces event callback exceptions', () async {
|
||||
final service = AgUiService(
|
||||
apiClient: _FakeApiClient(
|
||||
sseLines: <String>[
|
||||
..._buildSseEvent(
|
||||
id: '31',
|
||||
type: AgUiEventTypeWire.runStarted,
|
||||
payload:
|
||||
'{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}',
|
||||
),
|
||||
..._buildSseEvent(
|
||||
id: '32',
|
||||
type: AgUiEventTypeWire.runFinished,
|
||||
payload:
|
||||
'{"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-new"}',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
service.onEvent = (_) => throw StateError('event callback failed');
|
||||
|
||||
await expectLater(service.sendMessage('hello'), throwsA(isA<StateError>()));
|
||||
});
|
||||
|
||||
test('sendMessage fails when SSE closes before terminal event', () async {
|
||||
final startedLines = _buildSseEvent(
|
||||
id: '41',
|
||||
type: AgUiEventTypeWire.runStarted,
|
||||
payload: '{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}',
|
||||
);
|
||||
|
||||
final service = AgUiService(
|
||||
apiClient: _FakeApiClient(sseLines: <String>[...startedLines]),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
service.sendMessage('hello'),
|
||||
throwsA(
|
||||
isA<StateError>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('SSE closed before terminal event'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/chat/presentation/bloc/agent_stage.dart';
|
||||
|
||||
void main() {
|
||||
group('agent stage mapping', () {
|
||||
test('maps protocol step router to routing stage label', () {
|
||||
final stage = stageFromStepName('router');
|
||||
|
||||
expect(stage, AgentStage.routing);
|
||||
expect(stageLabel(stage), '意图识别中');
|
||||
});
|
||||
|
||||
test('maps protocol step worker to execution stage label', () {
|
||||
final stage = stageFromStepName('worker');
|
||||
|
||||
expect(stage, AgentStage.execution);
|
||||
expect(stageLabel(stage), '任务执行中');
|
||||
});
|
||||
|
||||
test('maps protocol step memory to memory stage label', () {
|
||||
final stage = stageFromStepName('memory');
|
||||
|
||||
expect(stage, AgentStage.memory);
|
||||
expect(stageLabel(stage), '记忆提取中');
|
||||
});
|
||||
|
||||
test('uses processing label when step is unknown', () {
|
||||
final stage = stageFromStepName('unexpected');
|
||||
|
||||
expect(stage, isNull);
|
||||
expect(stageLabel(stage), '任务处理中');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:social_app/core/api/i_api_client.dart';
|
||||
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
|
||||
import 'package:social_app/features/chat/data/models/chat_list_item.dart';
|
||||
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
|
||||
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
|
||||
|
||||
class _NoopApiClient implements IApiClient {
|
||||
@override
|
||||
Future<Response<T>> delete<T>(String path, {data, Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> get<T>(String path, {Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Stream<String>> getSseLines(
|
||||
String path, {
|
||||
Map<String, String>? headers,
|
||||
}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> patch<T>(String path, {data, Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> put<T>(String path, {data, Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> post<T>(String path, {data, Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeAgUiService extends AgUiService {
|
||||
_FakeAgUiService() : super(apiClient: _NoopApiClient());
|
||||
|
||||
Completer<SendMessageResult>? pendingResult;
|
||||
Completer<HistorySnapshot>? pendingHistory;
|
||||
Object? nextError;
|
||||
|
||||
@override
|
||||
Future<SendMessageResult> sendMessage(
|
||||
String content, {
|
||||
List<XFile>? images,
|
||||
}) async {
|
||||
final error = nextError;
|
||||
if (error != null) {
|
||||
nextError = null;
|
||||
throw error;
|
||||
}
|
||||
final pending = pendingResult;
|
||||
if (pending != null) {
|
||||
return pending.future;
|
||||
}
|
||||
return const SendMessageResult(uploadedAttachments: []);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<HistorySnapshot> loadHistory({DateTime? beforeDate}) async {
|
||||
final pending = pendingHistory;
|
||||
if (pending != null) {
|
||||
return pending.future;
|
||||
}
|
||||
return const HistorySnapshot(
|
||||
scope: 'history_day',
|
||||
threadId: null,
|
||||
day: null,
|
||||
hasMore: false,
|
||||
messages: <HistoryMessage>[],
|
||||
);
|
||||
}
|
||||
|
||||
void emitEvent(AgUiEvent event) {
|
||||
onEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('ChatBloc attachment sync', () {
|
||||
late _FakeAgUiService service;
|
||||
late ChatBloc bloc;
|
||||
|
||||
setUp(() {
|
||||
service = _FakeAgUiService();
|
||||
bloc = ChatBloc(service: service, apiClient: _NoopApiClient());
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await bloc.close();
|
||||
});
|
||||
|
||||
test('optimistic local image is replaced with uploaded url', () async {
|
||||
final completer = Completer<SendMessageResult>();
|
||||
service.pendingResult = completer;
|
||||
|
||||
final sendFuture = bloc.sendMessage(
|
||||
'hello',
|
||||
images: [
|
||||
XFile('/tmp/local.jpg', name: 'local.jpg', mimeType: 'image/jpeg'),
|
||||
],
|
||||
);
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final optimistic = bloc.state.items.last as TextMessageItem;
|
||||
expect(optimistic.attachments, hasLength(1));
|
||||
expect(optimistic.attachments.first['path'], '/tmp/local.jpg');
|
||||
expect(optimistic.attachments.first['uploading'], isTrue);
|
||||
|
||||
completer.complete(
|
||||
const SendMessageResult(
|
||||
uploadedAttachments: [
|
||||
UploadedAttachment(
|
||||
localPath: '/tmp/local.jpg',
|
||||
url: 'https://cdn.example.com/a.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await sendFuture;
|
||||
|
||||
final synced = bloc.state.items.last as TextMessageItem;
|
||||
expect(synced.attachments.first['url'], 'https://cdn.example.com/a.jpg');
|
||||
expect(synced.attachments.first['uploading'], isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'upload failure clears uploading state to avoid endless spinner',
|
||||
() async {
|
||||
service.nextError = StateError('upload failed');
|
||||
|
||||
await bloc.sendMessage(
|
||||
'hello',
|
||||
images: [
|
||||
XFile('/tmp/local.jpg', name: 'local.jpg', mimeType: 'image/jpeg'),
|
||||
],
|
||||
);
|
||||
|
||||
final failed = bloc.state.items.last as TextMessageItem;
|
||||
expect(failed.attachments.first['uploading'], isFalse);
|
||||
expect(bloc.state.error, contains('upload failed'));
|
||||
},
|
||||
);
|
||||
|
||||
test('tool call stays visible until assistant final output', () {
|
||||
service.emitEvent(
|
||||
ToolCallStartEvent(toolCallId: 'tool-1', toolCallName: 'ocr_image'),
|
||||
);
|
||||
var toolItem = bloc.state.items.last as ToolCallItem;
|
||||
expect(toolItem.status, ToolCallStatus.pending);
|
||||
|
||||
service.emitEvent(ToolCallEndEvent(toolCallId: 'tool-1'));
|
||||
toolItem = bloc.state.items.last as ToolCallItem;
|
||||
expect(toolItem.status, ToolCallStatus.executing);
|
||||
|
||||
service.emitEvent(
|
||||
ToolCallResultEvent(
|
||||
messageId: 'tool-msg-1',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'ocr_image',
|
||||
resultSummary: 'done',
|
||||
status: 'success',
|
||||
),
|
||||
);
|
||||
toolItem = bloc.state.items.last as ToolCallItem;
|
||||
expect(toolItem.status, ToolCallStatus.completed);
|
||||
|
||||
service.emitEvent(
|
||||
TextMessageEndEvent(
|
||||
messageId: 'assistant-1',
|
||||
answer: '识别完成',
|
||||
role: 'assistant',
|
||||
status: 'success',
|
||||
uiSchema: null,
|
||||
),
|
||||
);
|
||||
|
||||
expect(bloc.state.items.whereType<ToolCallItem>(), isEmpty);
|
||||
expect(bloc.state.items.whereType<TextMessageItem>().length, 1);
|
||||
});
|
||||
|
||||
test('run error keeps tool card and marks it failed', () {
|
||||
service.emitEvent(
|
||||
ToolCallStartEvent(toolCallId: 'tool-err', toolCallName: 'ocr_image'),
|
||||
);
|
||||
service.emitEvent(ToolCallEndEvent(toolCallId: 'tool-err'));
|
||||
|
||||
service.emitEvent(RunErrorEvent(message: 'runtime execution failed'));
|
||||
|
||||
final toolItem = bloc.state.items.whereType<ToolCallItem>().single;
|
||||
expect(toolItem.status, ToolCallStatus.error);
|
||||
expect(toolItem.errorMessage, '本次运行已失败');
|
||||
expect(bloc.state.error, 'runtime execution failed');
|
||||
});
|
||||
|
||||
test('run canceled error clears error and marks tool as canceled', () {
|
||||
service.emitEvent(
|
||||
ToolCallStartEvent(
|
||||
toolCallId: 'tool-cancel',
|
||||
toolCallName: 'ocr_image',
|
||||
),
|
||||
);
|
||||
service.emitEvent(ToolCallEndEvent(toolCallId: 'tool-cancel'));
|
||||
|
||||
service.emitEvent(
|
||||
RunErrorEvent(message: 'run canceled by user', code: 'RUN_CANCELED'),
|
||||
);
|
||||
|
||||
final toolItem = bloc.state.items.whereType<ToolCallItem>().single;
|
||||
expect(toolItem.status, ToolCallStatus.error);
|
||||
expect(toolItem.errorMessage, '本次运行已取消');
|
||||
expect(bloc.state.error, isNull);
|
||||
expect(bloc.state.isWaitingFirstToken, isFalse);
|
||||
expect(bloc.state.isStreaming, isFalse);
|
||||
expect(bloc.state.isCancelling, isFalse);
|
||||
});
|
||||
|
||||
test('text event with ui schema is rendered into chat items', () {
|
||||
service.emitEvent(RunStartedEvent(threadId: 'thread-1', runId: 'run-1'));
|
||||
|
||||
service.emitEvent(
|
||||
TextMessageEndEvent(
|
||||
messageId: 'assistant-1',
|
||||
answer: '这是测试回复',
|
||||
role: 'assistant',
|
||||
status: 'success',
|
||||
uiSchema: {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'body', 'content': '测试 UI 卡片'},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
service.emitEvent(RunFinishedEvent(threadId: 'thread-1', runId: 'run-1'));
|
||||
|
||||
final messages = bloc.state.items.whereType<TextMessageItem>().toList();
|
||||
final uiCards = bloc.state.items.whereType<ToolResultItem>().toList();
|
||||
|
||||
expect(messages, hasLength(1));
|
||||
expect(messages.single.content, '这是测试回复');
|
||||
expect(uiCards, hasLength(1));
|
||||
expect(uiCards.single.uiSchema['root'], isA<Map<String, dynamic>>());
|
||||
expect(bloc.state.isWaitingFirstToken, isFalse);
|
||||
expect(bloc.state.isStreaming, isFalse);
|
||||
expect(bloc.state.currentStage, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'history loading does not overwrite real-time text and ui events',
|
||||
() async {
|
||||
final historyCompleter = Completer<HistorySnapshot>();
|
||||
service.pendingHistory = historyCompleter;
|
||||
|
||||
final loadFuture = bloc.loadHistory();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
service.emitEvent(
|
||||
RunStartedEvent(threadId: 'thread-1', runId: 'run-1'),
|
||||
);
|
||||
service.emitEvent(
|
||||
TextMessageEndEvent(
|
||||
messageId: 'assistant-live',
|
||||
answer: '实时回复',
|
||||
role: 'assistant',
|
||||
status: 'success',
|
||||
uiSchema: {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'body', 'content': '实时 UI 卡片'},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
historyCompleter.complete(
|
||||
const HistorySnapshot(
|
||||
scope: 'history_day',
|
||||
threadId: 'thread-1',
|
||||
day: '2026-03-24',
|
||||
hasMore: false,
|
||||
messages: <HistoryMessage>[],
|
||||
),
|
||||
);
|
||||
|
||||
await loadFuture;
|
||||
|
||||
final texts = bloc.state.items.whereType<TextMessageItem>().toList();
|
||||
final uiCards = bloc.state.items.whereType<ToolResultItem>().toList();
|
||||
expect(texts.map((item) => item.id), contains('assistant-live'));
|
||||
expect(uiCards.map((item) => item.id), contains('assistant-live-ui'));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'abnormal SSE close recovers from history without raw bad-state error',
|
||||
() async {
|
||||
service.nextError = StateError(
|
||||
'SSE closed before terminal event for run',
|
||||
);
|
||||
service.pendingHistory = Completer<HistorySnapshot>()
|
||||
..complete(
|
||||
HistorySnapshot(
|
||||
scope: 'history_day',
|
||||
threadId: 'thread-1',
|
||||
day: '2026-03-24',
|
||||
hasMore: false,
|
||||
messages: <HistoryMessage>[
|
||||
HistoryMessage(
|
||||
id: 'assistant-history-1',
|
||||
seq: 2,
|
||||
role: 'assistant',
|
||||
content: '历史补偿回复',
|
||||
timestamp: DateTime(2026, 3, 24, 17, 0, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await bloc.sendMessage('你是谁?');
|
||||
|
||||
expect(bloc.state.error, isNull);
|
||||
expect(bloc.state.isWaitingFirstToken, isFalse);
|
||||
expect(bloc.state.isStreaming, isFalse);
|
||||
expect(bloc.state.currentStage, isNull);
|
||||
expect(
|
||||
bloc.state.items
|
||||
.whereType<TextMessageItem>()
|
||||
.map((item) => item.content)
|
||||
.toList(),
|
||||
contains('历史补偿回复'),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/chat/ui/navigation/ui_schema_navigation.dart';
|
||||
|
||||
void main() {
|
||||
test('buildUiSchemaNavigationTarget merges scalar params only', () {
|
||||
final target = buildUiSchemaNavigationTarget(
|
||||
path: '/calendar/dayweek',
|
||||
params: {
|
||||
'date': '2026-03-18',
|
||||
'from': 'home',
|
||||
'count': 2,
|
||||
'enabled': true,
|
||||
'ignored': {'nested': true},
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
target,
|
||||
'/calendar/dayweek?date=2026-03-18&from=home&count=2&enabled=true',
|
||||
);
|
||||
});
|
||||
|
||||
test('isValidInternalNavigationPath follows protocol constraints', () {
|
||||
expect(isValidInternalNavigationPath('/todo/123/edit'), true);
|
||||
expect(isValidInternalNavigationPath('https://evil.com'), false);
|
||||
expect(isValidInternalNavigationPath('/todo/123?x=1'), false);
|
||||
expect(isValidInternalNavigationPath('/todo/:id'), false);
|
||||
});
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
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() {
|
||||
group('UiSchemaRenderer', () {
|
||||
testWidgets('renders stack title and badge', (tester) async {
|
||||
final schema = {
|
||||
'version': '2.0',
|
||||
'locale': 'zh-CN',
|
||||
'status': 'success',
|
||||
'theme': 'default',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'card',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'title', 'content': '日程已创建'},
|
||||
{'type': 'badge', 'label': 'SUCCESS', 'status': 'success'},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(body: UiSchemaRenderer.renderSchema(schema)),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('日程已创建'), findsOneWidget);
|
||||
expect(find.text('SUCCESS'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders kv node values', (tester) async {
|
||||
final schema = {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'card',
|
||||
'children': [
|
||||
{
|
||||
'type': 'kv',
|
||||
'items': [
|
||||
{'key': 'title', 'label': '标题', 'value': '评审会'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(body: UiSchemaRenderer.renderSchema(schema)),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('标题'), findsOneWidget);
|
||||
expect(find.text('评审会'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders batch result list items in one card', (tester) async {
|
||||
final schema = {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'card',
|
||||
'status': 'warning',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'title', 'content': '日历操作完成'},
|
||||
{
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'gap': 8,
|
||||
'children': [
|
||||
{
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'card',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'body', 'content': '#1 create'},
|
||||
{'type': 'text', 'role': 'caption', 'content': '成功'},
|
||||
{'type': 'text', 'role': 'caption', 'content': '日程「晨会」已创建'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'card',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'body', 'content': '#2 delete'},
|
||||
{'type': 'text', 'role': 'caption', 'content': '失败'},
|
||||
{
|
||||
'type': 'text',
|
||||
'role': 'caption',
|
||||
'content': 'Schedule item not found',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(body: UiSchemaRenderer.renderSchema(schema)),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('日历操作完成'), findsOneWidget);
|
||||
expect(find.text('#1 create'), findsOneWidget);
|
||||
expect(find.text('#2 delete'), findsOneWidget);
|
||||
expect(find.text('成功'), findsOneWidget);
|
||||
expect(find.text('失败'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders fallback for invalid schema', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: UiSchemaRenderer.renderSchema({'version': '2.0'}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.textContaining('无效 UI Schema'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('handles navigation action by pushing target page', (
|
||||
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);
|
||||
expect(router.canPop(), isTrue);
|
||||
|
||||
router.pop();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('查看待办'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('uses replace navigation when replace is true', (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/456',
|
||||
'replace': true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
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']}'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
|
||||
await tester.tap(find.text('替换跳转'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('todo detail 456'), findsOneWidget);
|
||||
expect(router.canPop(), isFalse);
|
||||
|
||||
expect(find.text('todo detail 456'), 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/home/ui/controllers/home_keyboard_inset_calculator.dart';
|
||||
|
||||
void main() {
|
||||
test('subtracts bottom safe area from keyboard inset', () {
|
||||
final inset = HomeKeyboardInsetCalculator.compute(
|
||||
rawViewInsetBottom: 336,
|
||||
bottomViewPadding: 34,
|
||||
);
|
||||
|
||||
expect(inset, 302);
|
||||
});
|
||||
|
||||
test('returns zero when keyboard is effectively hidden', () {
|
||||
final inset = HomeKeyboardInsetCalculator.compute(
|
||||
rawViewInsetBottom: 6,
|
||||
bottomViewPadding: 34,
|
||||
);
|
||||
|
||||
expect(inset, 0);
|
||||
});
|
||||
|
||||
test('follows keyboard fallback immediately when inset decreases', () {
|
||||
final openedInset = HomeKeyboardInsetCalculator.compute(
|
||||
rawViewInsetBottom: 336,
|
||||
bottomViewPadding: 34,
|
||||
);
|
||||
final collapsedInset = HomeKeyboardInsetCalculator.compute(
|
||||
rawViewInsetBottom: 120,
|
||||
bottomViewPadding: 34,
|
||||
);
|
||||
|
||||
expect(openedInset, 302);
|
||||
expect(collapsedInset, 86);
|
||||
});
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/home/ui/controllers/home_message_viewport_controller.dart';
|
||||
|
||||
void main() {
|
||||
ViewportEvent buildEvent({
|
||||
required ViewportEventType type,
|
||||
required String conversationId,
|
||||
required int eventSeq,
|
||||
required double distanceToBottomPx,
|
||||
bool isFirstEnter = false,
|
||||
bool hasSavedViewport = false,
|
||||
bool hasAnchor = false,
|
||||
int deltaCount = 0,
|
||||
ViewportTriggerSource source = ViewportTriggerSource.system,
|
||||
}) {
|
||||
return ViewportEvent(
|
||||
type: type,
|
||||
conversationId: conversationId,
|
||||
eventSeq: eventSeq,
|
||||
triggerSource: source,
|
||||
deltaCount: deltaCount,
|
||||
anchor: const ViewportAnchor(messageId: null, offsetPx: null),
|
||||
timestamp: 1000 + eventSeq,
|
||||
viewportContext: ViewportContext(
|
||||
distanceToBottomPx: distanceToBottomPx,
|
||||
isFirstEnter: isFirstEnter,
|
||||
hasSavedViewport: hasSavedViewport,
|
||||
hasAnchor: hasAnchor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test('distance<=96 and new message => animateBottom', () {
|
||||
final controller = HomeMessageViewportController();
|
||||
final decision = controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.newMessageAppended,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 1,
|
||||
distanceToBottomPx: 80,
|
||||
deltaCount: 1,
|
||||
),
|
||||
);
|
||||
|
||||
expect(decision.action, ViewportAction.animateBottom);
|
||||
});
|
||||
|
||||
test('distance>96 and new message => showUnreadBadge', () {
|
||||
final controller = HomeMessageViewportController();
|
||||
controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.userScrollStateChanged,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 1,
|
||||
distanceToBottomPx: 200,
|
||||
source: ViewportTriggerSource.user,
|
||||
),
|
||||
);
|
||||
|
||||
final decision = controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.newMessageAppended,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 2,
|
||||
distanceToBottomPx: 200,
|
||||
deltaCount: 1,
|
||||
),
|
||||
);
|
||||
|
||||
expect(decision.action, ViewportAction.showUnreadBadge);
|
||||
expect(controller.unreadCount, 1);
|
||||
});
|
||||
|
||||
test('stale event is dropped by sequence', () {
|
||||
final controller = HomeMessageViewportController();
|
||||
controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.historyInitialLoaded,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 10,
|
||||
distanceToBottomPx: 0,
|
||||
isFirstEnter: true,
|
||||
),
|
||||
);
|
||||
|
||||
final decision = controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.newMessageAppended,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 9,
|
||||
distanceToBottomPx: 0,
|
||||
),
|
||||
);
|
||||
|
||||
expect(decision.action, ViewportAction.none);
|
||||
expect(decision.reason, 'stale-event');
|
||||
});
|
||||
|
||||
test('different conversations keep independent sequence', () {
|
||||
final controller = HomeMessageViewportController();
|
||||
controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.historyInitialLoaded,
|
||||
conversationId: 'A',
|
||||
eventSeq: 10,
|
||||
distanceToBottomPx: 0,
|
||||
isFirstEnter: true,
|
||||
),
|
||||
);
|
||||
|
||||
final decision = controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.historyInitialLoaded,
|
||||
conversationId: 'B',
|
||||
eventSeq: 1,
|
||||
distanceToBottomPx: 0,
|
||||
isFirstEnter: true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(decision.action, ViewportAction.jumpBottom);
|
||||
});
|
||||
|
||||
test('refresh keeps reading history position when far from bottom', () {
|
||||
final controller = HomeMessageViewportController();
|
||||
controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.userScrollStateChanged,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 1,
|
||||
distanceToBottomPx: 180,
|
||||
source: ViewportTriggerSource.user,
|
||||
),
|
||||
);
|
||||
|
||||
final decision = controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.sessionRefreshCompleted,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 2,
|
||||
distanceToBottomPx: 180,
|
||||
),
|
||||
);
|
||||
|
||||
expect(decision.action, ViewportAction.none);
|
||||
});
|
||||
|
||||
test('resume with saved viewport restores anchor', () {
|
||||
final controller = HomeMessageViewportController();
|
||||
final decision = controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.screenResumedFromSubRoute,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 1,
|
||||
distanceToBottomPx: 180,
|
||||
hasSavedViewport: true,
|
||||
hasAnchor: true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(decision.action, ViewportAction.restoreAnchor);
|
||||
expect(decision.reason, 'resume-restore-saved-viewport');
|
||||
});
|
||||
|
||||
test('prepend finish without anchor exits restoring state', () {
|
||||
final controller = HomeMessageViewportController();
|
||||
controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.historyPagePrependStarted,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 1,
|
||||
distanceToBottomPx: 200,
|
||||
hasAnchor: true,
|
||||
),
|
||||
);
|
||||
controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.historyPagePrependFinished,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 2,
|
||||
distanceToBottomPx: 200,
|
||||
hasAnchor: false,
|
||||
),
|
||||
);
|
||||
|
||||
final decision = controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.newMessageAppended,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 3,
|
||||
distanceToBottomPx: 200,
|
||||
deltaCount: 1,
|
||||
),
|
||||
);
|
||||
expect(decision.action, ViewportAction.showUnreadBadge);
|
||||
});
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/home/ui/navigation/home_return_policy.dart';
|
||||
|
||||
void main() {
|
||||
group('resolveHomeReturnAction', () {
|
||||
test('dock home action should always resolve to goHome', () {
|
||||
final action = resolveHomeReturnAction(canPop: true, isAuthEntry: false);
|
||||
expect(action, HomeReturnAction.goHomeForDock);
|
||||
});
|
||||
|
||||
test('second-level pages should return to home instead of exiting app', () {
|
||||
final action = resolveHomeReturnAction(
|
||||
canPop: false,
|
||||
isAuthEntry: false,
|
||||
forceGoHome: true,
|
||||
);
|
||||
expect(action, HomeReturnAction.goHome);
|
||||
});
|
||||
|
||||
test('business route with back stack resolves to dock home action', () {
|
||||
final action = resolveHomeReturnAction(canPop: true, isAuthEntry: false);
|
||||
expect(action, HomeReturnAction.goHomeForDock);
|
||||
});
|
||||
|
||||
test('business route without back stack falls back to go home', () {
|
||||
final action = resolveHomeReturnAction(canPop: false, isAuthEntry: false);
|
||||
expect(action, HomeReturnAction.goHome);
|
||||
});
|
||||
|
||||
test('auth entry always goes home directly', () {
|
||||
final action = resolveHomeReturnAction(canPop: true, isAuthEntry: true);
|
||||
expect(action, HomeReturnAction.goHome);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/chat/data/models/chat_list_item.dart';
|
||||
import 'package:social_app/features/home/ui/widgets/home_chat_item_renderer.dart';
|
||||
|
||||
void main() {
|
||||
ToolCallItem _toolCallItem(String toolName) {
|
||||
return ToolCallItem(
|
||||
id: 'tc-1',
|
||||
callId: 'tc-1',
|
||||
toolName: toolName,
|
||||
args: const {},
|
||||
status: ToolCallStatus.pending,
|
||||
timestamp: DateTime(2026, 1, 1),
|
||||
sender: MessageSender.ai,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pumpToolCallItem(WidgetTester tester, String toolName) async {
|
||||
final widget = MaterialApp(
|
||||
home: Scaffold(body: HomeChatItemRenderer.build(_toolCallItem(toolName))),
|
||||
);
|
||||
await tester.pumpWidget(widget);
|
||||
}
|
||||
|
||||
group('HomeChatItemRenderer tool name localization', () {
|
||||
testWidgets('renders dot style name in Chinese', (tester) async {
|
||||
await _pumpToolCallItem(tester, 'memory.write');
|
||||
expect(find.text('写入记忆'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders snake style alias in Chinese', (tester) async {
|
||||
await _pumpToolCallItem(tester, 'memory_write');
|
||||
expect(find.text('写入记忆'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('falls back to raw name for unknown tool', (tester) async {
|
||||
await _pumpToolCallItem(tester, 'unknown.tool');
|
||||
expect(find.text('unknown.tool'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:social_app/core/api/i_api_client.dart';
|
||||
import 'package:social_app/core/di/injection.dart';
|
||||
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
|
||||
import 'package:social_app/features/chat/data/models/chat_list_item.dart';
|
||||
import 'package:social_app/features/home/data/voice_recorder.dart';
|
||||
import 'package:social_app/features/home/ui/screens/home_screen.dart';
|
||||
import 'package:social_app/features/home/ui/widgets/home_attachment_strip.dart';
|
||||
import 'package:social_app/features/home/ui/widgets/home_floating_header.dart';
|
||||
import 'package:social_app/features/messages/data/inbox_api.dart';
|
||||
import 'package:social_app/shared/widgets/message_composer.dart';
|
||||
|
||||
class _PermissionDeniedRecorder implements VoiceRecorder {
|
||||
_PermissionDeniedRecorder();
|
||||
|
||||
int stopCalls = 0;
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
|
||||
@override
|
||||
Future<void> start() async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
throw StateError('录音权限未授权');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> stop() async {
|
||||
stopCalls += 1;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _DelayedSuccessRecorder implements VoiceRecorder {
|
||||
_DelayedSuccessRecorder();
|
||||
|
||||
int stopCalls = 0;
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
|
||||
@override
|
||||
Future<void> start() async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> stop() async {
|
||||
stopCalls += 1;
|
||||
return '/tmp/mock-recording.wav';
|
||||
}
|
||||
}
|
||||
|
||||
class _TestApiClient implements IApiClient {
|
||||
@override
|
||||
Future<Response<T>> delete<T>(String path, {data, Options? options}) async {
|
||||
return Response<T>(requestOptions: RequestOptions(path: path));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> get<T>(String path, {Options? options}) async {
|
||||
return Response<T>(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
data: <dynamic>[] as T,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Stream<String>> getSseLines(
|
||||
String path, {
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
return const Stream<String>.empty();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> patch<T>(String path, {data, Options? options}) async {
|
||||
return Response<T>(requestOptions: RequestOptions(path: path));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> put<T>(String path, {data, Options? options}) async {
|
||||
return Response<T>(requestOptions: RequestOptions(path: path));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> post<T>(String path, {data, Options? options}) async {
|
||||
return Response<T>(requestOptions: RequestOptions(path: path));
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late ChatBloc chatBloc;
|
||||
|
||||
setUp(() {
|
||||
final apiClient = _TestApiClient();
|
||||
if (sl.isRegistered<InboxApi>()) {
|
||||
sl.unregister<InboxApi>();
|
||||
}
|
||||
sl.registerSingleton<InboxApi>(InboxApi(apiClient));
|
||||
chatBloc = ChatBloc(apiClient: apiClient);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await chatBloc.close();
|
||||
if (sl.isRegistered<InboxApi>()) {
|
||||
await sl.unregister<InboxApi>();
|
||||
}
|
||||
});
|
||||
|
||||
Future<void> pumpHomeScreen(
|
||||
WidgetTester tester, {
|
||||
List<XFile> initialSelectedImages = const [],
|
||||
VoiceRecorder? voiceRecorder,
|
||||
Future<String> Function(String filePath)? onTranscribeAudio,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomeScreen(
|
||||
chatBloc: chatBloc,
|
||||
autoLoadHistory: false,
|
||||
initialSelectedImages: initialSelectedImages,
|
||||
voiceRecorder: voiceRecorder,
|
||||
onTranscribeAudio: onTranscribeAudio,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
List<ChatListItem> buildMessages(int count) {
|
||||
final base = DateTime(2026, 1, 1, 9, 0);
|
||||
return List<ChatListItem>.generate(count, (index) {
|
||||
return TextMessageItem(
|
||||
id: 'msg_$index',
|
||||
content: 'message $index',
|
||||
timestamp: base.add(Duration(minutes: index)),
|
||||
sender: index.isEven ? MessageSender.user : MessageSender.ai,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'home screen shows floating header, conversation stage, and bottom input stack',
|
||||
(tester) async {
|
||||
await pumpHomeScreen(tester);
|
||||
|
||||
expect(find.byKey(homeFloatingHeaderKey), findsOneWidget);
|
||||
expect(find.byKey(homeFloatingHeaderTitleKey), findsOneWidget);
|
||||
expect(find.text('Linksy'), findsOneWidget);
|
||||
expect(find.byKey(homeConversationStageKey), findsOneWidget);
|
||||
expect(find.byKey(homeBottomInputStackKey), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('empty state keeps clean stage without helper copy', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpHomeScreen(tester);
|
||||
|
||||
expect(find.byKey(homeConversationStageKey), findsOneWidget);
|
||||
expect(find.byKey(homeEmptyStateAmbientKey), findsOneWidget);
|
||||
expect(find.text('开始对话吧'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('selected images render in attachment strip above composer', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpHomeScreen(
|
||||
tester,
|
||||
initialSelectedImages: [XFile('/tmp/mock-image-a.png')],
|
||||
);
|
||||
|
||||
expect(find.byKey(homeAttachmentStripKey), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'long press release does not stop recorder before start succeeds',
|
||||
(tester) async {
|
||||
final recorder = _PermissionDeniedRecorder();
|
||||
await pumpHomeScreen(tester, voiceRecorder: recorder);
|
||||
|
||||
final holdArea = find.byKey(messageComposerHoldAreaKey);
|
||||
expect(holdArea, findsOneWidget);
|
||||
|
||||
final center = tester.getCenter(holdArea);
|
||||
final gesture = await tester.startGesture(center);
|
||||
await tester.pump(const Duration(milliseconds: 130));
|
||||
await gesture.up();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
expect(recorder.stopCalls, 0);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('switching to text mode auto focuses input', (tester) async {
|
||||
await pumpHomeScreen(tester);
|
||||
|
||||
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
final editable = tester.widget<EditableText>(find.byType(EditableText));
|
||||
expect(editable.focusNode.hasFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('single tap on input keeps text field focused', (tester) async {
|
||||
await pumpHomeScreen(tester);
|
||||
|
||||
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byType(EditableText));
|
||||
await tester.pump();
|
||||
|
||||
final editable = tester.widget<EditableText>(find.byType(EditableText));
|
||||
expect(editable.focusNode.hasFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('switching to text mode triggers keyboard show fallback', (
|
||||
tester,
|
||||
) async {
|
||||
var showCalls = 0;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.textInput, (call) async {
|
||||
if (call.method == 'TextInput.show') {
|
||||
showCalls += 1;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
addTearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.textInput, null);
|
||||
});
|
||||
|
||||
await pumpHomeScreen(tester);
|
||||
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 130));
|
||||
|
||||
expect(showCalls, greaterThanOrEqualTo(1));
|
||||
});
|
||||
|
||||
testWidgets('tap center of input lane focuses text field', (tester) async {
|
||||
await pumpHomeScreen(tester);
|
||||
|
||||
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
final composerRect = tester.getRect(find.byKey(messageComposerInnerKey));
|
||||
final centerLaneTap = Offset(
|
||||
composerRect.left + composerRect.width * 0.5,
|
||||
composerRect.center.dy,
|
||||
);
|
||||
|
||||
await tester.tapAt(centerLaneTap);
|
||||
await tester.pump();
|
||||
|
||||
final editable = tester.widget<EditableText>(find.byType(EditableText));
|
||||
expect(editable.focusNode.hasFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('tap focused input triggers at most one keyboard show', (
|
||||
tester,
|
||||
) async {
|
||||
var showCalls = 0;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.textInput, (call) async {
|
||||
if (call.method == 'TextInput.show') {
|
||||
showCalls += 1;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
addTearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.textInput, null);
|
||||
});
|
||||
|
||||
await pumpHomeScreen(tester);
|
||||
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 130));
|
||||
|
||||
showCalls = 0;
|
||||
await tester.tap(find.byType(EditableText));
|
||||
await tester.pump();
|
||||
|
||||
expect(showCalls, lessThanOrEqualTo(1));
|
||||
});
|
||||
|
||||
testWidgets('double toggle returns to hold-to-speak mode', (tester) async {
|
||||
await pumpHomeScreen(tester);
|
||||
|
||||
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.byType(EditableText), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(EditableText), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('rapid triple toggle ends in text mode with focused input', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpHomeScreen(tester);
|
||||
|
||||
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(messageComposerRightButtonKey));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
final editable = tester.widget<EditableText>(find.byType(EditableText));
|
||||
expect(editable.focusNode.hasFocus, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('release during delayed start continues to transcribe path', (
|
||||
tester,
|
||||
) async {
|
||||
final recorder = _DelayedSuccessRecorder();
|
||||
var transcribeCalls = 0;
|
||||
await pumpHomeScreen(
|
||||
tester,
|
||||
voiceRecorder: recorder,
|
||||
onTranscribeAudio: (_) async {
|
||||
transcribeCalls += 1;
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
final holdArea = find.byKey(messageComposerHoldAreaKey);
|
||||
final center = tester.getCenter(holdArea);
|
||||
final gesture = await tester.startGesture(center);
|
||||
await tester.pump(const Duration(milliseconds: 130));
|
||||
await gesture.up();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
expect(recorder.stopCalls, 1);
|
||||
expect(transcribeCalls, 1);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
});
|
||||
|
||||
testWidgets('cancel during delayed start skips transcribe path', (
|
||||
tester,
|
||||
) async {
|
||||
final recorder = _DelayedSuccessRecorder();
|
||||
var transcribeCalls = 0;
|
||||
await pumpHomeScreen(
|
||||
tester,
|
||||
voiceRecorder: recorder,
|
||||
onTranscribeAudio: (_) async {
|
||||
transcribeCalls += 1;
|
||||
return 'ignored';
|
||||
},
|
||||
);
|
||||
|
||||
final holdArea = find.byKey(messageComposerHoldAreaKey);
|
||||
final center = tester.getCenter(holdArea);
|
||||
final gesture = await tester.startGesture(center);
|
||||
await tester.pump(const Duration(milliseconds: 130));
|
||||
await gesture.moveBy(const Offset(0, -90));
|
||||
await tester.pump();
|
||||
await gesture.up();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
expect(recorder.stopCalls, 1);
|
||||
expect(transcribeCalls, 0);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'shows unread badge when new message arrives during history reading',
|
||||
(tester) async {
|
||||
await pumpHomeScreen(tester);
|
||||
|
||||
final initialItems = buildMessages(30);
|
||||
chatBloc.emit(const ChatState().copyWith(items: initialItems));
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
|
||||
final position = tester
|
||||
.state<ScrollableState>(find.byType(Scrollable))
|
||||
.position;
|
||||
position.jumpTo(0);
|
||||
await tester.pump(const Duration(milliseconds: 220));
|
||||
|
||||
final nextItems = [
|
||||
...initialItems,
|
||||
...buildMessages(1).map(
|
||||
(e) => (e as TextMessageItem).copyWith(
|
||||
id: 'new_1',
|
||||
content: 'new message',
|
||||
),
|
||||
),
|
||||
];
|
||||
chatBloc.emit(const ChatState().copyWith(items: nextItems));
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
|
||||
expect(find.textContaining('有1条新消息'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('tap unread badge scrolls bottom and clears badge', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpHomeScreen(tester);
|
||||
|
||||
final initialItems = buildMessages(30);
|
||||
chatBloc.emit(const ChatState().copyWith(items: initialItems));
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
|
||||
final position = tester
|
||||
.state<ScrollableState>(find.byType(Scrollable))
|
||||
.position;
|
||||
position.jumpTo(0);
|
||||
await tester.pump(const Duration(milliseconds: 220));
|
||||
|
||||
final nextItems = [
|
||||
...initialItems,
|
||||
...buildMessages(1).map(
|
||||
(e) => (e as TextMessageItem).copyWith(
|
||||
id: 'new_2',
|
||||
content: 'new message 2',
|
||||
),
|
||||
),
|
||||
];
|
||||
chatBloc.emit(const ChatState().copyWith(items: nextItems));
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
|
||||
await tester.tap(find.textContaining('有1条新消息'));
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
|
||||
expect(find.textContaining('有1条新消息'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/settings/data/models/automation_job_model.dart';
|
||||
|
||||
void main() {
|
||||
group('AutomationJobConfigModel', () {
|
||||
test('fromJson parses schedule correctly', () {
|
||||
final json = {
|
||||
'input_template': 'Hello {{name}}',
|
||||
'enabled_tools': ['tool1', 'tool2'],
|
||||
'context': {
|
||||
'source': 'latest_chat',
|
||||
'window_mode': 'day',
|
||||
'window_count': 5,
|
||||
},
|
||||
'schedule': {
|
||||
'type': 'weekly',
|
||||
'run_at': {'hour': 9, 'minute': 30},
|
||||
'weekdays': [1, 3, 5],
|
||||
},
|
||||
};
|
||||
|
||||
final model = AutomationJobConfigModel.fromJson(json);
|
||||
|
||||
expect(model.schedule.type, 'weekly');
|
||||
expect(model.schedule.runAt.hour, 9);
|
||||
expect(model.schedule.runAt.minute, 30);
|
||||
expect(model.schedule.weekdays, [1, 3, 5]);
|
||||
});
|
||||
});
|
||||
|
||||
group('AutomationJobModel', () {
|
||||
test('fromJson parses all fields correctly', () {
|
||||
final json = {
|
||||
'id': 'job-123',
|
||||
'owner_id': 'user-456',
|
||||
'bootstrap_key': 'key-789',
|
||||
'title': 'Daily Report',
|
||||
'timezone': 'America/New_York',
|
||||
'status': 'ACTIVE',
|
||||
'is_system': false,
|
||||
'config': {
|
||||
'input_template': 'Hello',
|
||||
'enabled_tools': ['tool1'],
|
||||
'context': {
|
||||
'source': 'latest_chat',
|
||||
'window_mode': 'day',
|
||||
'window_count': 2,
|
||||
},
|
||||
'schedule': {
|
||||
'type': 'daily',
|
||||
'run_at': {'hour': 9, 'minute': 0},
|
||||
},
|
||||
},
|
||||
'next_run_at': '2024-01-15T09:00:00Z',
|
||||
'last_run_at': '2024-01-14T09:00:00Z',
|
||||
'created_at': '2024-01-01T00:00:00Z',
|
||||
'updated_at': '2024-01-14T12:00:00Z',
|
||||
};
|
||||
|
||||
final model = AutomationJobModel.fromJson(json);
|
||||
|
||||
expect(model.id, 'job-123');
|
||||
expect(model.ownerId, 'user-456');
|
||||
expect(model.title, 'Daily Report');
|
||||
expect(model.config.schedule.type, 'daily');
|
||||
expect(model.config.schedule.runAt.hour, 9);
|
||||
expect(model.timezone, 'America/New_York');
|
||||
expect(model.isDaily, isTrue);
|
||||
expect(model.isWeekly, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('AutomationJobCreateRequest', () {
|
||||
test('toJson serializes schedule under config', () {
|
||||
final request = AutomationJobCreateRequest(
|
||||
title: 'New Job',
|
||||
timezone: 'UTC',
|
||||
status: 'ACTIVE',
|
||||
config: AutomationJobConfigModel(
|
||||
inputTemplate: 'Hello',
|
||||
enabledTools: ['tool1'],
|
||||
context: MessageContextConfigModel(
|
||||
source: 'latest_chat',
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
),
|
||||
schedule: ScheduleConfigModel(
|
||||
type: 'daily',
|
||||
runAt: ScheduleRunAtModel(hour: 10, minute: 0),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final json = request.toJson();
|
||||
|
||||
expect(json['title'], 'New Job');
|
||||
expect(json['timezone'], 'UTC');
|
||||
expect(json['status'], 'ACTIVE');
|
||||
expect((json['config'] as Map<String, dynamic>)['schedule'], {
|
||||
'type': 'daily',
|
||||
'run_at': {'hour': 10, 'minute': 0},
|
||||
});
|
||||
expect(json.containsKey('run_at'), isFalse);
|
||||
expect(json.containsKey('schedule_type'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('AutomationJobUpdateRequest', () {
|
||||
test('toJson includes schedule patch in config', () {
|
||||
final request = AutomationJobUpdateRequest(
|
||||
config: AutomationJobConfigPatchModel(
|
||||
schedule: ScheduleConfigModel(
|
||||
type: 'weekly',
|
||||
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
|
||||
weekdays: [2, 4],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final json = request.toJson();
|
||||
final configJson = json['config'] as Map<String, dynamic>;
|
||||
|
||||
expect(configJson['schedule'], {
|
||||
'type': 'weekly',
|
||||
'run_at': {'hour': 8, 'minute': 0},
|
||||
'weekdays': [2, 4],
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/core/cache/cache_policy.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';
|
||||
import 'package:social_app/features/settings/data/services/settings_user_cache.dart';
|
||||
import 'package:social_app/features/settings/data/services/user_profile_cache_repository.dart';
|
||||
import 'package:social_app/features/users/data/models/user_response.dart';
|
||||
|
||||
void main() {
|
||||
test('getProfile caches latest user in memory field', () async {
|
||||
var loadCalls = 0;
|
||||
final repository = UserProfileCacheRepository(
|
||||
store: HybridCacheStore(
|
||||
memory: MemoryCacheStore(),
|
||||
persistent: PersistentCacheStore(),
|
||||
),
|
||||
policy: const CachePolicy(
|
||||
softTtl: Duration(minutes: 2),
|
||||
hardTtl: Duration(minutes: 30),
|
||||
minRefreshInterval: Duration(minutes: 1),
|
||||
),
|
||||
remoteLoader: () async {
|
||||
loadCalls += 1;
|
||||
return const UserResponse(id: 'u1', username: 'first');
|
||||
},
|
||||
);
|
||||
final cache = SettingsUserCache(repository);
|
||||
|
||||
final first = await cache.getProfile();
|
||||
final second = await cache.getProfile();
|
||||
|
||||
expect(first.username, 'first');
|
||||
expect(second.username, 'first');
|
||||
expect(cache.cachedUser?.id, 'u1');
|
||||
expect(loadCalls, 1);
|
||||
});
|
||||
|
||||
test('invalidate clears memory cache', () {
|
||||
final repository = UserProfileCacheRepository(
|
||||
store: HybridCacheStore(
|
||||
memory: MemoryCacheStore(),
|
||||
persistent: PersistentCacheStore(),
|
||||
),
|
||||
remoteLoader: () async => const UserResponse(id: 'u1', username: 'first'),
|
||||
);
|
||||
final cache = SettingsUserCache(repository);
|
||||
|
||||
cache.set(const UserResponse(id: 'u1', username: 'first'));
|
||||
cache.invalidate();
|
||||
|
||||
expect(cache.cachedUser, isNull);
|
||||
});
|
||||
|
||||
test('set should update cached user immediately', () {
|
||||
final repository = UserProfileCacheRepository(
|
||||
store: HybridCacheStore(
|
||||
memory: MemoryCacheStore(),
|
||||
persistent: PersistentCacheStore(),
|
||||
),
|
||||
remoteLoader: () async => const UserResponse(id: 'u1', username: 'first'),
|
||||
);
|
||||
final cache = SettingsUserCache(repository);
|
||||
|
||||
cache.set(const UserResponse(id: 'u2', username: 'next'));
|
||||
|
||||
expect(cache.cachedUser?.id, 'u2');
|
||||
});
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/core/cache/cache_entry.dart';
|
||||
import 'package:social_app/core/cache/cache_policy.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';
|
||||
import 'package:social_app/features/settings/data/services/user_profile_cache_repository.dart';
|
||||
import 'package:social_app/features/users/data/models/user_response.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'repository should return persistent cache first then refresh in background',
|
||||
() async {
|
||||
final store = HybridCacheStore(
|
||||
memory: MemoryCacheStore(),
|
||||
persistent: PersistentCacheStore(),
|
||||
);
|
||||
const key = UserProfileCacheRepository.cacheKey;
|
||||
final stale = CacheEntry<UserResponse>(
|
||||
value: const UserResponse(id: 'u1', username: 'cached'),
|
||||
fetchedAt: DateTime(2026, 3, 20, 11, 0),
|
||||
);
|
||||
await store.persistent.write<CacheEntry<UserResponse>>(key, stale);
|
||||
|
||||
var refreshCalls = 0;
|
||||
final repository = UserProfileCacheRepository(
|
||||
store: store,
|
||||
now: () => DateTime(2026, 3, 20, 11, 5),
|
||||
policy: const CachePolicy(
|
||||
softTtl: Duration(minutes: 2),
|
||||
hardTtl: Duration(minutes: 30),
|
||||
minRefreshInterval: Duration(minutes: 1),
|
||||
),
|
||||
remoteLoader: () async {
|
||||
refreshCalls += 1;
|
||||
return const UserResponse(id: 'u1', username: 'remote');
|
||||
},
|
||||
);
|
||||
|
||||
final result = await repository.getProfile();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
expect(result.username, 'cached');
|
||||
expect(refreshCalls, 1);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import 'package:bloc_test/bloc_test.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:social_app/features/settings/data/models/automation_job_model.dart';
|
||||
import 'package:social_app/features/settings/data/services/automation_jobs_api.dart';
|
||||
import 'package:social_app/features/settings/presentation/cubits/automation_jobs_cubit.dart';
|
||||
|
||||
class MockAutomationJobsApi extends Mock implements AutomationJobsApi {}
|
||||
|
||||
class FakeAutomationJobUpdateRequest extends Fake
|
||||
implements AutomationJobUpdateRequest {}
|
||||
|
||||
void main() {
|
||||
late AutomationJobsCubit cubit;
|
||||
late MockAutomationJobsApi mockApi;
|
||||
|
||||
final testJob = AutomationJobModel(
|
||||
id: '1',
|
||||
ownerId: 'owner1',
|
||||
title: 'Test Job',
|
||||
timezone: 'UTC',
|
||||
status: 'ACTIVE',
|
||||
isSystem: false,
|
||||
config: AutomationJobConfigModel(
|
||||
inputTemplate: '',
|
||||
enabledTools: const [],
|
||||
context: MessageContextConfigModel(
|
||||
source: 'latest_chat',
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
),
|
||||
schedule: ScheduleConfigModel(
|
||||
type: 'daily',
|
||||
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
|
||||
),
|
||||
),
|
||||
nextRunAt: DateTime(2024, 1, 1),
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
updatedAt: DateTime(2024, 1, 1),
|
||||
);
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(FakeAutomationJobUpdateRequest());
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
mockApi = MockAutomationJobsApi();
|
||||
cubit = AutomationJobsCubit(mockApi);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
cubit.close();
|
||||
});
|
||||
|
||||
group('AutomationJobsCubit', () {
|
||||
test('initial state is correct', () {
|
||||
expect(cubit.state.jobs, isEmpty);
|
||||
expect(cubit.state.isLoading, isFalse);
|
||||
expect(cubit.state.error, isNull);
|
||||
});
|
||||
|
||||
blocTest<AutomationJobsCubit, AutomationJobsState>(
|
||||
'loadJobs success emits loading then jobs',
|
||||
build: () {
|
||||
when(() => mockApi.list()).thenAnswer((_) async => [testJob]);
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.loadJobs(),
|
||||
expect: () => [
|
||||
isA<AutomationJobsState>().having(
|
||||
(s) => s.isLoading,
|
||||
'isLoading',
|
||||
true,
|
||||
),
|
||||
isA<AutomationJobsState>()
|
||||
.having((s) => s.isLoading, 'isLoading', false)
|
||||
.having((s) => s.jobs, 'jobs', [testJob]),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<AutomationJobsCubit, AutomationJobsState>(
|
||||
'loadJobs failure emits loading then error',
|
||||
build: () {
|
||||
when(() => mockApi.list()).thenThrow(Exception('Network error'));
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.loadJobs(),
|
||||
expect: () => [
|
||||
isA<AutomationJobsState>().having(
|
||||
(s) => s.isLoading,
|
||||
'isLoading',
|
||||
true,
|
||||
),
|
||||
isA<AutomationJobsState>()
|
||||
.having((s) => s.isLoading, 'isLoading', false)
|
||||
.having((s) => s.error, 'error', isNotNull),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<AutomationJobsCubit, AutomationJobsState>(
|
||||
'deleteJob success calls loadJobs to refresh',
|
||||
build: () {
|
||||
when(() => mockApi.delete(any())).thenAnswer((_) async {});
|
||||
when(() => mockApi.list()).thenAnswer((_) async => []);
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.deleteJob('1'),
|
||||
verify: (_) {
|
||||
verify(() => mockApi.delete('1')).called(1);
|
||||
verify(() => mockApi.list()).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<AutomationJobsCubit, AutomationJobsState>(
|
||||
'deleteJob failure emits error without refreshing',
|
||||
build: () {
|
||||
when(() => mockApi.delete(any())).thenThrow(Exception('Delete failed'));
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.deleteJob('1'),
|
||||
expect: () => [
|
||||
isA<AutomationJobsState>().having((s) => s.error, 'error', isNotNull),
|
||||
],
|
||||
verify: (_) {
|
||||
verify(() => mockApi.delete('1')).called(1);
|
||||
verifyNever(() => mockApi.list());
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<AutomationJobsCubit, AutomationJobsState>(
|
||||
'updateJobStatus success replaces target job',
|
||||
build: () {
|
||||
when(
|
||||
() => mockApi.update(any(), any()),
|
||||
).thenAnswer((_) async => testJob.copyWith(status: 'disabled'));
|
||||
return cubit;
|
||||
},
|
||||
seed: () => AutomationJobsState(jobs: [testJob]),
|
||||
act: (c) => c.updateJobStatus(id: '1', enabled: false),
|
||||
expect: () => [
|
||||
isA<AutomationJobsState>().having(
|
||||
(s) => s.jobs.first.status,
|
||||
'updated status',
|
||||
'disabled',
|
||||
),
|
||||
],
|
||||
verify: (_) {
|
||||
verify(() => mockApi.update('1', any())).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<AutomationJobsCubit, AutomationJobsState>(
|
||||
'updateJobStatus failure emits error',
|
||||
build: () {
|
||||
when(
|
||||
() => mockApi.update(any(), any()),
|
||||
).thenThrow(Exception('Update failed'));
|
||||
return cubit;
|
||||
},
|
||||
seed: () => AutomationJobsState(jobs: [testJob]),
|
||||
act: (c) => c.updateJobStatus(id: '1', enabled: false),
|
||||
expect: () => [
|
||||
isA<AutomationJobsState>().having((s) => s.error, 'error', isNotNull),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import 'package:bloc_test/bloc_test.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:social_app/features/settings/data/models/automation_job_model.dart';
|
||||
import 'package:social_app/features/settings/data/services/automation_jobs_api.dart';
|
||||
import 'package:social_app/features/settings/presentation/cubits/job_detail_cubit.dart';
|
||||
|
||||
class MockAutomationJobsApi extends Mock implements AutomationJobsApi {}
|
||||
|
||||
class FakeAutomationJobUpdateRequest extends Fake
|
||||
implements AutomationJobUpdateRequest {}
|
||||
|
||||
class FakeAutomationJobCreateRequest extends Fake
|
||||
implements AutomationJobCreateRequest {}
|
||||
|
||||
void main() {
|
||||
late JobDetailCubit cubit;
|
||||
late MockAutomationJobsApi mockApi;
|
||||
|
||||
final testJob = AutomationJobModel(
|
||||
id: '1',
|
||||
ownerId: 'owner1',
|
||||
title: 'Test Job',
|
||||
timezone: 'UTC',
|
||||
status: 'ACTIVE',
|
||||
isSystem: false,
|
||||
config: AutomationJobConfigModel(
|
||||
inputTemplate: '',
|
||||
enabledTools: const [],
|
||||
context: MessageContextConfigModel(
|
||||
source: 'latest_chat',
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
),
|
||||
schedule: ScheduleConfigModel(
|
||||
type: 'daily',
|
||||
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
|
||||
),
|
||||
),
|
||||
nextRunAt: DateTime(2024, 1, 1),
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
updatedAt: DateTime(2024, 1, 1),
|
||||
);
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(FakeAutomationJobUpdateRequest());
|
||||
registerFallbackValue(FakeAutomationJobCreateRequest());
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
mockApi = MockAutomationJobsApi();
|
||||
cubit = JobDetailCubit(mockApi);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
cubit.close();
|
||||
});
|
||||
|
||||
group('JobDetailCubit', () {
|
||||
test('initial state is correct', () {
|
||||
expect(cubit.state.job, isNull);
|
||||
expect(cubit.state.isLoading, isFalse);
|
||||
expect(cubit.state.isSaving, isFalse);
|
||||
expect(cubit.state.error, isNull);
|
||||
});
|
||||
|
||||
blocTest<JobDetailCubit, JobDetailState>(
|
||||
'loadJob success emits loading then job',
|
||||
build: () {
|
||||
when(() => mockApi.get(any())).thenAnswer((_) async => testJob);
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.loadJob('1'),
|
||||
expect: () => [
|
||||
isA<JobDetailState>().having((s) => s.isLoading, 'isLoading', true),
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isLoading, 'isLoading', false)
|
||||
.having((s) => s.job, 'job', testJob),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<JobDetailCubit, JobDetailState>(
|
||||
'loadJob failure emits loading then error',
|
||||
build: () {
|
||||
when(() => mockApi.get(any())).thenThrow(Exception('Network error'));
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.loadJob('1'),
|
||||
expect: () => [
|
||||
isA<JobDetailState>().having((s) => s.isLoading, 'isLoading', true),
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isLoading, 'isLoading', false)
|
||||
.having((s) => s.error, 'error', isNotNull),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<JobDetailCubit, JobDetailState>(
|
||||
'updateJob success emits saving then job with saving false',
|
||||
build: () {
|
||||
when(
|
||||
() => mockApi.update(any(), any()),
|
||||
).thenAnswer((_) async => testJob);
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.updateJob('1', AutomationJobUpdateRequest()),
|
||||
expect: () => [
|
||||
isA<JobDetailState>().having((s) => s.isSaving, 'isSaving', true),
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isSaving, 'isSaving', false)
|
||||
.having((s) => s.job, 'job', testJob),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<JobDetailCubit, JobDetailState>(
|
||||
'updateJob failure emits saving then error',
|
||||
build: () {
|
||||
when(
|
||||
() => mockApi.update(any(), any()),
|
||||
).thenThrow(Exception('Update failed'));
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.updateJob('1', AutomationJobUpdateRequest()),
|
||||
expect: () => [
|
||||
isA<JobDetailState>().having((s) => s.isSaving, 'isSaving', true),
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isSaving, 'isSaving', false)
|
||||
.having((s) => s.error, 'error', isNotNull),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<JobDetailCubit, JobDetailState>(
|
||||
'deleteJob success emits saving then saving false',
|
||||
build: () {
|
||||
when(() => mockApi.delete(any())).thenAnswer((_) async {});
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.deleteJob('1'),
|
||||
expect: () => [
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isSaving, 'isSaving', true)
|
||||
.having((s) => s.error, 'error', isNull),
|
||||
isA<JobDetailState>().having((s) => s.isSaving, 'isSaving', false),
|
||||
],
|
||||
verify: (_) {
|
||||
verify(() => mockApi.delete('1')).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<JobDetailCubit, JobDetailState>(
|
||||
'deleteJob failure emits saving then error',
|
||||
build: () {
|
||||
when(() => mockApi.delete(any())).thenThrow(Exception('Delete failed'));
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.deleteJob('1'),
|
||||
expect: () => [
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isSaving, 'isSaving', true)
|
||||
.having((s) => s.error, 'error', isNull),
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isSaving, 'isSaving', false)
|
||||
.having((s) => s.error, 'error', isNotNull),
|
||||
],
|
||||
verify: (_) {
|
||||
verify(() => mockApi.delete('1')).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<JobDetailCubit, JobDetailState>(
|
||||
'createJob success emits saving then created job',
|
||||
build: () {
|
||||
when(() => mockApi.create(any())).thenAnswer((_) async => testJob);
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.createJob(
|
||||
AutomationJobCreateRequest(
|
||||
title: 'New Job',
|
||||
timezone: 'Asia/Shanghai',
|
||||
status: 'active',
|
||||
config: AutomationJobConfigModel(
|
||||
inputTemplate: 'hello',
|
||||
enabledTools: const ['memory.write'],
|
||||
context: MessageContextConfigModel(
|
||||
source: 'latest_chat',
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
),
|
||||
schedule: ScheduleConfigModel(
|
||||
type: 'daily',
|
||||
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
expect: () => [
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isSaving, 'isSaving', true)
|
||||
.having((s) => s.error, 'error', isNull),
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isSaving, 'isSaving', false)
|
||||
.having((s) => s.job, 'job', testJob),
|
||||
],
|
||||
verify: (_) {
|
||||
verify(() => mockApi.create(any())).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<JobDetailCubit, JobDetailState>(
|
||||
'createJob failure emits saving then error',
|
||||
build: () {
|
||||
when(() => mockApi.create(any())).thenThrow(Exception('Create failed'));
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.createJob(
|
||||
AutomationJobCreateRequest(
|
||||
title: 'New Job',
|
||||
timezone: 'Asia/Shanghai',
|
||||
status: 'active',
|
||||
config: AutomationJobConfigModel(
|
||||
inputTemplate: 'hello',
|
||||
enabledTools: const ['memory.write'],
|
||||
context: MessageContextConfigModel(
|
||||
source: 'latest_chat',
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
),
|
||||
schedule: ScheduleConfigModel(
|
||||
type: 'daily',
|
||||
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
expect: () => [
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isSaving, 'isSaving', true)
|
||||
.having((s) => s.error, 'error', isNull),
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isSaving, 'isSaving', false)
|
||||
.having((s) => s.error, 'error', isNotNull),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
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';
|
||||
import 'package:social_app/core/api/i_api_client.dart';
|
||||
import 'package:social_app/core/di/injection.dart';
|
||||
import 'package:social_app/features/friends/data/friends_api.dart';
|
||||
import 'package:social_app/features/settings/data/services/settings_user_cache.dart';
|
||||
import 'package:social_app/features/settings/data/services/user_profile_cache_repository.dart';
|
||||
import 'package:social_app/features/settings/ui/screens/settings_screen.dart';
|
||||
import 'package:social_app/features/users/data/models/user_response.dart';
|
||||
import 'package:social_app/features/users/data/users_api.dart';
|
||||
|
||||
class _TestApiClient implements IApiClient {
|
||||
@override
|
||||
Future<Response<T>> delete<T>(String path, {data, Options? options}) async {
|
||||
return Response<T>(requestOptions: RequestOptions(path: path));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> get<T>(String path, {Options? options}) async {
|
||||
return Response<T>(requestOptions: RequestOptions(path: path));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Stream<String>> getSseLines(
|
||||
String path, {
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
return const Stream<String>.empty();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> patch<T>(String path, {data, Options? options}) async {
|
||||
return Response<T>(requestOptions: RequestOptions(path: path));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> post<T>(String path, {data, Options? options}) async {
|
||||
return Response<T>(requestOptions: RequestOptions(path: path));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> put<T>(String path, {data, Options? options}) async {
|
||||
return Response<T>(requestOptions: RequestOptions(path: path));
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeUsersApi extends UsersApi {
|
||||
_FakeUsersApi(super.client);
|
||||
|
||||
int getMeCalls = 0;
|
||||
|
||||
@override
|
||||
Future<UserResponse> getMe() async {
|
||||
getMeCalls += 1;
|
||||
return const UserResponse(
|
||||
id: 'u1',
|
||||
username: 'Linksy',
|
||||
phone: '13800000000',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeFriendsApi extends FriendsApi {
|
||||
_FakeFriendsApi(super.client);
|
||||
|
||||
@override
|
||||
Future<List<FriendResponse>> getFriends() async {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late _FakeUsersApi usersApi;
|
||||
|
||||
setUp(() {
|
||||
final apiClient = _TestApiClient();
|
||||
if (sl.isRegistered<UsersApi>()) {
|
||||
sl.unregister<UsersApi>();
|
||||
}
|
||||
if (sl.isRegistered<FriendsApi>()) {
|
||||
sl.unregister<FriendsApi>();
|
||||
}
|
||||
if (sl.isRegistered<SettingsUserCache>()) {
|
||||
sl.unregister<SettingsUserCache>();
|
||||
}
|
||||
if (sl.isRegistered<UserProfileCacheRepository>()) {
|
||||
sl.unregister<UserProfileCacheRepository>();
|
||||
}
|
||||
usersApi = _FakeUsersApi(apiClient);
|
||||
final repository = UserProfileCacheRepository(
|
||||
store: HybridCacheStore(
|
||||
memory: MemoryCacheStore(),
|
||||
persistent: PersistentCacheStore(),
|
||||
),
|
||||
remoteLoader: usersApi.getMe,
|
||||
);
|
||||
sl.registerSingleton<UsersApi>(usersApi);
|
||||
sl.registerSingleton<FriendsApi>(_FakeFriendsApi(apiClient));
|
||||
sl.registerSingleton<UserProfileCacheRepository>(repository);
|
||||
sl.registerSingleton<SettingsUserCache>(SettingsUserCache(repository));
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (sl.isRegistered<UsersApi>()) {
|
||||
await sl.unregister<UsersApi>();
|
||||
}
|
||||
if (sl.isRegistered<FriendsApi>()) {
|
||||
await sl.unregister<FriendsApi>();
|
||||
}
|
||||
if (sl.isRegistered<SettingsUserCache>()) {
|
||||
await sl.unregister<SettingsUserCache>();
|
||||
}
|
||||
if (sl.isRegistered<UserProfileCacheRepository>()) {
|
||||
await sl.unregister<UserProfileCacheRepository>();
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('settings screen removes account row and shows logout button', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('我的账户'), findsNothing);
|
||||
expect(find.text('退出登录'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('settings profile hero shows edit icon entry', (tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(settingsProfileEditButtonKey), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('settings screen re-entry uses cached user', (tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump();
|
||||
|
||||
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(usersApi.getMeCalls, 1);
|
||||
});
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:social_app/features/todo/data/todo_api.dart';
|
||||
import 'package:social_app/core/api/i_api_client.dart';
|
||||
|
||||
class MockApiClient extends Mock implements IApiClient {}
|
||||
|
||||
class FakeRequestOptions extends Fake implements RequestOptions {}
|
||||
|
||||
void main() {
|
||||
late TodoApi todoApi;
|
||||
late MockApiClient mockClient;
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(FakeRequestOptions());
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
mockClient = MockApiClient();
|
||||
todoApi = TodoApi(mockClient);
|
||||
});
|
||||
|
||||
group('TodoApi.updateTodo - cross-quadrant drag', () {
|
||||
test(
|
||||
'calls PATCH with priority when moving to different quadrant',
|
||||
() async {
|
||||
const todoId = 'todo-123';
|
||||
const targetPriority = 2;
|
||||
const targetOrder = 0;
|
||||
|
||||
when(
|
||||
() => mockClient.patch(any(), data: any(named: 'data')),
|
||||
).thenAnswer(
|
||||
(_) async => Response(
|
||||
requestOptions: RequestOptions(path: '/api/v1/todos/$todoId'),
|
||||
data: {
|
||||
'id': todoId,
|
||||
'owner_id': 'user-1',
|
||||
'title': 'Test Todo',
|
||||
'priority': targetPriority,
|
||||
'order': targetOrder,
|
||||
'status': 'pending',
|
||||
'created_at': '2024-01-01T00:00:00Z',
|
||||
'updated_at': '2024-01-01T00:00:00Z',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final result = await todoApi.updateTodo(
|
||||
todoId,
|
||||
priority: targetPriority,
|
||||
order: targetOrder,
|
||||
);
|
||||
|
||||
expect(result.priority, targetPriority);
|
||||
expect(result.order, targetOrder);
|
||||
verify(
|
||||
() => mockClient.patch(
|
||||
'/api/v1/todos/$todoId',
|
||||
data: {'priority': targetPriority, 'order': targetOrder},
|
||||
),
|
||||
).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
test('throws when API fails - triggers rollback', () async {
|
||||
const todoId = 'todo-123';
|
||||
|
||||
when(
|
||||
() => mockClient.patch(any(), data: any(named: 'data')),
|
||||
).thenThrow(Exception('Network error'));
|
||||
|
||||
expect(
|
||||
() => todoApi.updateTodo(todoId, priority: 2, order: 0),
|
||||
throwsException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('Quadrant priority mapping', () {
|
||||
test('priority 1 = important urgent (Q1)', () async {
|
||||
when(() => mockClient.patch(any(), data: any(named: 'data'))).thenAnswer(
|
||||
(_) async => Response(
|
||||
requestOptions: RequestOptions(path: '/api/v1/todos/todo-1'),
|
||||
data: {
|
||||
'id': 'todo-1',
|
||||
'owner_id': 'user-1',
|
||||
'title': 'Q1 Todo',
|
||||
'priority': 1,
|
||||
'order': 1,
|
||||
'status': 'pending',
|
||||
'created_at': '2024-01-01T00:00:00Z',
|
||||
'updated_at': '2024-01-01T00:00:00Z',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final result = await todoApi.updateTodo('todo-1', priority: 1, order: 1);
|
||||
expect(result.priority, 1);
|
||||
expect(result.order, 1);
|
||||
});
|
||||
|
||||
test('priority 2 = important not urgent (Q3)', () async {
|
||||
when(() => mockClient.patch(any(), data: any(named: 'data'))).thenAnswer(
|
||||
(_) async => Response(
|
||||
requestOptions: RequestOptions(path: '/api/v1/todos/todo-2'),
|
||||
data: {
|
||||
'id': 'todo-2',
|
||||
'owner_id': 'user-1',
|
||||
'title': 'Q3 Todo',
|
||||
'priority': 2,
|
||||
'order': 2,
|
||||
'status': 'pending',
|
||||
'created_at': '2024-01-01T00:00:00Z',
|
||||
'updated_at': '2024-01-01T00:00:00Z',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final result = await todoApi.updateTodo('todo-2', priority: 2, order: 2);
|
||||
expect(result.priority, 2);
|
||||
expect(result.order, 2);
|
||||
});
|
||||
|
||||
test('priority 3 = urgent not important (Q2)', () async {
|
||||
when(() => mockClient.patch(any(), data: any(named: 'data'))).thenAnswer(
|
||||
(_) async => Response(
|
||||
requestOptions: RequestOptions(path: '/api/v1/todos/todo-3'),
|
||||
data: {
|
||||
'id': 'todo-3',
|
||||
'owner_id': 'user-1',
|
||||
'title': 'Q2 Todo',
|
||||
'priority': 3,
|
||||
'order': 0,
|
||||
'status': 'pending',
|
||||
'created_at': '2024-01-01T00:00:00Z',
|
||||
'updated_at': '2024-01-01T00:00:00Z',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final result = await todoApi.updateTodo('todo-3', priority: 3, order: 0);
|
||||
expect(result.priority, 3);
|
||||
expect(result.order, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('TodoApi.reorderTodos', () {
|
||||
test('calls batch reorder endpoint once', () async {
|
||||
when(() => mockClient.patch(any(), data: any(named: 'data'))).thenAnswer(
|
||||
(_) async => Response(
|
||||
requestOptions: RequestOptions(path: '/api/v1/todos/reorder'),
|
||||
data: {},
|
||||
),
|
||||
);
|
||||
|
||||
await todoApi.reorderTodos(const [
|
||||
TodoReorderItemPayload(id: 'todo-1', priority: 1, order: 0),
|
||||
TodoReorderItemPayload(id: 'todo-2', priority: 1, order: 1),
|
||||
]);
|
||||
|
||||
verify(
|
||||
() => mockClient.patch(
|
||||
'/api/v1/todos/reorder',
|
||||
data: {
|
||||
'items': [
|
||||
{'id': 'todo-1', 'priority': 1, 'order': 0},
|
||||
{'id': 'todo-2', 'priority': 1, 'order': 1},
|
||||
],
|
||||
},
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:social_app/core/cache/cache_entry.dart';
|
||||
import 'package:social_app/core/cache/cache_invalidator.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';
|
||||
import 'package:social_app/features/todo/data/todo_api.dart';
|
||||
import 'package:social_app/features/todo/data/todo_repository.dart';
|
||||
|
||||
class _MockTodoApi extends Mock implements TodoApi {}
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'complete todo should optimistically remove item and invalidate pending list key',
|
||||
() async {
|
||||
final api = _MockTodoApi();
|
||||
final store = HybridCacheStore(
|
||||
memory: MemoryCacheStore(),
|
||||
persistent: PersistentCacheStore(),
|
||||
);
|
||||
final invalidator = CacheInvalidator(store: store);
|
||||
final repository = TodoRepository(
|
||||
api: api,
|
||||
store: store,
|
||||
invalidator: invalidator,
|
||||
);
|
||||
|
||||
final cached = TodoResponse(
|
||||
id: 'todo_1',
|
||||
ownerId: 'u1',
|
||||
title: 't1',
|
||||
priority: 1,
|
||||
order: 0,
|
||||
status: 'pending',
|
||||
createdAt: DateTime(2026, 3, 20, 10),
|
||||
updatedAt: DateTime(2026, 3, 20, 10),
|
||||
);
|
||||
await store.write<CacheEntry<List<TodoResponse>>>(
|
||||
TodoRepository.pendingListKey,
|
||||
CacheEntry(value: [cached], fetchedAt: DateTime(2026, 3, 20, 10, 0)),
|
||||
);
|
||||
|
||||
when(
|
||||
() => api.completeTodo('todo_1'),
|
||||
).thenAnswer((_) async => cached.copyWith(status: 'completed'));
|
||||
|
||||
await repository.completeTodo('todo_1');
|
||||
|
||||
final updated = await store.read<CacheEntry<List<TodoResponse>>>(
|
||||
TodoRepository.pendingListKey,
|
||||
);
|
||||
expect(updated, isNull);
|
||||
expect(invalidator.wasInvalidated(TodoRepository.pendingListKey), true);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('AndroidManifest declares ActionBroadcastReceiver', () {
|
||||
final manifestFile = File('android/app/src/main/AndroidManifest.xml');
|
||||
|
||||
expect(manifestFile.existsSync(), isTrue);
|
||||
|
||||
final manifestContent = manifestFile.readAsStringSync();
|
||||
|
||||
expect(
|
||||
manifestContent,
|
||||
contains(
|
||||
'com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver',
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('AppDelegate registers flutter local notifications callback', () {
|
||||
final appDelegateFile = File('ios/Runner/AppDelegate.swift');
|
||||
|
||||
expect(appDelegateFile.existsSync(), isTrue);
|
||||
|
||||
final appDelegateContent = appDelegateFile.readAsStringSync();
|
||||
|
||||
expect(
|
||||
appDelegateContent,
|
||||
contains('FlutterLocalNotificationsPlugin.setPluginRegistrantCallback'),
|
||||
);
|
||||
expect(
|
||||
appDelegateContent,
|
||||
contains('GeneratedPluginRegistrant.register(with: registry)'),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/shared/utils/phone_display_formatter.dart';
|
||||
|
||||
void main() {
|
||||
group('formatPhoneForDisplay', () {
|
||||
test('formats +86 numbers as local masked style', () {
|
||||
final formatted = formatPhoneForDisplay('+8613812345678');
|
||||
|
||||
expect(formatted, '138****5678');
|
||||
});
|
||||
|
||||
test('keeps international country code while masking middle part', () {
|
||||
final formatted = formatPhoneForDisplay('+14155552671');
|
||||
|
||||
expect(formatted, '+1 ****2671');
|
||||
});
|
||||
|
||||
test('normalizes separators before formatting', () {
|
||||
final formatted = formatPhoneForDisplay('(+86) 138-1234-5678');
|
||||
|
||||
expect(formatted, '138****5678');
|
||||
});
|
||||
|
||||
test('prefers longer country code in fallback detection', () {
|
||||
final formatted = formatPhoneForDisplay('+33612345678');
|
||||
|
||||
expect(formatted, '+33 ****5678');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/shared/utils/tool_name_localizer.dart';
|
||||
|
||||
void main() {
|
||||
group('localizeToolName', () {
|
||||
test('translates dot style tool names', () {
|
||||
expect(localizeToolName('memory.write'), '写入记忆');
|
||||
expect(localizeToolName('calendar.read'), '读取日程');
|
||||
});
|
||||
|
||||
test('translates snake style aliases', () {
|
||||
expect(localizeToolName('memory_write'), '写入记忆');
|
||||
expect(localizeToolName('calendar_read'), '读取日程');
|
||||
});
|
||||
|
||||
test('returns raw name for unknown tool', () {
|
||||
expect(localizeToolName('unknown.tool'), 'unknown.tool');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user