refactor(apps): 主题系统迁移至 ColorScheme + 扩展架构并支持 Dark Mode
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/app/di/injection.dart';
|
||||
import 'package:social_app/app/router/app_router.dart';
|
||||
import 'package:social_app/app/router/app_routes.dart';
|
||||
import 'package:social_app/core/network/i_api_client.dart';
|
||||
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
|
||||
import 'package:social_app/features/auth/presentation/bloc/auth_state.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() async {
|
||||
if (sl.isRegistered<ChatBloc>()) {
|
||||
await sl.unregister<ChatBloc>();
|
||||
}
|
||||
sl.registerSingleton<ChatBloc>(ChatBloc(apiClient: _FakeApiClient()));
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (sl.isRegistered<ChatBloc>()) {
|
||||
await sl.unregister<ChatBloc>();
|
||||
}
|
||||
});
|
||||
|
||||
group('resolveAuthRedirect', () {
|
||||
test('redirects unauthenticated home access to login', () {
|
||||
final result = resolveAuthRedirect(
|
||||
authState: const AuthUnauthenticated(),
|
||||
matchedLocation: AppRoutes.homeMain,
|
||||
);
|
||||
|
||||
expect(result, AppRoutes.authLogin);
|
||||
});
|
||||
|
||||
test('redirects authenticated login access to home', () {
|
||||
final result = resolveAuthRedirect(
|
||||
authState: const AuthAuthenticated(
|
||||
user: AuthUser(id: 'u1', phone: '13800138000'),
|
||||
),
|
||||
matchedLocation: AppRoutes.authLogin,
|
||||
);
|
||||
|
||||
expect(result, AppRoutes.homeMain);
|
||||
});
|
||||
|
||||
test('redirects auth checking state to boot route', () {
|
||||
final result = resolveAuthRedirect(
|
||||
authState: AuthLoading(),
|
||||
matchedLocation: AppRoutes.homeMain,
|
||||
);
|
||||
|
||||
expect(result, AppRoutes.authBoot);
|
||||
});
|
||||
|
||||
test('redirects boot route to login for unauthenticated state', () {
|
||||
final result = resolveAuthRedirect(
|
||||
authState: const AuthUnauthenticated(),
|
||||
matchedLocation: AppRoutes.authBoot,
|
||||
);
|
||||
|
||||
expect(result, AppRoutes.authLogin);
|
||||
});
|
||||
});
|
||||
|
||||
test('home route screen is wrapped with ChatBloc provider', () {
|
||||
final widget = buildHomeRouteScreen();
|
||||
|
||||
expect(widget, isA<BlocProvider<ChatBloc>>());
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeApiClient 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>> post<T>(String path, {data, Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> put<T>(String path, {data, Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/data/cache/cache_policy.dart';
|
||||
import 'package:social_app/data/cache/cached_repository.dart';
|
||||
import 'package:social_app/data/cache/hybrid_cache_store.dart';
|
||||
import 'package:social_app/data/cache/memory_cache_store.dart';
|
||||
import 'package:social_app/data/cache/persistent_cache_store.dart';
|
||||
|
||||
class _IntCachedRepository extends CachedRepository<int> {
|
||||
int loadCount = 0;
|
||||
|
||||
_IntCachedRepository({required super.store})
|
||||
: super(
|
||||
policy: const CachePolicy(
|
||||
softTtl: Duration(hours: 1),
|
||||
hardTtl: Duration(hours: 2),
|
||||
minRefreshInterval: Duration(minutes: 10),
|
||||
),
|
||||
);
|
||||
|
||||
Future<int> fetch({bool forceRefresh = false}) {
|
||||
return getOrLoad(
|
||||
key: 'test:number',
|
||||
forceRefresh: forceRefresh,
|
||||
loadFromRemote: () async {
|
||||
loadCount += 1;
|
||||
return loadCount;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('CachedRepository', () {
|
||||
test('reads from cache after first load', () async {
|
||||
final repo = _IntCachedRepository(
|
||||
store: HybridCacheStore(
|
||||
memory: MemoryCacheStore(),
|
||||
persistent: PersistentCacheStore(),
|
||||
),
|
||||
);
|
||||
|
||||
final first = await repo.fetch();
|
||||
final second = await repo.fetch();
|
||||
|
||||
expect(first, 1);
|
||||
expect(second, 1);
|
||||
expect(repo.loadCount, 1);
|
||||
});
|
||||
|
||||
test('force refresh bypasses cached value', () async {
|
||||
final repo = _IntCachedRepository(
|
||||
store: HybridCacheStore(
|
||||
memory: MemoryCacheStore(),
|
||||
persistent: PersistentCacheStore(),
|
||||
),
|
||||
);
|
||||
|
||||
await repo.fetch();
|
||||
final refreshed = await repo.fetch(forceRefresh: true);
|
||||
|
||||
expect(refreshed, 2);
|
||||
expect(repo.loadCount, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/core/network/i_api_client.dart';
|
||||
import 'package:social_app/data/repositories/calendar_event_repository.dart';
|
||||
import 'package:social_app/data/repositories/friend_repository.dart';
|
||||
import 'package:social_app/data/repositories/inbox_repository.dart';
|
||||
import 'package:social_app/data/repositories/models/calendar_event.dart';
|
||||
import 'package:social_app/data/repositories/models/friend_request.dart';
|
||||
import 'package:social_app/data/repositories/models/inbox_message.dart';
|
||||
import 'package:social_app/data/repositories/user_repository.dart';
|
||||
|
||||
class _FakeApiClient implements IApiClient {
|
||||
final Map<String, dynamic> _getResponses = <String, dynamic>{};
|
||||
final Map<String, dynamic> _postResponses = <String, dynamic>{};
|
||||
final Map<String, dynamic> _patchResponses = <String, dynamic>{};
|
||||
|
||||
void setGet(String path, dynamic data) => _getResponses[path] = data;
|
||||
|
||||
void setPost(String path, dynamic data) => _postResponses[path] = data;
|
||||
|
||||
void setPatch(String path, dynamic data) => _patchResponses[path] = data;
|
||||
|
||||
@override
|
||||
Future<Response<T>> get<T>(String path, {Options? options}) async {
|
||||
if (!_getResponses.containsKey(path)) {
|
||||
throw StateError('missing GET mock for $path');
|
||||
}
|
||||
return Response<T>(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
data: _getResponses[path] as T,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> post<T>(String path, {data, Options? options}) async {
|
||||
if (!_postResponses.containsKey(path)) {
|
||||
throw StateError('missing POST mock for $path');
|
||||
}
|
||||
return Response<T>(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
data: _postResponses[path] as T,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> patch<T>(String path, {data, Options? options}) async {
|
||||
if (!_patchResponses.containsKey(path)) {
|
||||
throw StateError('missing PATCH mock for $path');
|
||||
}
|
||||
return Response<T>(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
data: _patchResponses[path] as T,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> delete<T>(String path, {data, Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Stream<String>> getSseLines(
|
||||
String path, {
|
||||
Map<String, String>? headers,
|
||||
}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> put<T>(String path, {data, Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('InboxRepository maps message type and status', () async {
|
||||
final client = _FakeApiClient();
|
||||
client.setGet('/api/v1/inbox/messages?is_read=false', [
|
||||
{
|
||||
'id': 'm1',
|
||||
'recipient_id': 'u1',
|
||||
'sender_id': 'u2',
|
||||
'message_type': 'calendar',
|
||||
'schedule_item_id': 's1',
|
||||
'friendship_id': null,
|
||||
'content': {'type': 'invite'},
|
||||
'is_read': false,
|
||||
'status': 'pending',
|
||||
'created_at': '2026-03-27T08:00:00Z',
|
||||
},
|
||||
]);
|
||||
|
||||
final repository = InboxRepositoryImpl(client);
|
||||
final result = await repository.getMessages(isRead: false);
|
||||
|
||||
expect(result.single.messageType, InboxMessageType.calendar);
|
||||
expect(result.single.status, InboxMessageStatus.pending);
|
||||
expect(result.single.scheduleItemId, 's1');
|
||||
});
|
||||
|
||||
test('FriendRepository maps request status', () async {
|
||||
final client = _FakeApiClient();
|
||||
client.setGet('/api/v1/friends/requests/f1', {
|
||||
'id': 'f1',
|
||||
'sender': {'id': 'u1', 'username': 'alice', 'avatar_url': null},
|
||||
'recipient': {'id': 'u2', 'username': 'bob', 'avatar_url': null},
|
||||
'content': 'hi',
|
||||
'status': 'accepted',
|
||||
'created_at': '2026-03-27T08:00:00Z',
|
||||
});
|
||||
|
||||
final repository = FriendRepositoryImpl(client);
|
||||
final request = await repository.getRequestById('f1');
|
||||
|
||||
expect(request.status, FriendRequestStatus.accepted);
|
||||
expect(request.sender.username, 'alice');
|
||||
});
|
||||
|
||||
test(
|
||||
'FriendRepository batch lookup fails when any request is missing',
|
||||
() async {
|
||||
final client = _FakeApiClient();
|
||||
client.setGet('/api/v1/friends/requests/f1', {
|
||||
'id': 'f1',
|
||||
'sender': {'id': 'u1', 'username': 'alice', 'avatar_url': null},
|
||||
'recipient': {'id': 'u2', 'username': 'bob', 'avatar_url': null},
|
||||
'content': 'hi',
|
||||
'status': 'pending',
|
||||
'created_at': '2026-03-27T08:00:00Z',
|
||||
});
|
||||
|
||||
final repository = FriendRepositoryImpl(client);
|
||||
await expectLater(
|
||||
repository.getRequestsByIds(['f1', 'missing']),
|
||||
throwsStateError,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('CalendarEventRepository maps archived status', () async {
|
||||
final client = _FakeApiClient();
|
||||
client.setGet('/api/v1/schedule-items/e1', {
|
||||
'id': 'e1',
|
||||
'owner_id': 'u1',
|
||||
'permission': 1,
|
||||
'is_owner': true,
|
||||
'title': 'event',
|
||||
'description': null,
|
||||
'start_at': '2026-03-27T08:00:00Z',
|
||||
'end_at': null,
|
||||
'timezone': 'UTC',
|
||||
'metadata': null,
|
||||
'source_type': 'manual',
|
||||
'status': 'archived',
|
||||
'created_at': '2026-03-27T08:00:00Z',
|
||||
'updated_at': '2026-03-27T09:00:00Z',
|
||||
});
|
||||
|
||||
final repository = CalendarEventRepositoryImpl(client);
|
||||
final event = await repository.getById('e1');
|
||||
|
||||
expect(event.status, CalendarEventStatus.archived);
|
||||
});
|
||||
|
||||
test('UserRepository returns shared user summary', () async {
|
||||
final client = _FakeApiClient();
|
||||
client.setGet('/api/v1/users/me', {
|
||||
'id': 'u1',
|
||||
'username': 'alice',
|
||||
'phone': null,
|
||||
'avatar_url': 'https://img',
|
||||
'bio': null,
|
||||
});
|
||||
|
||||
final repository = UserRepositoryImpl(client);
|
||||
final me = await repository.getMe();
|
||||
|
||||
expect(me.id, 'u1');
|
||||
expect(me.username, 'alice');
|
||||
expect(me.avatarUrl, 'https://img');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/data/cache/hybrid_cache_store.dart';
|
||||
import 'package:social_app/data/cache/memory_cache_store.dart';
|
||||
import 'package:social_app/data/models/user_profile.dart';
|
||||
import 'package:social_app/data/cache/persistent_cache_store.dart';
|
||||
import 'package:social_app/features/settings/data/services/user_profile_cache_repository.dart';
|
||||
|
||||
void main() {
|
||||
group('UserProfileCacheRepository', () {
|
||||
test('keeps in-memory snapshot and invalidates correctly', () async {
|
||||
var remoteCalls = 0;
|
||||
final repository = UserProfileCacheRepository(
|
||||
store: HybridCacheStore(
|
||||
memory: MemoryCacheStore(),
|
||||
persistent: PersistentCacheStore(),
|
||||
),
|
||||
remoteLoader: () async {
|
||||
remoteCalls += 1;
|
||||
return UserProfile(id: 'u1', username: 'user-$remoteCalls');
|
||||
},
|
||||
);
|
||||
|
||||
final first = await repository.getProfile();
|
||||
final second = await repository.getProfile();
|
||||
|
||||
expect(first.username, 'user-1');
|
||||
expect(second.username, 'user-1');
|
||||
expect(repository.cachedUser?.username, 'user-1');
|
||||
expect(remoteCalls, 1);
|
||||
|
||||
await repository.invalidate();
|
||||
|
||||
expect(repository.cachedUser, isNull);
|
||||
|
||||
final afterInvalidate = await repository.getProfile();
|
||||
expect(afterInvalidate.username, 'user-2');
|
||||
expect(remoteCalls, 2);
|
||||
});
|
||||
|
||||
test(
|
||||
'invalidate prevents stale in-flight refresh from restoring cache',
|
||||
() async {
|
||||
final completer = Completer<UserProfile>();
|
||||
final repository = UserProfileCacheRepository(
|
||||
store: HybridCacheStore(
|
||||
memory: MemoryCacheStore(),
|
||||
persistent: PersistentCacheStore(),
|
||||
),
|
||||
remoteLoader: () => completer.future,
|
||||
);
|
||||
|
||||
final pending = repository.getProfile(forceRefresh: true);
|
||||
await repository.invalidate();
|
||||
|
||||
completer.complete(
|
||||
const UserProfile(id: 'u-old', username: 'old-user'),
|
||||
);
|
||||
await pending;
|
||||
|
||||
expect(repository.cachedUser, isNull);
|
||||
|
||||
final cachedEntry = await repository.readCacheEntry(
|
||||
UserProfileCacheRepository.cacheKey,
|
||||
);
|
||||
expect(cachedEntry, isNull);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user