513 lines
17 KiB
Dart
513 lines
17 KiB
Dart
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('SyncMerger restores home page position on a fresh device', () {
|
|
// Mirrors what the server held for a real account: lesson a0-01 was
|
|
// finished on one device, then another device pushed its stale position
|
|
// (active lesson a0-01, review due_at overwritten with the push time).
|
|
SyncPullResponse stalePull() => const SyncPullResponse(
|
|
serverTime: '2026-09-16T08:00:00Z',
|
|
progress: SyncProgressPayload(
|
|
activeLessonId: 'a0-01',
|
|
completedLessonIds: ['a0-01'],
|
|
completedSegmentIds: ['a0-01-a'],
|
|
updatedAt: '2026-09-16T07:54:27Z',
|
|
),
|
|
masteryUpdates: [
|
|
SyncMasteryItemPayload(
|
|
itemId: 'A0-P03',
|
|
checkpoint: 0,
|
|
status: 'recall',
|
|
dueAt: '2026-09-16T07:55:13Z',
|
|
payload: {
|
|
'label': 'A0-P03',
|
|
'evidence': ['exposure', 'assisted', 'independentSuccess'],
|
|
'firstTaughtAt': '2026-09-16T07:49:45Z',
|
|
},
|
|
updatedAt: '2026-09-16T07:54:27Z',
|
|
),
|
|
],
|
|
);
|
|
|
|
test('advances past lessons completed on another device', () {
|
|
final state = AppState();
|
|
state.lessonStep = LessonStep.speaking;
|
|
|
|
final changed = SyncMerger.applyPullResponse(state, stalePull());
|
|
|
|
expect(changed, isTrue);
|
|
expect(state.completedLessonIds, contains('a0-01'));
|
|
expect(state.activeLessonId, 'a0-02');
|
|
expect(state.activeSegmentIndexFor('a0-01'), 0);
|
|
expect(state.lessonStep, LessonStep.preview);
|
|
});
|
|
|
|
test('keeps a completed lesson the learner reopened locally', () {
|
|
final state = AppState();
|
|
state.completedLessonIds.addAll(['a0-01', 'a0-02']);
|
|
state.completedLessons = 2;
|
|
state.activeLessonId = 'a0-01';
|
|
|
|
SyncMerger.applyPullResponse(state, stalePull());
|
|
|
|
expect(state.activeLessonId, 'a0-01');
|
|
});
|
|
|
|
test('never moves the position backwards', () {
|
|
final state = AppState();
|
|
state.completedLessonIds.addAll(['a0-01', 'a0-02']);
|
|
state.activeLessonId = 'a0-03';
|
|
|
|
SyncMerger.applyPullResponse(state, stalePull());
|
|
|
|
expect(state.activeLessonId, 'a0-03');
|
|
});
|
|
|
|
test('adopts a further remote lesson and segment position', () {
|
|
final state = AppState();
|
|
final pull = SyncPullResponse(
|
|
serverTime: '2026-09-16T08:00:00Z',
|
|
progress: SyncProgressPayload(
|
|
activeLessonId: 'a0-04',
|
|
completedLessonIds: const ['a0-01', 'a0-02', 'a0-03'],
|
|
completedSegmentIds: const [
|
|
'a0-01-a',
|
|
'a0-02-a',
|
|
'a0-03-a',
|
|
'a0-04-a',
|
|
],
|
|
updatedAt: '2026-09-16T07:54:27Z',
|
|
),
|
|
);
|
|
|
|
SyncMerger.applyPullResponse(state, pull);
|
|
|
|
expect(state.activeLessonId, 'a0-04');
|
|
expect(state.activeSegmentIndexFor('a0-04'), 1);
|
|
});
|
|
|
|
test('rebuilds review cards and repairs never-reviewed due dates', () {
|
|
final state = AppState();
|
|
|
|
SyncMerger.applyPullResponse(state, stalePull());
|
|
|
|
final review = state.reviewQueue.singleWhere((r) => r.id == 'A0-P03');
|
|
expect(review.dueAt.toUtc(), DateTime.parse('2026-09-17T07:49:45Z'));
|
|
expect(state.mastery['A0-P03']?.status, MasteryStatus.recall);
|
|
});
|
|
|
|
test('equal checkpoint with more remote evidence updates local status', () {
|
|
final state = AppState();
|
|
state.mastery['A0-P03'] = const MasteryItem(
|
|
id: 'A0-P03',
|
|
label: 'A0-P03',
|
|
status: MasteryStatus.newItem,
|
|
evidence: [EvidenceKind.exposure],
|
|
);
|
|
|
|
SyncMerger.applyPullResponse(state, stalePull());
|
|
|
|
expect(state.mastery['A0-P03']?.status, MasteryStatus.recall);
|
|
expect(state.mastery['A0-P03']?.evidence.length, 3);
|
|
});
|
|
|
|
test('push without a review card derives due date from first teaching', () {
|
|
final state = AppState();
|
|
state.mastery['A0-P01'] = MasteryItem(
|
|
id: 'A0-P01',
|
|
label: 'A0-P01',
|
|
status: MasteryStatus.newItem,
|
|
evidence: const [],
|
|
firstTaughtAt: DateTime.parse('2026-09-16T07:49:45Z'),
|
|
);
|
|
|
|
final req = SyncMerger.buildPushRequest(state);
|
|
|
|
expect(req.masteryUpdates.single.dueAt, '2026-09-17T07:49:45.000Z');
|
|
});
|
|
});
|
|
|
|
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 {
|
|
Future<http.Response> handler(http.Request 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(handler));
|
|
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'));
|
|
|
|
final pullQueries = <Map<String, String>>[];
|
|
final relogCoordinator = SyncCoordinator.createForTesting(
|
|
service: SyncService(
|
|
client: MockClient((request) async {
|
|
if (request.url.path == '/api/v1/sync/pull') {
|
|
pullQueries.add(request.url.queryParameters);
|
|
}
|
|
return handler(request);
|
|
}),
|
|
),
|
|
);
|
|
await relogCoordinator.init();
|
|
await relogCoordinator.syncNow(AppState());
|
|
expect(relogCoordinator.lastSyncTime, isNotNull);
|
|
await relogCoordinator.login(
|
|
serverUrl: 'http://127.0.0.1:8080',
|
|
username: 'tester',
|
|
password: 'password123',
|
|
);
|
|
await relogCoordinator.syncNow(AppState());
|
|
// A fresh login must pull everything, not only changes since last sync.
|
|
expect(pullQueries.last.containsKey('since'), isFalse);
|
|
|
|
await coordinator.logout();
|
|
expect(coordinator.isLoggedIn, isFalse);
|
|
expect(coordinator.config.token, isNull);
|
|
});
|
|
});
|
|
}
|