fix: 登录同步后首页恢复课程位置与复习卡片
- 拉取合并时还原当前课程与分段位置,位置只前进不后退,跳过其他设备已完成的课 - 由云端掌握项重建复习卡片,并纠正旧客户端写坏的首次复习到期时间 - 同 checkpoint 时以证据更多的一方为准;推送缺卡片项时不再用当前时间作到期时间 - 登录/注册及合并逻辑升级后强制全量拉取一次 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1081,6 +1081,73 @@ class AppState extends ChangeNotifier {
|
|||||||
SyncCoordinator.instance.triggerBackgroundSync(this);
|
SyncCoordinator.instance.triggerBackgroundSync(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Merges lesson progress pulled from the sync server.
|
||||||
|
///
|
||||||
|
/// Completed lessons/segments are unioned, and the learner's position only
|
||||||
|
/// ever moves forward: a device that has not caught up yet must never drag
|
||||||
|
/// another device back to an earlier lesson or segment.
|
||||||
|
bool mergeSyncedLessonProgress({
|
||||||
|
required Iterable<String> completedLessons,
|
||||||
|
required Iterable<String> completedSegments,
|
||||||
|
required String remoteActiveLessonId,
|
||||||
|
}) {
|
||||||
|
var changed = false;
|
||||||
|
final completedBefore = completedLessonIds.toSet();
|
||||||
|
for (final id in completedLessons) {
|
||||||
|
if (completedLessonIds.add(id)) changed = true;
|
||||||
|
}
|
||||||
|
for (final id in completedSegments) {
|
||||||
|
if (completedSegmentIds.add(id)) changed = true;
|
||||||
|
}
|
||||||
|
if (this.completedLessons != completedLessonIds.length) {
|
||||||
|
this.completedLessons = completedLessonIds.length;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int lessonIndex(String id) =>
|
||||||
|
a0SeedLessons.indexWhere((lesson) => lesson.id == id);
|
||||||
|
|
||||||
|
var targetIndex = lessonIndex(activeLessonId);
|
||||||
|
final remoteIndex = lessonIndex(remoteActiveLessonId);
|
||||||
|
if (remoteIndex > targetIndex && isLessonUnlocked(remoteActiveLessonId)) {
|
||||||
|
targetIndex = remoteIndex;
|
||||||
|
}
|
||||||
|
// The server's active lesson can itself be stale. Skip lessons that only
|
||||||
|
// became complete through this pull, but leave a lesson alone when the
|
||||||
|
// learner deliberately reopened it locally after finishing it.
|
||||||
|
while (targetIndex >= 0 &&
|
||||||
|
targetIndex < a0SeedLessons.length - 1 &&
|
||||||
|
!completedBefore.contains(a0SeedLessons[targetIndex].id) &&
|
||||||
|
completedLessonIds.contains(a0SeedLessons[targetIndex].id)) {
|
||||||
|
targetIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var positionChanged = false;
|
||||||
|
if (targetIndex >= 0 && a0SeedLessons[targetIndex].id != activeLessonId) {
|
||||||
|
activeLessonId = a0SeedLessons[targetIndex].id;
|
||||||
|
positionChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final lesson in a0SeedLessons) {
|
||||||
|
final segments = lesson.segments;
|
||||||
|
var firstOpen = segments.indexWhere(
|
||||||
|
(segment) => !completedSegmentIds.contains(segment.id),
|
||||||
|
);
|
||||||
|
if (firstOpen < 0) firstOpen = segments.length - 1;
|
||||||
|
if (firstOpen > activeSegmentIndexFor(lesson.id)) {
|
||||||
|
activeSegmentIndexes[lesson.id] = firstOpen;
|
||||||
|
if (lesson.id == activeLessonId) positionChanged = true;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (positionChanged) {
|
||||||
|
_resetLessonFlow();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
void _resetLessonFlow() {
|
void _resetLessonFlow() {
|
||||||
lessonStep = LessonStep.preview;
|
lessonStep = LessonStep.preview;
|
||||||
previewIndex = 0;
|
previewIndex = 0;
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import 'sync_service.dart';
|
|||||||
class SyncCoordinator extends ChangeNotifier {
|
class SyncCoordinator extends ChangeNotifier {
|
||||||
static const _prefKey = 'sync_config_v1';
|
static const _prefKey = 'sync_config_v1';
|
||||||
|
|
||||||
|
/// 合并逻辑版本。升级后丢弃旧的增量同步时间戳,强制全量拉取一次,
|
||||||
|
/// 让旧版本已经跳过的课程位置和复习卡片能被重新合并。
|
||||||
|
static const _mergeVersion = 2;
|
||||||
|
static const _mergeVersionKey = 'mergeVersion';
|
||||||
|
|
||||||
static final SyncCoordinator instance = SyncCoordinator._();
|
static final SyncCoordinator instance = SyncCoordinator._();
|
||||||
SyncCoordinator._({SyncService? service}) : _service = service ?? SyncService();
|
SyncCoordinator._({SyncService? service}) : _service = service ?? SyncService();
|
||||||
|
|
||||||
@@ -49,6 +54,9 @@ class SyncCoordinator extends ChangeNotifier {
|
|||||||
if (raw != null && raw.isNotEmpty) {
|
if (raw != null && raw.isNotEmpty) {
|
||||||
final map = jsonDecode(raw) as Map<String, dynamic>;
|
final map = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
_config = SyncConfig.fromJson(map);
|
_config = SyncConfig.fromJson(map);
|
||||||
|
if ((map[_mergeVersionKey] as int? ?? 1) < _mergeVersion) {
|
||||||
|
_config = _withoutLastSyncTime(_config);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[SyncCoordinator] init error: $e');
|
debugPrint('[SyncCoordinator] init error: $e');
|
||||||
@@ -61,12 +69,23 @@ class SyncCoordinator extends ChangeNotifier {
|
|||||||
Future<void> _saveConfig() async {
|
Future<void> _saveConfig() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_prefKey, jsonEncode(_config.toJson()));
|
await prefs.setString(
|
||||||
|
_prefKey,
|
||||||
|
jsonEncode({..._config.toJson(), _mergeVersionKey: _mergeVersion}),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[SyncCoordinator] save config error: $e');
|
debugPrint('[SyncCoordinator] save config error: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static SyncConfig _withoutLastSyncTime(SyncConfig config) => SyncConfig(
|
||||||
|
serverUrl: config.serverUrl,
|
||||||
|
token: config.token,
|
||||||
|
username: config.username,
|
||||||
|
userId: config.userId,
|
||||||
|
autoSyncEnabled: config.autoSyncEnabled,
|
||||||
|
);
|
||||||
|
|
||||||
/// 更新服务器地址
|
/// 更新服务器地址
|
||||||
Future<void> updateServerUrl(String newUrl) async {
|
Future<void> updateServerUrl(String newUrl) async {
|
||||||
_config = _config.copyWith(serverUrl: newUrl.trim());
|
_config = _config.copyWith(serverUrl: newUrl.trim());
|
||||||
@@ -98,7 +117,8 @@ class SyncCoordinator extends ChangeNotifier {
|
|||||||
username: username,
|
username: username,
|
||||||
password: password,
|
password: password,
|
||||||
);
|
);
|
||||||
_config = _config.copyWith(
|
// 新登录的账号必须全量拉取,不能沿用之前的增量时间戳
|
||||||
|
_config = _withoutLastSyncTime(_config).copyWith(
|
||||||
serverUrl: serverUrl.trim(),
|
serverUrl: serverUrl.trim(),
|
||||||
token: auth.token,
|
token: auth.token,
|
||||||
username: auth.username,
|
username: auth.username,
|
||||||
@@ -132,7 +152,8 @@ class SyncCoordinator extends ChangeNotifier {
|
|||||||
username: username,
|
username: username,
|
||||||
password: password,
|
password: password,
|
||||||
);
|
);
|
||||||
_config = _config.copyWith(
|
// 新登录的账号必须全量拉取,不能沿用之前的增量时间戳
|
||||||
|
_config = _withoutLastSyncTime(_config).copyWith(
|
||||||
serverUrl: serverUrl.trim(),
|
serverUrl: serverUrl.trim(),
|
||||||
token: auth.token,
|
token: auth.token,
|
||||||
username: auth.username,
|
username: auth.username,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import '../a0_core.dart';
|
||||||
import '../models.dart';
|
import '../models.dart';
|
||||||
import '../app_state.dart';
|
import '../app_state.dart';
|
||||||
import 'sync_models.dart';
|
import 'sync_models.dart';
|
||||||
@@ -20,8 +21,11 @@ class SyncMerger {
|
|||||||
final masteryUpdates = state.mastery.values.map((m) {
|
final masteryUpdates = state.mastery.values.map((m) {
|
||||||
// 查找对应复习到期时间
|
// 查找对应复习到期时间
|
||||||
final review = state.reviewQueue.where((r) => r.id == m.id).firstOrNull;
|
final review = state.reviewQueue.where((r) => r.id == m.id).firstOrNull;
|
||||||
final dueAt = review?.dueAt.toUtc().toIso8601String() ??
|
final dueAt = (review?.dueAt ??
|
||||||
DateTime.now().toUtc().toIso8601String();
|
m.firstTaughtAt?.add(_firstReviewDelay) ??
|
||||||
|
DateTime.now())
|
||||||
|
.toUtc()
|
||||||
|
.toIso8601String();
|
||||||
final attempts = review?.attempts ?? 0;
|
final attempts = review?.attempts ?? 0;
|
||||||
final successfulReviews = review?.successfulReviews ?? 0;
|
final successfulReviews = review?.successfulReviews ?? 0;
|
||||||
|
|
||||||
@@ -71,60 +75,60 @@ class SyncMerger {
|
|||||||
static bool applyPullResponse(AppState state, SyncPullResponse response) {
|
static bool applyPullResponse(AppState state, SyncPullResponse response) {
|
||||||
var changed = false;
|
var changed = false;
|
||||||
|
|
||||||
// 1. 合并课程关卡 (Union)
|
// 1. 合并课程关卡 (Union),学习位置只前进不后退
|
||||||
if (response.progress != null) {
|
if (response.progress != null) {
|
||||||
final p = response.progress!;
|
final p = response.progress!;
|
||||||
for (final id in p.completedLessonIds) {
|
if (state.mergeSyncedLessonProgress(
|
||||||
if (!state.completedLessonIds.contains(id)) {
|
completedLessons: p.completedLessonIds,
|
||||||
state.completedLessonIds.add(id);
|
completedSegments: p.completedSegmentIds,
|
||||||
changed = true;
|
remoteActiveLessonId: p.activeLessonId,
|
||||||
}
|
)) {
|
||||||
}
|
|
||||||
for (final sid in p.completedSegmentIds) {
|
|
||||||
if (!state.completedSegmentIds.contains(sid)) {
|
|
||||||
state.completedSegmentIds.add(sid);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (state.completedLessonIds.length != state.completedLessons) {
|
|
||||||
state.completedLessons = state.completedLessonIds.length;
|
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 合并复习掌握项 (Max Checkpoint)
|
// 2. 合并复习掌握项 (Max Checkpoint;同 Checkpoint 时证据更多者更新)
|
||||||
for (final m in response.masteryUpdates) {
|
for (final m in response.masteryUpdates) {
|
||||||
final local = state.mastery[m.itemId];
|
final local = state.mastery[m.itemId];
|
||||||
|
final status = _parseMasteryStatus(m.status);
|
||||||
|
final evidence = (m.payload['evidence'] as List? ?? [])
|
||||||
|
.whereType<String>()
|
||||||
|
.map(_parseEvidenceKind)
|
||||||
|
.toList();
|
||||||
|
final firstTaughtAt = m.payload['firstTaughtAt'] is String
|
||||||
|
? DateTime.tryParse(m.payload['firstTaughtAt'] as String)
|
||||||
|
: null;
|
||||||
|
final needsReview = m.payload['needsReview'] as bool?;
|
||||||
if (local == null) {
|
if (local == null) {
|
||||||
// 本地没有,直接添加
|
// 本地没有,直接添加
|
||||||
final status = _parseMasteryStatus(m.status);
|
|
||||||
final evidence = (m.payload['evidence'] as List? ?? [])
|
|
||||||
.whereType<String>()
|
|
||||||
.map(_parseEvidenceKind)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
state.mastery[m.itemId] = MasteryItem(
|
state.mastery[m.itemId] = MasteryItem(
|
||||||
id: m.itemId,
|
id: m.itemId,
|
||||||
label: m.payload['label'] as String? ?? m.itemId,
|
label: m.payload['label'] as String? ?? m.itemId,
|
||||||
status: status,
|
status: status,
|
||||||
evidence: evidence,
|
evidence: evidence,
|
||||||
needsReview: m.payload['needsReview'] as bool? ?? false,
|
needsReview: needsReview ?? false,
|
||||||
checkpoint: m.checkpoint,
|
checkpoint: m.checkpoint,
|
||||||
firstTaughtAt: m.payload['firstTaughtAt'] != null
|
firstTaughtAt: firstTaughtAt,
|
||||||
? DateTime.tryParse(m.payload['firstTaughtAt'] as String)
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
changed = true;
|
changed = true;
|
||||||
} else if (m.checkpoint > local.checkpoint) {
|
} else if (m.checkpoint > local.checkpoint ||
|
||||||
// 云端 Checkpoint 更高,升级本地状态
|
(m.checkpoint == local.checkpoint &&
|
||||||
final status = _parseMasteryStatus(m.status);
|
evidence.length > local.evidence.length)) {
|
||||||
|
// 云端进度更新(证据只追加不删除,条数更多说明学得更多)
|
||||||
state.mastery[m.itemId] = local.copyWith(
|
state.mastery[m.itemId] = local.copyWith(
|
||||||
checkpoint: m.checkpoint,
|
checkpoint: m.checkpoint,
|
||||||
status: status,
|
status: status,
|
||||||
needsReview: m.payload['needsReview'] as bool? ?? local.needsReview,
|
evidence: evidence.length > local.evidence.length ? evidence : null,
|
||||||
|
needsReview: needsReview ?? local.needsReview,
|
||||||
|
firstTaughtAt: local.firstTaughtAt ?? firstTaughtAt,
|
||||||
);
|
);
|
||||||
changed = true;
|
changed = true;
|
||||||
|
} else if (local.firstTaughtAt == null && firstTaughtAt != null) {
|
||||||
|
state.mastery[m.itemId] = local.copyWith(firstTaughtAt: firstTaughtAt);
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_mergeReviewItem(state, m, firstTaughtAt)) changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 合并用户偏好设置 (按需合并)
|
// 3. 合并用户偏好设置 (按需合并)
|
||||||
@@ -146,6 +150,53 @@ class SyncMerger {
|
|||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 与 AppState 引入新课目标时的首次复习间隔保持一致
|
||||||
|
static const _firstReviewDelay = Duration(days: 1);
|
||||||
|
|
||||||
|
/// 首页的到期复习数来自 reviewQueue,因此云端掌握项也要还原为复习卡片
|
||||||
|
static bool _mergeReviewItem(
|
||||||
|
AppState state,
|
||||||
|
SyncMasteryItemPayload m,
|
||||||
|
DateTime? firstTaughtAt,
|
||||||
|
) {
|
||||||
|
// 只有在课程里正式引入过、或复习过的项目才会有复习卡片
|
||||||
|
if (firstTaughtAt == null && m.attempts == 0) return false;
|
||||||
|
// 从未复习过的卡片到期时间固定为首次学习后一天;旧版本客户端会把
|
||||||
|
// 缺失卡片的 due_at 写成推送时刻,这里据此纠正
|
||||||
|
final dueAt = m.attempts == 0 && firstTaughtAt != null
|
||||||
|
? firstTaughtAt.add(_firstReviewDelay)
|
||||||
|
: DateTime.tryParse(m.dueAt);
|
||||||
|
if (dueAt == null) return false;
|
||||||
|
|
||||||
|
final index = state.reviewQueue.indexWhere((r) => r.id == m.itemId);
|
||||||
|
if (index < 0) {
|
||||||
|
final template = coreReviewTemplate(m.itemId);
|
||||||
|
state.reviewQueue.add(
|
||||||
|
ReviewItem(
|
||||||
|
id: m.itemId,
|
||||||
|
target: a0CoreItems[m.itemId] ?? m.itemId,
|
||||||
|
prompt: template.prompt,
|
||||||
|
hint: template.hint,
|
||||||
|
dueAt: dueAt.toLocal(),
|
||||||
|
skill: template.skill,
|
||||||
|
attempts: m.attempts,
|
||||||
|
successfulReviews: m.successfulReviews,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final local = state.reviewQueue[index];
|
||||||
|
if (m.attempts > local.attempts) {
|
||||||
|
state.reviewQueue[index] = local.copyWith(
|
||||||
|
dueAt: dueAt.toLocal(),
|
||||||
|
attempts: m.attempts,
|
||||||
|
successfulReviews: m.successfulReviews,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
static MasteryStatus _parseMasteryStatus(String str) {
|
static MasteryStatus _parseMasteryStatus(String str) {
|
||||||
return MasteryStatus.values.where((e) => e.name == str).firstOrNull ??
|
return MasteryStatus.values.where((e) => e.name == str).firstOrNull ??
|
||||||
MasteryStatus.newItem;
|
MasteryStatus.newItem;
|
||||||
|
|||||||
@@ -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', () {
|
group('SyncService HTTP operations', () {
|
||||||
test('testConnection returns true on 200 health check', () async {
|
test('testConnection returns true on 200 health check', () async {
|
||||||
final mockClient = MockClient((request) async {
|
final mockClient = MockClient((request) async {
|
||||||
@@ -278,7 +402,7 @@ void main() {
|
|||||||
|
|
||||||
group('SyncCoordinator Integration', () {
|
group('SyncCoordinator Integration', () {
|
||||||
test('login, syncNow and logout lifecycle', () async {
|
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') {
|
if (request.url.path == '/api/v1/auth/login') {
|
||||||
return http.Response(
|
return http.Response(
|
||||||
jsonEncode({
|
jsonEncode({
|
||||||
@@ -325,9 +449,9 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return http.Response('Not Found', 404);
|
return http.Response('Not Found', 404);
|
||||||
});
|
}
|
||||||
|
|
||||||
final service = SyncService(client: mockClient);
|
final service = SyncService(client: MockClient(handler));
|
||||||
final coordinator = SyncCoordinator.createForTesting(service: service);
|
final coordinator = SyncCoordinator.createForTesting(service: service);
|
||||||
await coordinator.init();
|
await coordinator.init();
|
||||||
|
|
||||||
@@ -349,6 +473,29 @@ void main() {
|
|||||||
expect(coordinator.lastSyncTime, isNotNull);
|
expect(coordinator.lastSyncTime, isNotNull);
|
||||||
expect(state.completedLessonIds, contains('a0-01'));
|
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();
|
await coordinator.logout();
|
||||||
expect(coordinator.isLoggedIn, isFalse);
|
expect(coordinator.isLoggedIn, isFalse);
|
||||||
expect(coordinator.config.token, isNull);
|
expect(coordinator.config.token, isNull);
|
||||||
|
|||||||
Reference in New Issue
Block a user