Files
English/kouyu_english/test/sync_test.dart
T
shenleiandClaude Opus 5 8ed52312fb fix: 登录同步后首页恢复课程位置与复习卡片
- 拉取合并时还原当前课程与分段位置,位置只前进不后退,跳过其他设备已完成的课
- 由云端掌握项重建复习卡片,并纠正旧客户端写坏的首次复习到期时间
- 同 checkpoint 时以证据更多的一方为准;推送缺卡片项时不再用当前时间作到期时间
- 登录/注册及合并逻辑升级后强制全量拉取一次

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:05:15 +09:00

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