diff --git a/lib/pangea/analytics_misc/analytics_constants.dart b/lib/pangea/analytics_misc/analytics_constants.dart index 96fb33a13..c41d0ede5 100644 --- a/lib/pangea/analytics_misc/analytics_constants.dart +++ b/lib/pangea/analytics_misc/analytics_constants.dart @@ -14,4 +14,16 @@ class AnalyticsConstants { static const levelUpImageFileName = "LvL_Up_Full_Banner.png"; static const vocabIconFileName = "Vocabulary_icon.png"; static const morphIconFileName = "grammar_icon.png"; + + /// Default days-since-last-used when a construct has never been practiced. + static const int defaultDaysSinceLastUsed = 20; + + /// Multiplier for content words (nouns, verbs, adjectives). + static const int contentWordMultiplier = 10; + + /// Multiplier for function words (articles, prepositions). + static const int functionWordMultiplier = 7; + + /// Bonus multiplier applied to active-tier constructs. + static const int activeTierMultiplier = 2; } diff --git a/lib/pangea/analytics_misc/construct_use_model.dart b/lib/pangea/analytics_misc/construct_use_model.dart index cb298accf..454d27efc 100644 --- a/lib/pangea/analytics_misc/construct_use_model.dart +++ b/lib/pangea/analytics_misc/construct_use_model.dart @@ -6,8 +6,10 @@ import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/practice_tier_enum.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; /// One lemma and a list of construct uses for that lemma class ConstructUses { @@ -98,9 +100,84 @@ class ConstructUses { return result; } + /// Read-only view of all uses, sorted chronologically (oldest first). + List get uses => List.unmodifiable(_uses); + + /// Classify this construct into a [PracticeTier] based on use-type history. + /// + /// Walks uses in reverse chronological order to find the most recent + /// chat use and any incorrect practice answers after it. + PracticeTier get practiceTier { + // Walk reverse chronologically. Everything seen before finding the + // last chat use is more recent than that chat use. + bool hasIncorrectAfterLastChatUse = false; + + for (int i = _uses.length - 1; i >= 0; i--) { + final use = _uses[i]; + + if (use.useType.isChatUse) { + // Found the most recent chat use. + if (use.useType == ConstructUseTypeEnum.wa && + !hasIncorrectAfterLastChatUse) { + return PracticeTier.suppressed; + } + if (use.useType.isAssistedChatUse) { + return PracticeTier.active; + } + // wa with incorrect after → active + if (hasIncorrectAfterLastChatUse) { + return PracticeTier.active; + } + return PracticeTier.maintenance; + } + + if (use.useType.isIncorrectPractice) { + hasIncorrectAfterLastChatUse = true; + } + } + + // No chat use found (only practice history). + if (hasIncorrectAfterLastChatUse) return PracticeTier.active; + return PracticeTier.maintenance; + } + DateTime? lastUseByTypes(List types) => _uses.lastWhereOrNull((u) => types.contains(u.useType))?.timeStamp; + /// Compute priority score for this construct. + /// + /// Higher score = higher priority (should be practiced sooner). + /// Suppressed-tier constructs return 0. + /// + /// When [activityType] is provided, recency is checked against that + /// activity's specific use types (e.g., corPA/incPA for wordMeaning). + /// Otherwise, aggregate recency across all use types is used. + int practiceScore({ActivityTypeEnum? activityType}) { + final tier = practiceTier; + if (tier == PracticeTier.suppressed) return 0; + + // Per-activity-type recency when available, otherwise aggregate. + final DateTime? lastUsedDate = activityType != null + ? lastUseByTypes(activityType.associatedUseTypes) + : lastUsed; + + final daysSince = lastUsedDate == null + ? AnalyticsConstants.defaultDaysSinceLastUsed + : DateTime.now().difference(lastUsedDate).inDays; + + final wordMultiplier = id.isContentWord + ? AnalyticsConstants.contentWordMultiplier + : AnalyticsConstants.functionWordMultiplier; + + var score = daysSince * wordMultiplier; + + if (tier == PracticeTier.active) { + score *= AnalyticsConstants.activeTierMultiplier; + } + + return score; + } + Map toJson() { final json = { 'construct_id': id.toJson(), diff --git a/lib/pangea/analytics_misc/construct_use_type_enum.dart b/lib/pangea/analytics_misc/construct_use_type_enum.dart index ec5cae6d8..a645d7721 100644 --- a/lib/pangea/analytics_misc/construct_use_type_enum.dart +++ b/lib/pangea/analytics_misc/construct_use_type_enum.dart @@ -462,6 +462,50 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { return null; } } + + /// Whether this use type represents direct chat production (wa, ga, ta). + bool get isChatUse { + switch (this) { + case ConstructUseTypeEnum.wa: + case ConstructUseTypeEnum.ga: + case ConstructUseTypeEnum.ta: + return true; + default: + return false; + } + } + + /// Whether this chat use involved assistance (ga = IGC, ta = IT). + bool get isAssistedChatUse { + switch (this) { + case ConstructUseTypeEnum.ga: + case ConstructUseTypeEnum.ta: + return true; + default: + return false; + } + } + + /// Whether this is an incorrect answer in any practice activity. + bool get isIncorrectPractice { + switch (this) { + case ConstructUseTypeEnum.incPA: + case ConstructUseTypeEnum.incWL: + case ConstructUseTypeEnum.incHWL: + case ConstructUseTypeEnum.incL: + case ConstructUseTypeEnum.incM: + case ConstructUseTypeEnum.incMM: + case ConstructUseTypeEnum.incIt: + case ConstructUseTypeEnum.incIGC: + case ConstructUseTypeEnum.incLM: + case ConstructUseTypeEnum.incLA: + case ConstructUseTypeEnum.incGC: + case ConstructUseTypeEnum.incGE: + return true; + default: + return false; + } + } } class ConstructUseTypeUtil { diff --git a/lib/pangea/analytics_misc/practice_tier_enum.dart b/lib/pangea/analytics_misc/practice_tier_enum.dart new file mode 100644 index 000000000..ab3cf8d22 --- /dev/null +++ b/lib/pangea/analytics_misc/practice_tier_enum.dart @@ -0,0 +1,14 @@ +/// Priority tier for spaced repetition scoring. +/// +/// Tier is determined by how the user most recently encountered the word +/// in chat (wa/ga/ta) and whether they've struggled with it in practice. +enum PracticeTier { + /// User demonstrated mastery (wa) with no subsequent errors — skip entirely. + suppressed, + + /// User needed help (ta/ga) or recently got it wrong — prioritize. + active, + + /// Standard aging-based priority (correctly practiced, but aging). + maintenance, +} diff --git a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart index d1a2ae7a8..c0c6f3b1d 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart @@ -127,14 +127,16 @@ class AnalyticsPracticeSessionRepo { .getAggregatedConstructs(ConstructTypeEnum.vocab, language) .then((map) => map.values.toList()); - // sort by last used descending, nulls first + // Score and sort by priority (highest first). Uses shared scorer for + // consistent prioritization with message practice. constructs.sort((a, b) { - final dateA = a.lastUsed; - final dateB = b.lastUsed; - if (dateA == null && dateB == null) return 0; - if (dateA == null) return -1; - if (dateB == null) return 1; - return dateA.compareTo(dateB); + final scoreA = a.practiceScore( + activityType: ActivityTypeEnum.lemmaMeaning, + ); + final scoreB = b.practiceScore( + activityType: ActivityTypeEnum.lemmaMeaning, + ); + return scoreB.compareTo(scoreA); }); final Set seemLemmas = {}; @@ -162,14 +164,12 @@ class AnalyticsPracticeSessionRepo { .getAggregatedConstructs(ConstructTypeEnum.vocab, language) .then((map) => map.values.toList()); - // sort by last used descending, nulls first + // Score and sort by priority (highest first). Uses shared scorer for + // consistent prioritization with message practice. constructs.sort((a, b) { - final dateA = a.lastUsed; - final dateB = b.lastUsed; - if (dateA == null && dateB == null) return 0; - if (dateA == null) return -1; - if (dateB == null) return 1; - return dateA.compareTo(dateB); + final scoreA = a.practiceScore(activityType: ActivityTypeEnum.lemmaAudio); + final scoreB = b.practiceScore(activityType: ActivityTypeEnum.lemmaAudio); + return scoreB.compareTo(scoreA); }); final Set seenLemmas = {}; @@ -246,14 +246,16 @@ class AnalyticsPracticeSessionRepo { } } - // sort by last used descending, nulls first + // Score and sort by priority (highest first). Uses shared scorer for + // consistent prioritization with message practice. constructs.sort((a, b) { - final dateA = a.lastUsed; - final dateB = b.lastUsed; - if (dateA == null && dateB == null) return 0; - if (dateA == null) return -1; - if (dateB == null) return 1; - return dateA.compareTo(dateB); + final scoreA = a.practiceScore( + activityType: ActivityTypeEnum.grammarCategory, + ); + final scoreB = b.practiceScore( + activityType: ActivityTypeEnum.grammarCategory, + ); + return scoreB.compareTo(scoreA); }); final targets = []; diff --git a/lib/pangea/constructs/construct_identifier.dart b/lib/pangea/constructs/construct_identifier.dart index e0c240621..da0cff3cf 100644 --- a/lib/pangea/constructs/construct_identifier.dart +++ b/lib/pangea/constructs/construct_identifier.dart @@ -7,6 +7,7 @@ import 'package:collection/collection.dart'; import 'package:matrix/matrix.dart' hide Result; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart'; import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/user_lemma_info_extension.dart'; @@ -182,4 +183,11 @@ class ConstructIdentifier { text: PangeaTokenText.fromString(lemma), morph: {}, ); + + /// Score for a construct that has never been seen (no use history). + int get unseenPracticeScore => + AnalyticsConstants.defaultDaysSinceLastUsed * + (isContentWord + ? AnalyticsConstants.contentWordMultiplier + : AnalyticsConstants.functionWordMultiplier); } diff --git a/lib/pangea/practice_activities/practice_selection_repo.dart b/lib/pangea/practice_activities/practice_selection_repo.dart index a8179ae6e..089e2cd67 100644 --- a/lib/pangea/practice_activities/practice_selection_repo.dart +++ b/lib/pangea/practice_activities/practice_selection_repo.dart @@ -239,17 +239,10 @@ class PracticeSelectionRepo { .getConstructUses(ids, language); for (final token in tokens) { - final construct = constructs[idMap[token]]; - final lastUsed = construct?.lastUseByTypes( - activityType.associatedUseTypes, - ); - - final daysSinceLastUsed = lastUsed == null - ? 20 - : DateTime.now().difference(lastUsed).inDays; - + final id = idMap[token]!; scores[token] = - daysSinceLastUsed * (token.vocabConstructID.isContentWord ? 10 : 7); + constructs[id]?.practiceScore(activityType: activityType) ?? + id.unseenPracticeScore; } return scores; } diff --git a/test/pangea/practice_target_scorer_test.dart b/test/pangea/practice_target_scorer_test.dart new file mode 100644 index 000000000..eaa9e4d0b --- /dev/null +++ b/test/pangea/practice_target_scorer_test.dart @@ -0,0 +1,454 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/practice_tier_enum.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; + +/// Helper to create a [OneConstructUse] with minimal required fields. +OneConstructUse _makeUse( + ConstructUseTypeEnum type, { + DateTime? time, + String lemma = 'test', + String category = 'verb', + int? xp, +}) { + return OneConstructUse( + useType: type, + lemma: lemma, + constructType: ConstructTypeEnum.vocab, + metadata: ConstructUseMetaData( + roomId: null, + timeStamp: time ?? DateTime.now(), + ), + category: category, + form: lemma, + xp: xp ?? type.pointValue, + ); +} + +/// Helper to create a [ConstructUses] wrapper. +ConstructUses _makeConstructUses( + List uses, { + String lemma = 'test', + String category = 'verb', +}) { + return ConstructUses( + uses: uses, + constructType: ConstructTypeEnum.vocab, + lemma: lemma, + category: category, + ); +} + +ConstructIdentifier _makeId({String lemma = 'test', String category = 'verb'}) { + return ConstructIdentifier( + lemma: lemma, + type: ConstructTypeEnum.vocab, + category: category, + ); +} + +void main() { + group('ConstructUseTypeEnum boolean getters', () { + test('isChatUse is true for wa, ga, ta', () { + expect(ConstructUseTypeEnum.wa.isChatUse, true); + expect(ConstructUseTypeEnum.ga.isChatUse, true); + expect(ConstructUseTypeEnum.ta.isChatUse, true); + expect(ConstructUseTypeEnum.corPA.isChatUse, false); + expect(ConstructUseTypeEnum.incLM.isChatUse, false); + expect(ConstructUseTypeEnum.click.isChatUse, false); + }); + + test('isAssistedChatUse is true for ga, ta only', () { + expect(ConstructUseTypeEnum.ga.isAssistedChatUse, true); + expect(ConstructUseTypeEnum.ta.isAssistedChatUse, true); + expect(ConstructUseTypeEnum.wa.isAssistedChatUse, false); + }); + + test('isIncorrectPractice covers all inc* types', () { + expect(ConstructUseTypeEnum.incPA.isIncorrectPractice, true); + expect(ConstructUseTypeEnum.incWL.isIncorrectPractice, true); + expect(ConstructUseTypeEnum.incLM.isIncorrectPractice, true); + expect(ConstructUseTypeEnum.incGE.isIncorrectPractice, true); + expect(ConstructUseTypeEnum.corPA.isIncorrectPractice, false); + expect(ConstructUseTypeEnum.wa.isIncorrectPractice, false); + expect(ConstructUseTypeEnum.click.isIncorrectPractice, false); + }); + }); + + group('ConstructUses.practiceTier classification', () { + test('null uses treated as maintenance by scorer', () { + // null has no .practiceTier — scorer handles it + final id = _makeId(category: 'verb'); + final score = id.unseenPracticeScore; + expect(score, greaterThan(0)); // maintenance, not suppressed + }); + + test('empty uses → maintenance', () { + final uses = _makeConstructUses([]); + expect(uses.practiceTier, PracticeTier.maintenance); + }); + + test('only wa use → suppressed', () { + final uses = _makeConstructUses([_makeUse(ConstructUseTypeEnum.wa)]); + expect(uses.practiceTier, PracticeTier.suppressed); + }); + + test('wa then correct practice → suppressed (no incorrect)', () { + final now = DateTime.now(); + final uses = _makeConstructUses([ + _makeUse( + ConstructUseTypeEnum.wa, + time: now.subtract(const Duration(days: 5)), + ), + _makeUse( + ConstructUseTypeEnum.corLM, + time: now.subtract(const Duration(days: 3)), + ), + _makeUse( + ConstructUseTypeEnum.corPA, + time: now.subtract(const Duration(days: 1)), + ), + ]); + expect(uses.practiceTier, PracticeTier.suppressed); + }); + + test('wa then incorrect practice → active', () { + final now = DateTime.now(); + final uses = _makeConstructUses([ + _makeUse( + ConstructUseTypeEnum.wa, + time: now.subtract(const Duration(days: 5)), + ), + _makeUse( + ConstructUseTypeEnum.incLM, + time: now.subtract(const Duration(days: 2)), + ), + ]); + expect(uses.practiceTier, PracticeTier.active); + }); + + test('ga use (IGC correction) → active', () { + final uses = _makeConstructUses([_makeUse(ConstructUseTypeEnum.ga)]); + expect(uses.practiceTier, PracticeTier.active); + }); + + test('ta use (IT translation) → active', () { + final uses = _makeConstructUses([_makeUse(ConstructUseTypeEnum.ta)]); + expect(uses.practiceTier, PracticeTier.active); + }); + + test('ga then wa → suppressed (wa is most recent chat use)', () { + final now = DateTime.now(); + final uses = _makeConstructUses([ + _makeUse( + ConstructUseTypeEnum.ga, + time: now.subtract(const Duration(days: 10)), + ), + _makeUse( + ConstructUseTypeEnum.wa, + time: now.subtract(const Duration(days: 1)), + ), + ]); + expect(uses.practiceTier, PracticeTier.suppressed); + }); + + test('wa then ga → active (ga is most recent chat use)', () { + final now = DateTime.now(); + final uses = _makeConstructUses([ + _makeUse( + ConstructUseTypeEnum.wa, + time: now.subtract(const Duration(days: 10)), + ), + _makeUse( + ConstructUseTypeEnum.ga, + time: now.subtract(const Duration(days: 1)), + ), + ]); + expect(uses.practiceTier, PracticeTier.active); + }); + + test('only correct practice (no chat uses) → maintenance', () { + final uses = _makeConstructUses([ + _makeUse(ConstructUseTypeEnum.corPA), + _makeUse(ConstructUseTypeEnum.corLM), + ]); + expect(uses.practiceTier, PracticeTier.maintenance); + }); + + test('only incorrect practice (no chat uses) → active', () { + final uses = _makeConstructUses([_makeUse(ConstructUseTypeEnum.incPA)]); + expect(uses.practiceTier, PracticeTier.active); + }); + + test('click use only → maintenance', () { + final uses = _makeConstructUses([_makeUse(ConstructUseTypeEnum.click)]); + expect(uses.practiceTier, PracticeTier.maintenance); + }); + + test('wa → corPA → incLM → active (incorrect after wa)', () { + final now = DateTime.now(); + final uses = _makeConstructUses([ + _makeUse( + ConstructUseTypeEnum.wa, + time: now.subtract(const Duration(days: 10)), + ), + _makeUse( + ConstructUseTypeEnum.corPA, + time: now.subtract(const Duration(days: 5)), + ), + _makeUse( + ConstructUseTypeEnum.incLM, + time: now.subtract(const Duration(days: 1)), + ), + ]); + expect(uses.practiceTier, PracticeTier.active); + }); + }); + + group('scoreConstruct', () { + test('suppressed tier returns 0', () { + final uses = _makeConstructUses([_makeUse(ConstructUseTypeEnum.wa)]); + expect(uses.practiceScore(), 0); + }); + + test( + 'null uses (never seen) returns default days × content multiplier', + () { + final contentId = _makeId(category: 'verb'); + final score = contentId.unseenPracticeScore; + // 20 days × 10 (content word) = 200 + expect(score, 200); + }, + ); + + test( + 'null uses with function word → default days × function multiplier', + () { + final funcId = _makeId(category: 'det'); + final score = funcId.unseenPracticeScore; + // 20 days × 7 (function word) = 140 + expect(score, 140); + }, + ); + + test('active tier gets 2× multiplier', () { + final now = DateTime.now(); + final uses = _makeConstructUses([ + _makeUse(ConstructUseTypeEnum.ga, time: now), + ]); + final score = uses.practiceScore( + activityType: ActivityTypeEnum.lemmaMeaning, + ); + // ga is most recent chat use → active tier. + // No lemmaMeaning uses → defaults to 20 days. + // 20 × 10 (content) × 2 (active) = 400. + expect(score, 400); + }); + + test('maintenance tier, recent use → low score', () { + final now = DateTime.now(); + final uses = _makeConstructUses([ + _makeUse(ConstructUseTypeEnum.corPA, time: now), + ]); + final score = uses.practiceScore( + activityType: ActivityTypeEnum.wordMeaning, + ); + // corPA matches wordMeaning's associatedUseTypes. + // 0 days × 10 (content) = 0. Maintenance (no chat uses). + expect(score, 0); + }); + + test('per-activity-type recency filters correctly', () { + final now = DateTime.now(); + final uses = _makeConstructUses([ + _makeUse( + ConstructUseTypeEnum.corLM, + time: now.subtract(const Duration(days: 1)), + ), + _makeUse(ConstructUseTypeEnum.corPA, time: now), + ], category: 'noun'); + // Score for lemmaMeaning: corLM was 1 day ago → 1 × 10 = 10 + final lmScore = uses.practiceScore( + activityType: ActivityTypeEnum.lemmaMeaning, + ); + // Score for wordMeaning: corPA was today → 0 × 10 = 0 + final wmScore = uses.practiceScore( + activityType: ActivityTypeEnum.wordMeaning, + ); + expect(lmScore, greaterThan(wmScore)); + }); + + test('no activityType uses aggregate lastUsed', () { + final now = DateTime.now(); + final uses = _makeConstructUses([ + _makeUse( + ConstructUseTypeEnum.click, + time: now.subtract(const Duration(days: 3)), + ), + ]); + final score = uses.practiceScore(); + // No activityType → uses aggregate lastUsed (3 days ago). + // 3 × 10 (content) = 30. + expect(score, 30); + }); + }); + + group('scoring ordering', () { + test('active-tier words rank above same-age maintenance words', () { + final now = DateTime.now(); + final fiveDaysAgo = now.subtract(const Duration(days: 5)); + + // Active: ga use 5 days ago + final activeUses = _makeConstructUses([ + _makeUse(ConstructUseTypeEnum.ga, time: fiveDaysAgo), + ]); + + // Maintenance: click 5 days ago (no chat use → maintenance) + final maintenanceUses = _makeConstructUses([ + _makeUse(ConstructUseTypeEnum.click, time: fiveDaysAgo), + ]); + + final activeScore = activeUses.practiceScore(); + final maintenanceScore = maintenanceUses.practiceScore(); + + expect(activeScore, greaterThan(maintenanceScore)); + }); + + test('content words rank above function words at same recency', () { + final now = DateTime.now(); + final threeDaysAgo = now.subtract(const Duration(days: 3)); + + final contentUses = _makeConstructUses([ + _makeUse(ConstructUseTypeEnum.click, time: threeDaysAgo), + ], category: 'verb'); + final funcUses = _makeConstructUses([ + _makeUse(ConstructUseTypeEnum.click, time: threeDaysAgo), + ], category: 'det'); + + final contentScore = contentUses.practiceScore(); + final funcScore = funcUses.practiceScore(); + + // 3 × 10 = 30 vs 3 × 7 = 21 + expect(contentScore, greaterThan(funcScore)); + }); + + test('older words rank above recent words in same tier', () { + final now = DateTime.now(); + + final oldUses = _makeConstructUses([ + _makeUse( + ConstructUseTypeEnum.click, + time: now.subtract(const Duration(days: 10)), + ), + ], category: 'noun'); + + final recentUses = _makeConstructUses([ + _makeUse( + ConstructUseTypeEnum.click, + time: now.subtract(const Duration(days: 2)), + ), + ], category: 'noun'); + + final oldScore = oldUses.practiceScore(); + final recentScore = recentUses.practiceScore(); + + expect(oldScore, greaterThan(recentScore)); + }); + + test('suppressed words always rank last (score 0)', () { + final id = _makeId(category: 'verb'); + final suppressedUses = _makeConstructUses([ + _makeUse(ConstructUseTypeEnum.wa), + ]); + + final neverSeenScore = id.unseenPracticeScore; + final suppressedScore = suppressedUses.practiceScore(); + + expect(suppressedScore, 0); + expect(neverSeenScore, greaterThan(suppressedScore)); + }); + }); + + group('design scenario from instructions', () { + test( + 'full lifecycle: wa → suppressed, ta → active, inc → stays active, cors → maintenance', + () { + final now = DateTime.now(); + + // Step 1: User types "gato" without assistance → wa → suppressed + final step1 = _makeConstructUses( + [ + _makeUse( + ConstructUseTypeEnum.wa, + time: now.subtract(const Duration(days: 20)), + lemma: 'gato', + ), + ], + lemma: 'gato', + category: 'noun', + ); + expect(step1.practiceTier, PracticeTier.suppressed); + expect(step1.practiceScore(), 0); + + // Step 2: User uses IT for "mariposa" → ta → active + final step2 = _makeConstructUses( + [ + _makeUse( + ConstructUseTypeEnum.ta, + time: now.subtract(const Duration(days: 15)), + lemma: 'mariposa', + ), + ], + lemma: 'mariposa', + category: 'noun', + ); + expect(step2.practiceTier, PracticeTier.active); + expect(step2.practiceScore(), greaterThan(0)); + + // Step 3: User gets "mariposa" wrong → incLM → stays active + final step3 = _makeConstructUses( + [ + _makeUse( + ConstructUseTypeEnum.ta, + time: now.subtract(const Duration(days: 15)), + lemma: 'mariposa', + ), + _makeUse( + ConstructUseTypeEnum.incLM, + time: now.subtract(const Duration(days: 10)), + lemma: 'mariposa', + ), + ], + lemma: 'mariposa', + category: 'noun', + ); + expect(step3.practiceTier, PracticeTier.active); + + // Step 6: User misspells "gato" and IGC corrects → ga → moves from suppressed to active + final step6 = _makeConstructUses( + [ + _makeUse( + ConstructUseTypeEnum.wa, + time: now.subtract(const Duration(days: 20)), + lemma: 'gato', + ), + _makeUse( + ConstructUseTypeEnum.ga, + time: now.subtract(const Duration(days: 1)), + lemma: 'gato', + ), + ], + lemma: 'gato', + category: 'noun', + ); + expect(step6.practiceTier, PracticeTier.active); + expect(step6.practiceScore(), greaterThan(0)); + }, + ); + }); +}