fix: 登录同步后首页恢复课程位置与复习卡片
- 拉取合并时还原当前课程与分段位置,位置只前进不后退,跳过其他设备已完成的课 - 由云端掌握项重建复习卡片,并纠正旧客户端写坏的首次复习到期时间 - 同 checkpoint 时以证据更多的一方为准;推送缺卡片项时不再用当前时间作到期时间 - 登录/注册及合并逻辑升级后强制全量拉取一次 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -169,6 +169,130 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
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 {
|
||||
@@ -278,7 +402,7 @@ void main() {
|
||||
|
||||
group('SyncCoordinator Integration', () {
|
||||
test('login, syncNow and logout lifecycle', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
Future<http.Response> handler(http.Request request) async {
|
||||
if (request.url.path == '/api/v1/auth/login') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
@@ -325,9 +449,9 @@ void main() {
|
||||
);
|
||||
}
|
||||
return http.Response('Not Found', 404);
|
||||
});
|
||||
}
|
||||
|
||||
final service = SyncService(client: mockClient);
|
||||
final service = SyncService(client: MockClient(handler));
|
||||
final coordinator = SyncCoordinator.createForTesting(service: service);
|
||||
await coordinator.init();
|
||||
|
||||
@@ -349,6 +473,29 @@ void main() {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user