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

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