From 76e89a776fa45865b45aef0a6a8653cf27d80c23 Mon Sep 17 00:00:00 2001 From: RaycarlLei <38275852+RaycarlLei@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:17:42 -0400 Subject: [PATCH] Bind persisted review answers to current content --- README.md | 7 + README.zh-CN.md | 2 + docs/persisted-review.md | 70 +++ lib/pages/flash_cards/flash_cards_widget.dart | 12 + lib/services/learning_repository.dart | 239 +++++---- pubspec.yaml | 2 +- test/persisted_review_content_test.dart | 473 ++++++++++++++++++ test/review_availability_widget_test.dart | 108 ++++ 8 files changed, 828 insertions(+), 85 deletions(-) create mode 100644 docs/persisted-review.md create mode 100644 test/persisted_review_content_test.dart diff --git a/README.md b/README.md index 0ccbc29..0fa20be 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ not a signed store release. Platform support beyond those checks should be verif | Problem | Implementation | Regression tests | |---|---|---| | Persist meaning-level learning progress | [learning_repository.dart](lib/services/learning_repository.dart) | [learning_repository_test.dart](test/learning_repository_test.dart) | +| Keep unanswered questions bound to imported content | [persisted review contract](docs/persisted-review.md) | [disk, migration and failure tests](test/persisted_review_content_test.dart) | | Preserve imported content across refreshes | [dictionary_import.dart](lib/services/dictionary_import.dart) | [home import](test/home_import_widget_test.dart), [dictionary validation](test/dictionary_import_test.dart) | | Bound a slow speech download | [community_gateway.dart](lib/services/community_gateway.dart) | [gateway lifecycle](test/community_gateway_test.dart) | | Prepare a usable review from available content | [review_preparation.dart](lib/services/review_preparation.dart) | [review_preparation_test.dart](test/review_preparation_test.dart) | @@ -67,6 +68,12 @@ Reimporting an entry updates its content while preserving learning progress. A storage failure may leave earlier entries imported; retrying is supported. The import is not represented as an all-or-nothing database transaction. +An unanswered review is checked against its saved choices and current content +before it can advance learning progress. If an import changes that content, the +page prepares a fresh question. Temporary database failures leave the round +retryable. See the [persisted review contract](docs/persisted-review.md), including +the schema 3 to 4 upgrade and preservation of previously earned progress. + Refreshing the home screen seeds only missing sample meanings. It does not overwrite an imported replacement or reset its learning state. diff --git a/README.zh-CN.md b/README.zh-CN.md index 17b3046..ba7dce3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -37,6 +37,8 @@ python3 scripts/check_public_tree.py 示例:`examples/words.json`。请确认导入内容的分发与使用许可。生产环境的第三方词库和音频没有随本仓库分发。 +未回答的题目会在计分事务中核对已保存选项和当前内容;导入改变题目内容后,页面会重新出题。临时数据库错误保留轮次供重试。数据库从 schema 3 升级到 4 时保留已有进度,旧题快照会在再次使用时重建。设计与磁盘回归测试见 [持久化题目说明](docs/persisted-review.md)。 + ## 可选语音网关 默认不联系网络服务。设备离线语音是否可用取决于系统安装的语音包。需要下载发音时,自行实现 [网关协议](docs/speech-gateway.md),再显式配置自己的地址: diff --git a/docs/persisted-review.md b/docs/persisted-review.md new file mode 100644 index 0000000..b4da8f2 --- /dev/null +++ b/docs/persisted-review.md @@ -0,0 +1,70 @@ +# Persisted review questions + +Review progress belongs to a local user, query, and sense. Reimporting a dossier +updates its content without resetting earned stages, attempt counts, or learning +timestamps. A content update is not an automatic relearning policy. + +An unanswered question has a narrower lifetime. Its displayed content and answer +must still match the dictionary when the answer is recorded. SQLite schema version +4 stores a `content_fingerprint` alongside each question's choices and correct +index. This is a SHA-256 hash of a versioned JSON representation containing: + +- The target ID, learning stage, and normalized review language. +- The target word (also used for pronunciation), part of speech, and that + language's correct meaning, with answer whitespace normalized. +- For a context question, its English example and highlighted target form. +- All four saved choice strings in order and the saved correct index. + +An independent question does not display the example. Changes to its example do +not invalidate that question. Unused translations, other language meanings, +attempt counters, and the dossier's global content revision are also excluded. +Changing a displayed dependency invalidates the unanswered question even when the +correct option's text stays the same. + +Distractors are frozen text, not references to live dictionary entries. Editing +another word or replacing an external candidate pool does not change the choices +or correct index of a saved question. The complete frozen choices are included +in its fingerprint. Candidate selection's existing language and duplicate checks +still apply when generating a new question. + +## Transaction boundaries + +Restoring a saved question reads the current target and snapshot in one SQLite +transaction. Generating a new question may load candidates outside a transaction; +the insertion transaction then rereads the session position and the target's +content dependencies. If the content changed while candidates loaded, preparation +reports a changed question instead of saving the obsolete one or skipping the +target. Another valid snapshot already saved for that position wins. + +Submitting an answer checks the active session position, saved choices/index, +request content fingerprint, and current target fingerprint in the same +transaction as the progress, attempt, and session updates. A stale request changes +none of those records. A duplicate submission at an already advanced position +also changes none of them. If an answer commits before an import, its earned +progress is retained by that later import. + +`ReviewQuestionChanged` asks the review page to prepare its current position +again. A failure during this preparation uses the existing Retry state. A +temporary database failure is propagated separately; it is not evidence that a +question or session contains invalid data. Session retirement for invalid data +only follows a failure to decode the stored session payload. + +## Existing databases and verification + +The schema 3 to 4 migration adds a nullable column without changing progress, +attempts, or session positions. A legacy NULL fingerprint cannot establish what +content the old question tested. The question is rebuilt from current content +when requested; the migration does not backfill an invented fingerprint onto +its old choices. Malformed or stale snapshots are likewise rebuilt. Only the +question snapshot is discarded, and database read/delete failures remain errors. + +`test/persisted_review_content_test.dart` uses fresh file-backed SQLite databases, +closes and reopens them, exercises the schema upgrade, injects one-shot read and +retirement failures, interrupts an attempt write with a SQLite abort trigger, and +holds candidate loading across an import. Widget regressions cover updating a +visible question and retrying after a preparation failure. + +These checks establish application-level snapshot identity and transaction +behavior. They do not simulate a power cut or establish filesystem durability on +every device. The fingerprint is a consistency check, not authentication against +a party able to rewrite the local database. diff --git a/lib/pages/flash_cards/flash_cards_widget.dart b/lib/pages/flash_cards/flash_cards_widget.dart index fc2c307..ee363be 100644 --- a/lib/pages/flash_cards/flash_cards_widget.dart +++ b/lib/pages/flash_cards/flash_cards_widget.dart @@ -480,6 +480,18 @@ class _FlashCardsWidgetState extends State await _requestExit(); return; } + if (error is ReviewQuestionChanged) { + // Content can change without advancing the session or learning stage. + // Re-prepare this position; a storage failure during preparation uses + // the existing Retry state instead of leaving a stale answer enabled. + setState(() { + _submitting = false; + _selectedIndex = null; + _question = null; + }); + await _advanceToNext(showPreparation: true); + return; + } if (await _recoverAdvancedAnswer(session, question)) return; if (!mounted) return; if (!_accountIsCurrent) { diff --git a/lib/services/learning_repository.dart b/lib/services/learning_repository.dart index 9d83ceb..45e74b9 100644 --- a/lib/services/learning_repository.dart +++ b/lib/services/learning_repository.dart @@ -120,6 +120,14 @@ class ReviewQuestion { ReviewTestType get type => target.requiredTest; } +/// The caller must prepare the current question again before submitting. +/// This is distinct from an operational database failure, which is retryable +/// without changing either the question or the session. +class ReviewQuestionChanged extends StateError { + ReviewQuestionChanged() + : super('The review question changed. Prepare it again.'); +} + @immutable class ReviewSessionState { const ReviewSessionState({ @@ -197,7 +205,7 @@ class LearningRepository { static final instance = LearningRepository._(); static const _uuid = Uuid(); static const _databaseName = 'wordai_learning.db'; - static const _databaseVersion = 3; + static const _databaseVersion = 4; static const _sessionStateFields = [ 'started_at', 'ended_at', @@ -330,7 +338,14 @@ class LearningRepository { int newVersion, ) async { if (oldVersion < 2) await _createSyncStateTable(db); - if (oldVersion < 3) await _createReviewQuestionsTable(db); + if (oldVersion < 3) { + await _createReviewQuestionsTable(db); + } else if (oldVersion < 4) { + // NULL identifies a legacy snapshot. Its original content cannot be + // inferred from the current dictionary; rebuild it when next requested. + await db.execute( + 'ALTER TABLE review_questions ADD COLUMN content_fingerprint TEXT'); + } } static Future _createReviewQuestionsTable(DatabaseExecutor db) => @@ -342,6 +357,7 @@ class LearningRepository { CHECK(language_code IN ('en', 'zh_Hans', 'zh_Hant')), options_json TEXT NOT NULL, correct_index INTEGER NOT NULL CHECK(correct_index BETWEEN 0 AND 3), + content_fingerprint TEXT, created_at INTEGER NOT NULL, PRIMARY KEY(session_id, target_id, language_code), FOREIGN KEY(session_id) REFERENCES review_sessions(session_id) @@ -540,17 +556,9 @@ class LearningRepository { ); ReviewSessionState? selected; for (final row in rows) { + late ReviewSessionState session; try { - final session = _sessionFromMap(row); - if (scope != null && !await _sessionMatchesScope(db, session, scope)) { - await _retireSession(db, uid, session.id, 'scope_changed'); - continue; - } - if (selected == null) { - selected = session; - } else { - await _retireSession(db, uid, session.id, 'superseded_session'); - } + session = _sessionFromMap(row); } catch (error, stack) { // Preserve answers and original payload for diagnostics; retire only // this unusable session so Retry can create a healthy round. @@ -562,6 +570,18 @@ class LearningRepository { ); _reportSyncError('learning.invalidSession', const FormatException('Invalid saved review session'), stack); + continue; + } + // Only decoding can establish that a payload is invalid. A failed read + // or retirement write must propagate, leaving a valid round retryable. + if (scope != null && !await _sessionMatchesScope(db, session, scope)) { + await _retireSession(db, uid, session.id, 'scope_changed'); + continue; + } + if (selected == null) { + selected = session; + } else { + await _retireSession(db, uid, session.id, 'superseded_session'); } } return selected; @@ -741,43 +761,33 @@ class LearningRepository { ) async { if (session.isComplete || session.currentIndex < 0) return null; final db = await database; - final sessionRows = await db.query( - 'review_sessions', - where: 'session_id = ? AND status = ?', - whereArgs: [session.id, 'active'], - limit: 1, - ); - if (sessionRows.isEmpty) return null; - final persistedSession = _sessionFromMap(sessionRows.first); - if (persistedSession.currentIndex != session.currentIndex || - persistedSession.targetIds.length != session.targetIds.length || - persistedSession.currentIndex >= persistedSession.targetIds.length || - persistedSession.targetIds[persistedSession.currentIndex] != - session.targetIds[session.currentIndex]) { - return null; - } - final target = await targetById(session.targetIds[session.currentIndex]); - if (target == null || target.stage == LearningStage.learned) return null; final language = _reviewLanguage(languageCode); + final prepared = await db + .transaction<({LearningTarget? target, ReviewQuestion? saved})>( + (txn) async { + final target = await _currentQuestionTarget(txn, session); + if (target == null || + target.stage == LearningStage.learned || + !_isMeaningValid( + _cleanMeaning(target.meaningFor(language)), language) || + (target.requiredTest == ReviewTestType.context && + target.exampleEnglish.trim().isEmpty)) { + return (target: null, saved: null); + } + // Read the target and its saved choices in one SQLite snapshot. Reopening + // a round does not depend on the current distractor pool or a network call. + final saved = await _readQuestionSnapshot(txn, + sessionId: session.id, target: target, languageCode: language); + return (target: target, saved: saved); + }); + final target = prepared.target; + if (target == null) return null; + if (prepared.saved != null) return prepared.saved; final correct = _cleanMeaning(target.meaningFor(language)); - if (!_isMeaningValid(correct, language) || - (target.requiredTest == ReviewTestType.context && - target.exampleEnglish.trim().isEmpty)) { - return null; - } final seed = int.parse( _hash('${session.id}:${target.targetId}:$language').substring(0, 8), radix: 16, ); - final saved = await _readQuestionSnapshot( - db, - sessionId: session.id, - target: target, - languageCode: language, - currentCorrect: correct, - ); - if (saved != null) return saved; - final localCandidates = await _learningMeaningCandidates( target, language, @@ -796,30 +806,28 @@ class LearningRepository { ], seed: seed, ); - if (distractors.length < 3) return null; - final random = Random(seed); - final options = distractors.toList(growable: true); - final correctIndex = random.nextInt(4); - options.insert(correctIndex, correct); - final now = DateTime.now().millisecondsSinceEpoch; - final stillCurrent = await db.transaction((txn) async { - final currentRows = await txn.query( - 'review_sessions', - columns: const ['current_index', 'target_ids_json'], - where: 'session_id = ? AND status = ?', - whereArgs: [session.id, 'active'], - limit: 1, - ); - if (currentRows.isEmpty) return false; - final index = (currentRows.first['current_index'] as num?)?.toInt() ?? -1; - final ids = jsonDecode(currentRows.first['target_ids_json'] as String); - if (ids is! List || - index != session.currentIndex || - index < 0 || - index >= ids.length || - ids[index] != target.targetId) { - return false; + return db.transaction((txn) async { + final current = await _currentQuestionTarget(txn, session); + if (current == null) return null; + if (_questionContent(current, language) != + _questionContent(target, language)) { + // Do not report an unavailable target: that would cause the caller to + // skip it. Preparation may be retried using the newly imported content. + throw ReviewQuestionChanged(); } + final saved = await _readQuestionSnapshot(txn, + sessionId: session.id, target: current, languageCode: language); + if (saved != null) return saved; + if (distractors.length < 3) return null; + final options = distractors.toList(growable: true); + final correctIndex = Random(seed).nextInt(4); + options.insert(correctIndex, correct); + final question = ReviewQuestion( + target: current, + options: List.unmodifiable(options), + correctIndex: correctIndex, + languageCode: language, + ); await txn.insert( 'review_questions', { @@ -828,20 +836,36 @@ class LearningRepository { 'language_code': language, 'options_json': jsonEncode(options), 'correct_index': correctIndex, - 'created_at': now, + 'content_fingerprint': _questionFingerprint(question), + 'created_at': DateTime.now().millisecondsSinceEpoch, }, - conflictAlgorithm: ConflictAlgorithm.ignore, ); - return true; + return question; }); - if (!stillCurrent) return null; - return _readQuestionSnapshot( - db, - sessionId: session.id, - target: target, - languageCode: language, - currentCorrect: correct, + } + + Future _currentQuestionTarget( + DatabaseExecutor db, ReviewSessionState session) async { + final rows = await db.query( + 'review_sessions', + where: 'session_id = ? AND status = ?', + whereArgs: [session.id, 'active'], + limit: 1, ); + if (rows.isEmpty) return null; + final saved = _sessionFromMap(rows.first); + if (saved.currentIndex != session.currentIndex || + !listEquals(saved.targetIds, session.targetIds) || + saved.isComplete) { + return null; + } + final targets = await db.query( + 'learning_progress', + where: 'target_id = ? AND uid = ?', + whereArgs: [saved.targetIds[saved.currentIndex], rows.first['uid']], + limit: 1, + ); + return targets.isEmpty ? null : LearningTarget.fromMap(targets.first); } Future> _loadMeaningPool( @@ -943,7 +967,6 @@ class LearningRepository { required String sessionId, required LearningTarget target, required String languageCode, - required String currentCorrect, }) async { final rows = await db.query( 'review_questions', @@ -965,15 +988,20 @@ class LearningRepository { final options = decoded.cast(); if (options.toSet().length != 4 || options.map(_answerSignature).toSet().length != 4 || - _cleanMeaning(options[correctIndex]) != currentCorrect) { + _cleanMeaning(options[correctIndex]) != + _cleanMeaning(target.meaningFor(languageCode))) { throw const FormatException('Stale saved review question'); } - return ReviewQuestion( + final question = ReviewQuestion( target: target, options: List.unmodifiable(options), correctIndex: correctIndex, languageCode: languageCode, ); + if (rows.first['content_fingerprint'] != _questionFingerprint(question)) { + throw const FormatException('Unbound or stale saved review question'); + } + return question; } on Object { await db.delete( 'review_questions', @@ -984,6 +1012,32 @@ class LearningRepository { } } + // Versioned, finite dependencies of the displayed question and its answer. + // Other locales, unused translations, progress counters, global revisions, + // and the live distractor pool are deliberately not part of this identity. + static String _questionContent(LearningTarget target, String languageCode) => + jsonEncode([ + 'review-content-v1', + target.targetId, + target.stage.value, + languageCode, + target.word, + target.partOfSpeech, + _cleanMeaning(target.meaningFor(languageCode)), + if (target.requiredTest == ReviewTestType.context) ...[ + target.exampleEnglish, + target.targetForm, + ], + ]); + + static String _questionFingerprint(ReviewQuestion question, + {LearningTarget? target}) => + _hash(jsonEncode([ + _questionContent(target ?? question.target, question.languageCode), + question.options, + question.correctIndex, + ])); + static String _reviewLanguage(String languageCode) { if (languageCode == 'zh_Hant') return 'zh_Hant'; if (languageCode.startsWith('zh')) return 'zh_Hans'; @@ -1075,7 +1129,11 @@ class LearningRepository { } final questionRows = await txn.query( 'review_questions', - columns: const ['options_json', 'correct_index'], + columns: const [ + 'options_json', + 'correct_index', + 'content_fingerprint' + ], where: 'session_id = ? AND target_id = ? AND language_code = ?', whereArgs: [ session.id, @@ -1085,10 +1143,15 @@ class LearningRepository { limit: 1, ); if (questionRows.isEmpty) { - throw StateError('The saved review question is missing.'); + throw ReviewQuestionChanged(); + } + final Object? persistedOptions; + try { + persistedOptions = + jsonDecode(questionRows.first['options_json'] as String); + } on FormatException { + throw ReviewQuestionChanged(); } - final persistedOptions = - jsonDecode(questionRows.first['options_json'] as String); final persistedCorrect = (questionRows.first['correct_index'] as num?)?.toInt() ?? -1; if (persistedOptions is! List || @@ -1098,7 +1161,7 @@ class LearningRepository { persistedCorrect != question.correctIndex || persistedCorrect < 0 || persistedCorrect >= persistedOptions.length) { - throw StateError('The review question does not match its saved copy.'); + throw ReviewQuestionChanged(); } correct = selectedIndex == persistedCorrect; final progressRows = await txn.query( @@ -1108,6 +1171,14 @@ class LearningRepository { limit: 1, ); if (progressRows.isEmpty) throw StateError('Learning target is missing.'); + final current = LearningTarget.fromMap(progressRows.first); + final fingerprint = questionRows.first['content_fingerprint']; + if (fingerprint != _questionFingerprint(question) || + fingerprint != _questionFingerprint(question, target: current)) { + // Validation and the answer's writes share one transaction. Reimports + // preserve earned progress but cannot credit this obsolete question. + throw ReviewQuestionChanged(); + } previous = LearningStageValue.fromInt(progressRows.first['stage']); if (previous == LearningStage.learned) { throw StateError( diff --git a/pubspec.yaml b/pubspec.yaml index c46aab9..08c8d07 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: word_a_i description: Free and open-source offline vocabulary learning with optional self-hosted speech. publish_to: none -version: 0.1.2+3 +version: 0.1.3+4 environment: sdk: ">=3.6.0 <4.0.0" flutter: ">=3.44.0" diff --git a/test/persisted_review_content_test.dart b/test/persisted_review_content_test.dart new file mode 100644 index 0000000..fd47cc8 --- /dev/null +++ b/test/persisted_review_content_test.dart @@ -0,0 +1,473 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:word_a_i/sample_words.dart'; +import 'package:word_a_i/services/dictionary_import.dart'; +import 'package:word_a_i/services/learning_distractor_source.dart'; +import 'package:word_a_i/services/learning_repository.dart'; + +const _replacement = 'a domesticated feline companion with retractable claws'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + sqfliteFfiInit(); + + test('a failed scope read leaves a durable round resumable with its answers', + () async { + final disk = await _Disk.create(); + final session = + (await disk.repo.createSession('local', queries: ['cat', 'river']))!; + final question = (await disk.repo.buildQuestion(session, 'en'))!; + await _answer(disk.repo, session, question); + await disk.reopen(); + + final fault = _FailOnceDatabase(disk.db, scopeRead: true); + final retrying = LearningRepository.forTesting(fault); + await expectLater(retrying.resumeScopedSession('local', ['cat', 'river']), + throwsA(isA<_Busy>())); + expect(fault.failures, 1); + final row = (await disk.db.query('review_sessions')).single; + expect(row['status'], 'active'); + expect(row['exit_reason'], isNull); + expect(row['completed_count'], 1); + + await disk.reopen(); + final restored = + (await disk.repo.resumeScopedSession('local', ['cat', 'river']))!; + expect(restored.id, session.id); + expect(restored.currentIndex, 1); + expect(restored.completedCount, 1); + await expectLater(_answer(disk.repo, session, question), throwsStateError); + final next = (await disk.repo.buildQuestion(restored, 'en'))!; + expect(next.target.targetId, session.targetIds[1]); + await _answer(disk.repo, restored, next); + await disk.reopen(); + expect((await disk.repo.sessionById(session.id)).completedCount, 2); + expect(await disk.db.query('review_attempts'), hasLength(2)); + }); + + test('a failed retirement write is not reclassified as an invalid payload', + () async { + final disk = await _Disk.create(); + final session = (await disk.repo.createSession('local', queries: ['cat']))!; + final fault = _FailOnceDatabase(disk.db, scopeRead: false); + final retrying = LearningRepository.forTesting(fault); + await expectLater(retrying.resumeScopedSession('local', ['river']), + throwsA(isA<_Busy>())); + expect(fault.retirementReasons, ['scope_changed']); + await disk.reopen(); + expect((await disk.db.query('review_sessions')).single['status'], 'active'); + expect((await disk.repo.resumeScopedSession('local', ['cat']))!.id, + session.id); + }); + + test('a proven corrupt session is retired and preparation can recover', + () async { + final disk = await _Disk.create(); + final session = (await disk.repo.createSession('local', queries: ['cat']))!; + await disk.db.update('review_sessions', {'target_ids_json': '[broken'}, + where: 'session_id = ?', whereArgs: [session.id]); + await disk.reopen(); + final fresh = (await disk.repo.createSession('local', queries: ['cat']))!; + expect(fresh.id, isNot(session.id)); + expect(await disk.repo.buildQuestion(fresh, 'en'), isNotNull); + final retired = (await disk.db.query('review_sessions', + where: 'session_id = ?', whereArgs: [session.id])) + .single; + expect(retired['exit_reason'], 'invalid_session'); + expect(retired['target_ids_json'], '[broken'); + expect(await disk.db.query('review_attempts'), isEmpty); + }); + + test('an imported definition cannot earn progress from the old saved answer', + () async { + final disk = await _Disk.create(); + final session = (await disk.repo.createSession('local', queries: ['cat']))!; + final old = (await disk.repo.buildQuestion(session, 'en'))!; + await _importCat(disk.repo, definition: _replacement); + await expectLater(_answer(disk.repo, session, old), + throwsA(isA())); + await disk.reopen(); + final target = (await disk.repo.targetById(old.target.targetId))!; + expect(target.definitionEnglish, _replacement); + expect(target.stage, LearningStage.unlearned); + expect(target.attemptCount, 0); + expect(await disk.db.query('review_attempts'), isEmpty); + final restored = (await disk.repo.resumeScopedSession('local', ['cat']))!; + expect(restored.currentIndex, 0); + final fresh = (await disk.repo.buildQuestion(restored, 'en'))!; + expect(fresh.options[fresh.correctIndex], _replacement); + await _answer(disk.repo, restored, fresh); + await disk.reopen(); + expect((await disk.repo.targetById(old.target.targetId))!.stage, + LearningStage.contextPassed); + expect((await disk.repo.sessionById(session.id)).completedCount, 1); + expect(await disk.db.query('review_attempts'), hasLength(1)); + await _importCat(disk.repo, + definition: 'a tame feline that shares a home with people'); + await disk.reopen(); + // The position check remains first even if both stage and content changed + // after commit. The UI recovers this acknowledgement from the saved index. + await expectLater( + _answer(disk.repo, restored, fresh), + throwsA(isA() + .having((error) => error is ReviewQuestionChanged, + 'content conflict', isFalse) + .having((error) => error.message, 'message', + 'This review answer was already handled.'))); + expect(await disk.db.query('review_attempts'), hasLength(1)); + }); + + test( + 'reimport preserves earned progress while refreshing an independent test', + () async { + final disk = await _Disk.create(); + final first = (await disk.repo.createSession('local', queries: ['cat']))!; + final context = (await disk.repo.buildQuestion(first, 'en'))!; + await _answer(disk.repo, first, context); + await disk.repo.finishSession( + uid: 'local', sessionId: first.id, completed: true, activeMs: 10); + final second = (await disk.repo.createSession('local', queries: ['cat']))!; + final old = (await disk.repo.buildQuestion(second, 'en'))!; + expect(old.type, ReviewTestType.independent); + final before = (await disk.db.query('learning_progress', + where: 'target_id = ?', whereArgs: [old.target.targetId])) + .single; + await _importCat(disk.repo, definition: _replacement); + await expectLater( + _answer(disk.repo, second, old), throwsA(isA())); + await disk.reopen(); + final preserved = (await disk.db.query('learning_progress', + where: 'target_id = ?', whereArgs: [old.target.targetId])) + .single; + for (final column in [ + 'stage', + 'attempt_count', + 'last_tested_at', + 'context_passed_at', + 'learned_at', + 'learned_once' + ]) { + expect(preserved[column], before[column], reason: column); + } + final restored = (await disk.repo.resumeScopedSession('local', ['cat']))!; + final fresh = (await disk.repo.buildQuestion(restored, 'en'))!; + // Independent questions display a word, not an example. This unused field + // can change without replacing their already validated answer snapshot. + await disk.db.update('learning_progress', + {'example_en': 'A different example about the same cat.'}, + where: 'target_id = ?', whereArgs: [old.target.targetId]); + await _answer(disk.repo, restored, fresh); + await disk.reopen(); + expect((await disk.repo.targetById(old.target.targetId))!.stage, + LearningStage.learned); + expect(await disk.db.query('review_attempts'), hasLength(2)); + }); + + test( + 'context prompt and highlighted form changes invalidate its saved answer', + () async { + final disk = await _Disk.create(); + final session = (await disk.repo.createSession('local', queries: ['cat']))!; + // Keep the correct meaning unchanged: matching option text is insufficient. + for (final change in [ + {'example_en': 'A cat waited beside the door.'}, + {'target_form': 'A cat'}, + {'word': 'domestic cat'}, + {'part_of_speech': 'countable noun'}, + ]) { + final old = (await disk.repo.buildQuestion(session, 'en'))!; + await disk.db.update('learning_progress', change, + where: 'target_id = ?', whereArgs: [old.target.targetId]); + await expectLater(_answer(disk.repo, session, old), + throwsA(isA()), + reason: change.keys.single); + await disk.reopen(); + expect(await disk.repo.buildQuestion(session, 'en'), isNotNull); + } + expect((await disk.repo.sessionById(session.id)).currentIndex, 0); + expect(await disk.db.query('review_attempts'), isEmpty); + }); + + test('frozen distractors and unused target translations remain stable', + () async { + final disk = await _Disk.create(); + final session = (await disk.repo.createSession('local', queries: ['cat']))!; + final question = (await disk.repo.buildQuestion(session, 'en'))!; + // The saved choices are literal text, not live references to these rows. + await disk.db.update( + 'learning_progress', {'definition_en': 'new pool text'}, + where: 'query != ?', whereArgs: ['cat']); + await disk.db.update( + 'learning_progress', + { + 'meaning_zh_hans': '家猫', + 'example_zh_hans': '一只家猫正在休息。', + 'content_version': 'an-unrelated-global-revision', + }, + where: 'query = ?', + whereArgs: ['cat']); + await disk.reopen(); + final resumed = (await disk.repo.buildQuestion(session, 'en'))!; + expect(resumed.options, question.options); + expect(resumed.correctIndex, question.correctIndex); + await _answer(disk.repo, session, question); + expect(await disk.db.query('review_attempts'), hasLength(1)); + }); + + test('a changed localized answer invalidates only that language snapshot', + () async { + final disk = await _Disk.create(); + final session = (await disk.repo.createSession('local', queries: ['cat']))!; + final english = (await disk.repo.buildQuestion(session, 'en'))!; + final chinese = (await disk.repo.buildQuestion(session, 'zh_Hans'))!; + await disk.db.update('learning_progress', {'meaning_zh_hans': '家猫'}, + where: 'query = ?', whereArgs: ['cat']); + await expectLater(_answer(disk.repo, session, chinese), + throwsA(isA())); + await disk.reopen(); + expect((await disk.repo.buildQuestion(session, 'en'))!.options, + english.options); + final refreshed = (await disk.repo.buildQuestion(session, 'zh_Hans'))!; + expect(refreshed.options[refreshed.correctIndex], '家猫'); + await _answer(disk.repo, session, refreshed); + }); + + test('an import during distractor loading cannot insert an obsolete question', + () async { + final disk = await _Disk.create(); + final loading = Completer(); + final release = Completer>(); + final repo = LearningRepository.forTesting(disk.db, meaningLoader: ( + {required languageCode, required seed, required limit}) { + loading.complete(); + return release.future; + }); + final session = (await repo.createSession('local', queries: ['cat']))!; + final pending = repo.buildQuestion(session, 'en'); + final rejected = + expectLater(pending, throwsA(isA())); + await loading.future; + await _importCat(disk.repo, definition: _replacement); + release.complete(const []); + await rejected; + expect(await disk.db.query('review_questions'), isEmpty); + await disk.reopen(); + final restored = (await disk.repo.resumeScopedSession('local', ['cat']))!; + expect(restored.currentIndex, 0); + final fresh = (await disk.repo.buildQuestion(restored, 'en'))!; + expect(fresh.options[fresh.correctIndex], _replacement); + await _answer(disk.repo, restored, fresh); + }); + + test('version 3 disk snapshots are rebuilt without inventing legacy identity', + () async { + final disk = await _Disk.create(); + final first = (await disk.repo.createSession('local', queries: ['cat']))!; + final context = (await disk.repo.buildQuestion(first, 'en'))!; + await _answer(disk.repo, first, context); + await disk.repo.finishSession( + uid: 'local', sessionId: first.id, completed: true, activeMs: 10); + final session = (await disk.repo.createSession('local', queries: ['cat']))!; + final old = (await disk.repo.buildQuestion(session, 'en'))!; + expect(old.type, ReviewTestType.independent); + // Recreate the actual v3 table shape and user_version before reopening it. + await disk.db.execute( + 'ALTER TABLE review_questions DROP COLUMN content_fingerprint'); + await disk.db.setVersion(3); + await _importCat(disk.repo, definition: _replacement); + await disk.reopen(); + expect(await disk.db.getVersion(), 4); + final legacy = (await disk.db.query('review_questions', + where: 'session_id = ?', whereArgs: [session.id])) + .single; + expect(legacy['content_fingerprint'], isNull); + await expectLater(_answer(disk.repo, session, old), + throwsA(isA())); + final fresh = (await disk.repo.buildQuestion(session, 'en'))!; + expect(fresh.options[fresh.correctIndex], _replacement); + expect( + (await disk.db.query('review_questions', + where: 'session_id = ?', whereArgs: [session.id])) + .single['content_fingerprint'], + matches(RegExp(r'^[0-9a-f]{64}$'))); + expect((await disk.repo.targetById(old.target.targetId))!.attemptCount, 1); + expect((await disk.repo.targetById(old.target.targetId))!.stage, + LearningStage.contextPassed); + expect((await disk.repo.resumeScopedSession('local', ['cat']))!.id, + session.id); + expect((await disk.repo.sessionById(session.id)).currentIndex, 0); + expect(await disk.db.query('review_attempts'), hasLength(1)); + await disk.reopen(); + expect( + (await disk.repo.buildQuestion(session, 'en'))!.options, fresh.options); + await _answer(disk.repo, session, fresh); + }); + + test('an aborted attempt insert rolls back progress and retries after reopen', + () async { + final disk = await _Disk.create(); + final session = (await disk.repo.createSession('local', queries: ['cat']))!; + final question = (await disk.repo.buildQuestion(session, 'en'))!; + await disk.db.execute(''' + CREATE TRIGGER fail_attempt BEFORE INSERT ON review_attempts + BEGIN SELECT RAISE(ABORT, 'injected attempt write failure'); END + '''); + await expectLater(_answer(disk.repo, session, question), + throwsA(isA())); + await disk.reopen(); + final untouched = (await disk.repo.targetById(question.target.targetId))!; + expect(untouched.stage, LearningStage.unlearned); + expect(untouched.attemptCount, 0); + expect(untouched.lastTestedAt, isNull); + expect((await disk.repo.sessionById(session.id)).currentIndex, 0); + expect(await disk.db.query('review_attempts'), isEmpty); + expect(await disk.db.query('review_questions'), hasLength(1)); + await disk.db.execute('DROP TRIGGER fail_attempt'); + await _answer(disk.repo, session, question); + await disk.reopen(); + expect((await disk.repo.targetById(question.target.targetId))!.attemptCount, + 1); + expect((await disk.repo.sessionById(session.id)).currentIndex, 1); + }); +} + +Future _answer(LearningRepository repo, + ReviewSessionState session, ReviewQuestion question) => + repo.recordAnswer( + uid: 'local', + session: session, + question: question, + selectedIndex: question.correctIndex, + latencyMs: 10, + activeMs: 10); + +Future _importCat(LearningRepository repo, + {required String definition}) async { + final replacement = sampleWords().first.toJson(); + (replacement['senses'] as List).first['definition_en'] = definition; + await importDictionary(Stream.value(utf8.encode(jsonEncode(replacement))), + repository: repo, uid: 'local'); +} + +class _Disk { + _Disk(this.directory, this.canonicalDirectory, this.path, this.db); + final Directory directory; + final String canonicalDirectory; + final String path; + Database db; + LearningRepository get repo => LearningRepository.forTesting(db); + + static Future<_Disk> create() async { + final directory = await Directory.systemTemp.createTemp('wordai-review-'); + final canonical = await directory.resolveSymbolicLinks(); + final path = p.join(canonical, 'learning.db'); + final disk = _Disk(directory, canonical, path, await _open(path)); + addTearDown(() async { + if (disk.db.isOpen) await disk.db.close(); + // Only this freshly created fixture directory may be removed. Do not + // follow a replaced symlink or operate on any existing application data. + if (await directory.resolveSymbolicLinks() != canonical || + !p.isWithin(canonical, path)) { + throw StateError('The fixture directory identity changed'); + } + await directory.delete(recursive: true); + }); + for (final sample in sampleWords().take(4)) { + await disk.repo.registerDossier('local', sample); + } + return disk; + } + + static Future _open(String path) => + databaseFactoryFfi.openDatabase(path, + options: OpenDatabaseOptions( + version: 4, + singleInstance: false, + onCreate: LearningRepository.createSchema, + onUpgrade: LearningRepository.upgradeSchema, + onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'))); + + Future reopen() async { + await db.close(); + db = await _open(path); + } +} + +/// Injects one operational failure while all successful reads/writes still use +/// the real file-backed SQLite database. No payload is altered by this proxy. +class _FailOnceDatabase implements Database { + _FailOnceDatabase(this.inner, {required this.scopeRead}); + final Database inner; + final bool scopeRead; + int failures = 0; + final retirementReasons = []; + + @override + Future>> query( + String table, { + bool? distinct, + List? columns, + String? where, + List? whereArgs, + String? groupBy, + String? having, + String? orderBy, + int? limit, + int? offset, + }) { + if (scopeRead && + failures == 0 && + table == 'learning_progress' && + columns?.join(',') == 'target_id,query') { + failures++; + return Future.error(_Busy()); + } + return inner.query(table, + distinct: distinct, + columns: columns, + where: where, + whereArgs: whereArgs, + groupBy: groupBy, + having: having, + orderBy: orderBy, + limit: limit, + offset: offset); + } + + @override + Future update( + String table, + Map values, { + String? where, + List? whereArgs, + ConflictAlgorithm? conflictAlgorithm, + }) { + if (table == 'review_sessions') { + retirementReasons.add(values['exit_reason'] as String); + if (!scopeRead && failures == 0) { + failures++; + return Future.error(_Busy()); + } + } + return inner.update(table, values, + where: where, + whereArgs: whereArgs, + conflictAlgorithm: conflictAlgorithm); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Busy extends DatabaseException { + _Busy() : super('database is locked: one-shot test fault'); + @override + int getResultCode() => 5; + @override + Object? get result => null; +} diff --git a/test/review_availability_widget_test.dart b/test/review_availability_widget_test.dart index d888011..80c861d 100644 --- a/test/review_availability_widget_test.dart +++ b/test/review_availability_widget_test.dart @@ -1,10 +1,13 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:word_a_i/pages/flash_cards/flash_cards_widget.dart'; +import 'package:word_a_i/sample_words.dart'; +import 'package:word_a_i/services/dictionary_import.dart'; import 'package:word_a_i/services/learning_repository.dart'; import 'package:word_a_i/services/review_pronunciation.dart'; import 'package:word_a_i/services/wordai_dossier.dart'; @@ -340,6 +343,111 @@ void main() { expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox.shrink()); }); + + for (final failPreparation in [false, true]) { + testWidgets( + failPreparation + ? 'a changed question survives a failed reprepare and a manual Retry' + : 'an imported change refreshes the displayed question before scoring', + (tester) async { + final state = await prepare(tester, 0); + await tester.runAsync(() async { + for (final sample in sampleWords().take(4)) { + await state.repository.registerDossier('u', sample); + } + }); + final repository = _FailOnePreparationRepository(state.db); + await tester.pumpWidget(app(FlashCardsWidget( + repository: repository, + testUid: 'u', + initialWords: const ['cat'], + dossierLoader: (_, __) => const Stream.empty(), + ))); + await waitForReview(tester); + final session = + (await tester.runAsync(() => repository.resumeActiveSession('u')))!; + final old = (await tester + .runAsync(() => repository.buildQuestion(session, 'en')))!; + const updatedMeaning = 'a newly imported meaning for this word'; + await tester.runAsync(() async { + final replacement = sampleWords().first.toJson(); + (replacement['senses'] as List).first['definition_en'] = updatedMeaning; + await importDictionary( + Stream.value(utf8.encode(jsonEncode(replacement))), + repository: repository, + uid: 'u'); + }); + repository.failNextBuild = failPreparation; + await tester.tap(find.text(old.options[old.correctIndex])); + + if (failPreparation) { + await _waitUntil( + tester, () => find.text('Retry').evaluate().isNotEmpty); + expect(find.text('Unable to prepare the next question. Please retry.'), + findsOneWidget); + expect(repository.preparationFailures, 1); + await tester.tap(find.text('Retry')); + } + await _waitUntil( + tester, () => find.text(updatedMeaning).evaluate().isNotEmpty); + await tester.pumpAndSettle(); + expect(find.text(old.options[old.correctIndex]), findsNothing); + expect( + find.text('Progress was not saved. Please try again.'), findsNothing); + final preserved = + (await tester.runAsync(() => repository.sessionById(session.id)))!; + expect(preserved.currentIndex, 0); + expect(preserved.completedCount, 0); + expect(await tester.runAsync(() => state.db.query('review_attempts')), + isEmpty); + + await tester.tap(find.text(updatedMeaning)); + for (var index = 0; index < 100; index++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 5))); + await tester.pump(); + final rows = + (await tester.runAsync(() => state.db.query('review_attempts')))!; + if (rows.isNotEmpty) break; + } + expect(await tester.runAsync(() => state.db.query('review_attempts')), + hasLength(1)); + expect( + (await tester + .runAsync(() => repository.targetById(old.target.targetId)))! + .stage, + LearningStage.contextPassed); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } +} + +Future _waitUntil(WidgetTester tester, bool Function() ready) async { + for (var index = 0; index < 150 && !ready(); index++) { + await tester + .runAsync(() => Future.delayed(const Duration(milliseconds: 5))); + await tester.pump(const Duration(milliseconds: 20)); + } + expect(ready(), isTrue, reason: 'The expected review state did not appear'); +} + +class _FailOnePreparationRepository extends LearningRepository { + _FailOnePreparationRepository(super.database) : super.forTesting(); + bool failNextBuild = false; + int preparationFailures = 0; + + @override + Future buildQuestion( + ReviewSessionState session, String languageCode) { + if (failNextBuild) { + failNextBuild = false; + preparationFailures++; + return Future.error( + StateError('One-shot storage failure during preparation')); + } + return super.buildQuestion(session, languageCode); + } } class _LostAcknowledgementRepository extends LearningRepository {