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,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);
});
}