feat: 完善芽说英语品牌Logo与图标配置,完成跨平台云端同步服务开发与自动化部署
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:kouyu_english/core/app_state.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
import 'package:kouyu_english/core/sync/sync_coordinator.dart';
|
||||
import 'package:kouyu_english/core/sync/sync_merger.dart';
|
||||
import 'package:kouyu_english/core/sync/sync_models.dart';
|
||||
import 'package:kouyu_english/core/sync/sync_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
group('Sync Models Serialization', () {
|
||||
test('SyncConfig toJson and fromJson round-trip', () {
|
||||
final config = SyncConfig(
|
||||
serverUrl: 'https://sync.example.com',
|
||||
token: 'test_token_123',
|
||||
username: 'alice',
|
||||
userId: 'u-1',
|
||||
lastSyncTime: DateTime.parse('2026-09-16T08:00:00.000Z'),
|
||||
autoSyncEnabled: true,
|
||||
);
|
||||
|
||||
final json = config.toJson();
|
||||
final recovered = SyncConfig.fromJson(json);
|
||||
|
||||
expect(recovered.serverUrl, 'https://sync.example.com');
|
||||
expect(recovered.token, 'test_token_123');
|
||||
expect(recovered.username, 'alice');
|
||||
expect(recovered.userId, 'u-1');
|
||||
expect(recovered.isLoggedIn, isTrue);
|
||||
expect(recovered.lastSyncTime?.toIso8601String(), '2026-09-16T08:00:00.000Z');
|
||||
expect(recovered.autoSyncEnabled, isTrue);
|
||||
});
|
||||
|
||||
test('SyncPushRequest and SyncPullResponse parsing', () {
|
||||
final pullJson = {
|
||||
'code': 0,
|
||||
'message': 'success',
|
||||
'data': {
|
||||
'server_time': '2026-09-16T10:00:00Z',
|
||||
'progress': {
|
||||
'active_lesson_id': 'a0-02',
|
||||
'completed_lesson_ids': ['a0-01', 'a0-02'],
|
||||
'completed_segment_ids': ['a0-01-s1', 'a0-02-s1'],
|
||||
'active_step': 'listening',
|
||||
'streak_days': 2,
|
||||
'updated_at': '2026-09-16T10:00:00Z',
|
||||
},
|
||||
'mastery_updates': [
|
||||
{
|
||||
'item_id': 'A0-P01',
|
||||
'checkpoint': 3,
|
||||
'status': 'use',
|
||||
'due_at': '2026-09-17T10:00:00Z',
|
||||
'successful_reviews': 3,
|
||||
'attempts': 3,
|
||||
'payload': {'label': 'I need coffee'},
|
||||
'updated_at': '2026-09-16T10:00:00Z',
|
||||
}
|
||||
],
|
||||
'profile': {
|
||||
'onboarding_complete': true,
|
||||
'goal': 'workplace',
|
||||
'placement': 'A0',
|
||||
'daily_minutes': 20,
|
||||
'show_chinese_hints': true,
|
||||
'updated_at': '2026-09-16T10:00:00Z',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
final response = SyncPullResponse.fromJson(pullJson);
|
||||
expect(response.serverTime, '2026-09-16T10:00:00Z');
|
||||
expect(response.progress?.completedLessonIds, ['a0-01', 'a0-02']);
|
||||
expect(response.masteryUpdates.length, 1);
|
||||
expect(response.masteryUpdates.first.itemId, 'A0-P01');
|
||||
expect(response.masteryUpdates.first.checkpoint, 3);
|
||||
expect(response.profile?.goal, 'workplace');
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncMerger Logic', () {
|
||||
test('buildPushRequest extracts current AppState data', () {
|
||||
final state = AppState();
|
||||
state.completedLessonIds.add('a0-01');
|
||||
state.completedLessons = 1;
|
||||
state.mastery['A0-P01'] = const MasteryItem(
|
||||
id: 'A0-P01',
|
||||
label: 'I am Shen',
|
||||
status: MasteryStatus.recall,
|
||||
checkpoint: 2,
|
||||
evidence: [EvidenceKind.independentSuccess],
|
||||
);
|
||||
|
||||
final req = SyncMerger.buildPushRequest(state, deviceName: 'MacBook');
|
||||
expect(req.deviceName, 'MacBook');
|
||||
expect(req.progress?.completedLessonIds, contains('a0-01'));
|
||||
expect(req.masteryUpdates.any((m) => m.itemId == 'A0-P01'), isTrue);
|
||||
final item = req.masteryUpdates.firstWhere((m) => m.itemId == 'A0-P01');
|
||||
expect(item.checkpoint, 2);
|
||||
expect(item.status, 'recall');
|
||||
});
|
||||
|
||||
test('applyPullResponse merges lessons by union and upgrades mastery by max checkpoint', () {
|
||||
final state = AppState();
|
||||
state.completedLessonIds.add('a0-01');
|
||||
state.completedLessons = 1;
|
||||
state.mastery['A0-P01'] = const MasteryItem(
|
||||
id: 'A0-P01',
|
||||
label: 'I am Shen',
|
||||
status: MasteryStatus.recognize,
|
||||
checkpoint: 1,
|
||||
evidence: [],
|
||||
);
|
||||
|
||||
final remotePull = SyncPullResponse(
|
||||
serverTime: '2026-09-16T10:00:00Z',
|
||||
progress: const SyncProgressPayload(
|
||||
activeLessonId: 'a0-03',
|
||||
completedLessonIds: ['a0-01', 'a0-02'],
|
||||
completedSegmentIds: ['a0-01-s1', 'a0-02-s1'],
|
||||
updatedAt: '2026-09-16T10:00:00Z',
|
||||
),
|
||||
masteryUpdates: [
|
||||
const SyncMasteryItemPayload(
|
||||
itemId: 'A0-P01',
|
||||
checkpoint: 3,
|
||||
status: 'use',
|
||||
dueAt: '2026-09-20T10:00:00Z',
|
||||
payload: {'label': 'I am Shen'},
|
||||
updatedAt: '2026-09-16T10:00:00Z',
|
||||
),
|
||||
const SyncMasteryItemPayload(
|
||||
itemId: 'A0-P02',
|
||||
checkpoint: 1,
|
||||
status: 'recognize',
|
||||
dueAt: '2026-09-18T10:00:00Z',
|
||||
payload: {'label': 'Thank you'},
|
||||
updatedAt: '2026-09-16T10:00:00Z',
|
||||
),
|
||||
],
|
||||
profile: const SyncProfilePayload(
|
||||
onboardingComplete: true,
|
||||
goal: 'dailyLife',
|
||||
updatedAt: '2026-09-16T10:00:00Z',
|
||||
),
|
||||
);
|
||||
|
||||
final changed = SyncMerger.applyPullResponse(state, remotePull);
|
||||
expect(changed, isTrue);
|
||||
// Lesson union
|
||||
expect(state.completedLessonIds, containsAll(['a0-01', 'a0-02']));
|
||||
expect(state.completedLessons, 2);
|
||||
// Mastery max checkpoint upgrade
|
||||
expect(state.mastery['A0-P01']?.checkpoint, 3);
|
||||
expect(state.mastery['A0-P01']?.status, MasteryStatus.use);
|
||||
// New item added from remote
|
||||
expect(state.mastery['A0-P02']?.checkpoint, 1);
|
||||
expect(state.mastery['A0-P02']?.label, 'Thank you');
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncService HTTP operations', () {
|
||||
test('testConnection returns true on 200 health check', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
if (request.url.path == '/api/v1/health') {
|
||||
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||
}
|
||||
return http.Response('Not Found', 404);
|
||||
});
|
||||
|
||||
final service = SyncService(client: mockClient);
|
||||
final ok = await service.testConnection('http://127.0.0.1:8080');
|
||||
expect(ok, isTrue);
|
||||
});
|
||||
|
||||
test('register and login return auth tokens', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
if (request.url.path == '/api/v1/auth/register' ||
|
||||
request.url.path == '/api/v1/auth/login') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'success',
|
||||
'data': {
|
||||
'user_id': 'usr-888',
|
||||
'username': 'shen',
|
||||
'token': 'jwt_mock_token_abc',
|
||||
'expires_in': 604800,
|
||||
}
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('Not Found', 404);
|
||||
});
|
||||
|
||||
final service = SyncService(client: mockClient);
|
||||
final regRes = await service.register(
|
||||
serverUrl: 'http://127.0.0.1:8080',
|
||||
username: 'shen',
|
||||
password: 'password123',
|
||||
);
|
||||
expect(regRes.userId, 'usr-888');
|
||||
expect(regRes.token, 'jwt_mock_token_abc');
|
||||
|
||||
final logRes = await service.login(
|
||||
serverUrl: 'http://127.0.0.1:8080',
|
||||
username: 'shen',
|
||||
password: 'password123',
|
||||
);
|
||||
expect(logRes.userId, 'usr-888');
|
||||
expect(logRes.token, 'jwt_mock_token_abc');
|
||||
});
|
||||
|
||||
test('pull and push send and receive data correctly', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
if (request.url.path == '/api/v1/sync/pull') {
|
||||
expect(request.headers['Authorization'], 'Bearer mock_token');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'success',
|
||||
'data': {
|
||||
'server_time': '2026-09-16T12:00:00Z',
|
||||
'progress': {
|
||||
'active_lesson_id': 'a0-01',
|
||||
'completed_lesson_ids': ['a0-01'],
|
||||
'completed_segment_ids': ['a0-01-s1'],
|
||||
'active_step': 'speaking',
|
||||
'streak_days': 1,
|
||||
'updated_at': '2026-09-16T12:00:00Z',
|
||||
},
|
||||
'mastery_updates': [],
|
||||
'profile': null,
|
||||
}
|
||||
}),
|
||||
200,
|
||||
);
|
||||
} else if (request.url.path == '/api/v1/sync/push') {
|
||||
expect(request.headers['Authorization'], 'Bearer mock_token');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'success',
|
||||
'data': {'server_time': '2026-09-16T12:05:00Z'}
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('Not Found', 404);
|
||||
});
|
||||
|
||||
final service = SyncService(client: mockClient);
|
||||
final pullResp = await service.pull(
|
||||
serverUrl: 'http://127.0.0.1:8080',
|
||||
token: 'mock_token',
|
||||
);
|
||||
expect(pullResp.progress?.completedLessonIds, ['a0-01']);
|
||||
|
||||
final sTime = await service.push(
|
||||
serverUrl: 'http://127.0.0.1:8080',
|
||||
token: 'mock_token',
|
||||
request: const SyncPushRequest(clientTime: '2026-09-16T12:04:00Z'),
|
||||
);
|
||||
expect(sTime, '2026-09-16T12:05:00Z');
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncCoordinator Integration', () {
|
||||
test('login, syncNow and logout lifecycle', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
if (request.url.path == '/api/v1/auth/login') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'success',
|
||||
'data': {
|
||||
'user_id': 'u100',
|
||||
'username': 'tester',
|
||||
'token': 'auth_token_999',
|
||||
'expires_in': 604800,
|
||||
}
|
||||
}),
|
||||
200,
|
||||
);
|
||||
} else if (request.url.path == '/api/v1/sync/pull') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'success',
|
||||
'data': {
|
||||
'server_time': '2026-09-16T15:00:00Z',
|
||||
'progress': {
|
||||
'active_lesson_id': 'a0-01',
|
||||
'completed_lesson_ids': ['a0-01'],
|
||||
'completed_segment_ids': ['a0-01-s1'],
|
||||
'active_step': 'reading',
|
||||
'streak_days': 1,
|
||||
'updated_at': '2026-09-16T15:00:00Z',
|
||||
},
|
||||
'mastery_updates': [],
|
||||
'profile': null,
|
||||
}
|
||||
}),
|
||||
200,
|
||||
);
|
||||
} else if (request.url.path == '/api/v1/sync/push') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'success',
|
||||
'data': {'server_time': '2026-09-16T15:00:01Z'}
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('Not Found', 404);
|
||||
});
|
||||
|
||||
final service = SyncService(client: mockClient);
|
||||
final coordinator = SyncCoordinator.createForTesting(service: service);
|
||||
await coordinator.init();
|
||||
|
||||
expect(coordinator.isLoggedIn, isFalse);
|
||||
|
||||
final loginSuccess = await coordinator.login(
|
||||
serverUrl: 'http://127.0.0.1:8080',
|
||||
username: 'tester',
|
||||
password: 'password123',
|
||||
);
|
||||
expect(loginSuccess, isTrue);
|
||||
expect(coordinator.isLoggedIn, isTrue);
|
||||
expect(coordinator.username, 'tester');
|
||||
|
||||
final state = AppState();
|
||||
final syncSuccess = await coordinator.syncNow(state);
|
||||
expect(syncSuccess, isTrue);
|
||||
expect(coordinator.state, SyncState.success);
|
||||
expect(coordinator.lastSyncTime, isNotNull);
|
||||
expect(state.completedLessonIds, contains('a0-01'));
|
||||
|
||||
await coordinator.logout();
|
||||
expect(coordinator.isLoggedIn, isFalse);
|
||||
expect(coordinator.config.token, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user