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 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 = >[]; 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); }); }); }