merge with practice_engine

This commit is contained in:
gitea
2026-01-02 20:18:48 +01:00
parent e3ffc4d556
commit cad489b678
37 changed files with 3172 additions and 1 deletions
@@ -0,0 +1,157 @@
import 'package:test/test.dart';
import 'package:practice_engine/models/deck.dart';
import 'package:practice_engine/models/deck_config.dart';
import 'package:practice_engine/models/question.dart';
import 'package:practice_engine/logic/attempt_service.dart';
void main() {
group('Attempt Flow', () {
late Deck deck;
late AttemptService attemptService;
setUp(() {
final config = const DeckConfig(
defaultAttemptSize: 5,
requiredConsecutiveCorrect: 3,
);
final questions = List.generate(10, (i) {
return Question(
id: 'q$i',
prompt: 'Question $i',
answers: ['A', 'B', 'C'],
correctAnswerIndices: [0],
priorityPoints: i,
);
});
deck = Deck(
id: 'deck1',
title: 'Test Deck',
description: 'Test',
questions: questions,
config: config,
);
attemptService = AttemptService(seed: 42);
});
test('createAttempt selects correct number of questions', () {
final attempt = attemptService.createAttempt(deck: deck);
expect(attempt.questions.length, equals(deck.config.defaultAttemptSize));
});
test('createAttempt uses custom attempt size', () {
final attempt = attemptService.createAttempt(
deck: deck,
attemptSize: 3,
);
expect(attempt.questions.length, equals(3));
});
test('createAttempt has no duplicate questions', () {
final attempt = attemptService.createAttempt(deck: deck);
final ids = attempt.questions.map((q) => q.id).toList();
final uniqueIds = ids.toSet();
expect(uniqueIds.length, equals(ids.length));
});
test('processAttempt updates deck with results', () {
final attempt = attemptService.createAttempt(deck: deck);
final answers = <String, int>{};
// Answer all questions correctly
for (final question in attempt.questions) {
answers[question.id] = question.correctIndices.first;
}
final result = attemptService.processAttempt(
deck: deck,
attempt: attempt,
answers: answers,
);
expect(result.updatedDeck.currentAttemptIndex,
equals(deck.currentAttemptIndex + 1));
});
test('processAttempt calculates correct percentage', () {
final attempt = attemptService.createAttempt(
deck: deck,
attemptSize: 3,
);
final answers = <String, int>{};
// Answer 2 out of 3 correctly
answers[attempt.questions[0].id] =
attempt.questions[0].correctIndices.first;
answers[attempt.questions[1].id] =
attempt.questions[1].correctIndices.first;
answers[attempt.questions[2].id] = 999; // Wrong answer
final result = attemptService.processAttempt(
deck: deck,
attempt: attempt,
answers: answers,
);
expect(result.result.percentageCorrect, closeTo(66.67, 0.01));
expect(result.result.correctCount, equals(2));
});
test('processAttempt tracks incorrect questions', () {
final attempt = attemptService.createAttempt(
deck: deck,
attemptSize: 3,
);
final answers = <String, int>{};
// Answer first correctly, rest incorrectly
answers[attempt.questions[0].id] =
attempt.questions[0].correctIndices.first;
answers[attempt.questions[1].id] = 999;
answers[attempt.questions[2].id] = 999;
final result = attemptService.processAttempt(
deck: deck,
attempt: attempt,
answers: answers,
);
expect(result.result.incorrectQuestions.length, equals(2));
});
test('processAttempt updates question streaks correctly', () {
final question = Question(
id: 'q1',
prompt: 'Test',
answers: ['A', 'B'],
correctAnswerIndices: [0],
consecutiveCorrect: 2,
);
final testDeck = deck.copyWith(questions: [question]);
final attempt = attemptService.createAttempt(
deck: testDeck,
attemptSize: 1,
);
final answers = {question.id: question.correctIndices.first};
final result = attemptService.processAttempt(
deck: testDeck,
attempt: attempt,
answers: answers,
);
final updated = result.updatedDeck.questions.first;
expect(updated.consecutiveCorrect, equals(3));
expect(updated.isKnown, equals(true));
});
});
}
@@ -0,0 +1,144 @@
import 'package:test/test.dart';
import 'package:practice_engine/models/question.dart';
import 'package:practice_engine/models/attempt_result.dart';
void main() {
group('AttemptResult', () {
test('fromAnswers calculates correct percentage', () {
final question = Question(
id: 'q1',
prompt: 'Test',
answers: ['A', 'B'],
correctAnswerIndices: [0],
);
final results = [
AnswerResult(
question: question,
userAnswerIndices: [0],
isCorrect: true,
statusChange: QuestionStatusChange.unchanged,
),
AnswerResult(
question: question,
userAnswerIndices: [1],
isCorrect: false,
statusChange: QuestionStatusChange.unchanged,
),
AnswerResult(
question: question,
userAnswerIndices: [0],
isCorrect: true,
statusChange: QuestionStatusChange.unchanged,
),
];
final attemptResult = AttemptResult.fromAnswers(
results: results,
timeSpent: 1000,
);
expect(attemptResult.totalQuestions, equals(3));
expect(attemptResult.correctCount, equals(2));
expect(attemptResult.percentageCorrect, closeTo(66.67, 0.01));
});
test('fromAnswers handles empty results', () {
final attemptResult = AttemptResult.fromAnswers(
results: [],
timeSpent: 0,
);
expect(attemptResult.totalQuestions, equals(0));
expect(attemptResult.correctCount, equals(0));
expect(attemptResult.percentageCorrect, equals(0.0));
});
test('fromAnswers filters incorrect questions', () {
final question = Question(
id: 'q1',
prompt: 'Test',
answers: ['A', 'B'],
correctAnswerIndices: [0],
);
final results = [
AnswerResult(
question: question,
userAnswerIndices: [0],
isCorrect: true,
statusChange: QuestionStatusChange.unchanged,
),
AnswerResult(
question: question,
userAnswerIndices: [1],
isCorrect: false,
statusChange: QuestionStatusChange.unchanged,
),
AnswerResult(
question: question,
userAnswerIndices: [1],
isCorrect: false,
statusChange: QuestionStatusChange.unchanged,
),
];
final attemptResult = AttemptResult.fromAnswers(
results: results,
timeSpent: 1000,
);
expect(attemptResult.incorrectQuestions.length, equals(2));
});
test('fromAnswers includes all results', () {
final question = Question(
id: 'q1',
prompt: 'Test',
answers: ['A', 'B'],
correctAnswerIndices: [0],
);
final results = List.generate(5, (i) {
return AnswerResult(
question: question,
userAnswerIndices: [i % 2],
isCorrect: i % 2 == 0,
statusChange: QuestionStatusChange.unchanged,
);
});
final attemptResult = AttemptResult.fromAnswers(
results: results,
timeSpent: 2000,
);
expect(attemptResult.allResults.length, equals(5));
expect(attemptResult.timeSpent, equals(2000));
});
});
group('AnswerResult', () {
test('contains question and answer information', () {
final question = Question(
id: 'q1',
prompt: 'What is 2+2?',
answers: ['3', '4', '5'],
correctAnswerIndices: [1],
);
final result = AnswerResult(
question: question,
userAnswerIndices: [1],
isCorrect: true,
statusChange: QuestionStatusChange.improved,
);
expect(result.question, equals(question));
expect(result.userAnswerIndices, equals([1]));
expect(result.isCorrect, equals(true));
expect(result.statusChange, equals(QuestionStatusChange.improved));
});
});
}
@@ -0,0 +1,54 @@
import 'package:test/test.dart';
import 'package:practice_engine/models/deck_config.dart';
void main() {
group('DeckConfig', () {
test('has default values', () {
const config = DeckConfig();
expect(config.requiredConsecutiveCorrect, equals(3));
expect(config.defaultAttemptSize, equals(10));
expect(config.priorityIncreaseOnIncorrect, equals(5));
expect(config.priorityDecreaseOnCorrect, equals(2));
expect(config.immediateFeedbackEnabled, equals(true));
});
test('can be created with custom values', () {
const config = DeckConfig(
requiredConsecutiveCorrect: 5,
defaultAttemptSize: 20,
priorityIncreaseOnIncorrect: 10,
priorityDecreaseOnCorrect: 3,
immediateFeedbackEnabled: false,
);
expect(config.requiredConsecutiveCorrect, equals(5));
expect(config.defaultAttemptSize, equals(20));
expect(config.priorityIncreaseOnIncorrect, equals(10));
expect(config.priorityDecreaseOnCorrect, equals(3));
expect(config.immediateFeedbackEnabled, equals(false));
});
test('copyWith creates new config with updated fields', () {
const config = DeckConfig();
final updated = config.copyWith(
requiredConsecutiveCorrect: 4,
immediateFeedbackEnabled: false,
);
expect(updated.requiredConsecutiveCorrect, equals(4));
expect(updated.defaultAttemptSize, equals(config.defaultAttemptSize));
expect(updated.immediateFeedbackEnabled, equals(false));
});
test('equality works correctly', () {
const config1 = DeckConfig();
const config2 = DeckConfig();
const config3 = DeckConfig(requiredConsecutiveCorrect: 5);
expect(config1, equals(config2));
expect(config1, isNot(equals(config3)));
});
});
}
@@ -0,0 +1,115 @@
import 'package:test/test.dart';
import 'package:practice_engine/models/deck.dart';
import 'package:practice_engine/models/deck_config.dart';
import 'package:practice_engine/models/question.dart';
void main() {
group('Deck', () {
late DeckConfig defaultConfig;
late List<Question> sampleQuestions;
setUp(() {
defaultConfig = const DeckConfig();
sampleQuestions = [
Question(
id: 'q1',
prompt: 'What is 2+2?',
answers: ['3', '4', '5'],
correctAnswerIndices: [1],
isKnown: true,
),
Question(
id: 'q2',
prompt: 'What is 3+3?',
answers: ['5', '6', '7'],
correctAnswerIndices: [1],
isKnown: false,
),
Question(
id: 'q3',
prompt: 'What is 4+4?',
answers: ['7', '8', '9'],
correctAnswerIndices: [1],
isKnown: true,
),
];
});
test('calculates numberOfQuestions correctly', () {
final deck = Deck(
id: 'deck1',
title: 'Math Deck',
description: 'Basic math',
questions: sampleQuestions,
config: defaultConfig,
);
expect(deck.numberOfQuestions, equals(3));
});
test('calculates knownCount correctly', () {
final deck = Deck(
id: 'deck1',
title: 'Math Deck',
description: 'Basic math',
questions: sampleQuestions,
config: defaultConfig,
);
expect(deck.knownCount, equals(2));
});
test('calculates practicePercentage correctly', () {
final deck = Deck(
id: 'deck1',
title: 'Math Deck',
description: 'Basic math',
questions: sampleQuestions,
config: defaultConfig,
);
expect(deck.practicePercentage, closeTo(66.67, 0.01));
});
test('practicePercentage is 0 for empty deck', () {
final deck = Deck(
id: 'deck1',
title: 'Empty Deck',
description: 'No questions',
questions: [],
config: defaultConfig,
);
expect(deck.practicePercentage, equals(0.0));
});
test('practicePercentage is 100 when all questions are known', () {
final allKnown = sampleQuestions.map((q) => q.copyWith(isKnown: true)).toList();
final deck = Deck(
id: 'deck1',
title: 'All Known',
description: 'All known',
questions: allKnown,
config: defaultConfig,
);
expect(deck.practicePercentage, equals(100.0));
});
test('copyWith creates new deck with updated fields', () {
final deck = Deck(
id: 'deck1',
title: 'Math Deck',
description: 'Basic math',
questions: sampleQuestions,
config: defaultConfig,
);
final updated = deck.copyWith(title: 'Updated Title');
expect(updated.title, equals('Updated Title'));
expect(updated.id, equals(deck.id));
expect(updated.questions, equals(deck.questions));
});
});
}
@@ -0,0 +1,125 @@
import 'package:test/test.dart';
import 'package:practice_engine/models/deck.dart';
import 'package:practice_engine/models/deck_config.dart';
import 'package:practice_engine/models/question.dart';
import 'package:practice_engine/logic/deck_service.dart';
void main() {
group('Manual Override Logic', () {
late DeckConfig config;
late Deck deck;
setUp(() {
config = const DeckConfig(requiredConsecutiveCorrect: 3);
deck = Deck(
id: 'deck1',
title: 'Test Deck',
description: 'Test',
questions: [
Question(
id: 'q1',
prompt: 'Question 1',
answers: ['A', 'B'],
correctAnswerIndices: [0],
consecutiveCorrect: 1,
isKnown: false,
priorityPoints: 5,
),
Question(
id: 'q2',
prompt: 'Question 2',
answers: ['A', 'B'],
correctAnswerIndices: [0],
consecutiveCorrect: 0,
isKnown: false,
priorityPoints: 10,
),
],
config: config,
);
});
test('markQuestionAsKnown sets streak to threshold and isKnown to true', () {
final updated = DeckService.markQuestionAsKnown(
deck: deck,
questionId: 'q1',
);
final question = updated.questions.firstWhere((q) => q.id == 'q1');
expect(question.consecutiveCorrect,
equals(config.requiredConsecutiveCorrect));
expect(question.isKnown, equals(true));
});
test('markQuestionAsNeedsPractice sets isKnown to false and streak to 0', () {
// First mark as known
var updated = DeckService.markQuestionAsKnown(
deck: deck,
questionId: 'q1',
);
// Then mark as needs practice
updated = DeckService.markQuestionAsNeedsPractice(
deck: updated,
questionId: 'q1',
);
final question = updated.questions.firstWhere((q) => q.id == 'q1');
expect(question.isKnown, equals(false));
expect(question.consecutiveCorrect, equals(0));
});
test('markQuestionAsNeedsPractice preserves priority', () {
final updated = DeckService.markQuestionAsNeedsPractice(
deck: deck,
questionId: 'q1',
);
final question = updated.questions.firstWhere((q) => q.id == 'q1');
expect(question.priorityPoints, equals(5));
});
test('manual override with needs practice ignores correctness', () {
final question = deck.questions.first;
// Answer correctly but mark as needs practice
final updated = DeckService.updateQuestionWithManualOverride(
question: question,
isCorrect: true,
userMarkedNeedsPractice: true,
config: config,
currentAttemptIndex: 0,
);
// Streak should not increment
expect(updated.consecutiveCorrect, equals(question.consecutiveCorrect));
// Priority should not decrease
expect(updated.priorityPoints, equals(question.priorityPoints));
// isKnown should not change
expect(updated.isKnown, equals(question.isKnown));
// But totalAttempts should increment
expect(updated.totalAttempts, equals(question.totalAttempts + 1));
});
test('manual override without needs practice follows normal flow', () {
final question = deck.questions.first;
final updated = DeckService.updateQuestionWithManualOverride(
question: question,
isCorrect: true,
userMarkedNeedsPractice: false,
config: config,
currentAttemptIndex: 0,
);
// Should behave like normal update
expect(updated.consecutiveCorrect, equals(question.consecutiveCorrect + 1));
expect(updated.priorityPoints,
equals(question.priorityPoints - config.priorityDecreaseOnCorrect));
});
});
}
@@ -0,0 +1,102 @@
import 'package:test/test.dart';
import 'package:practice_engine/models/question.dart';
import 'package:practice_engine/models/deck_config.dart';
import 'package:practice_engine/algorithms/priority_manager.dart';
void main() {
group('PriorityManager', () {
late DeckConfig config;
setUp(() {
config = const DeckConfig(
priorityIncreaseOnIncorrect: 5,
priorityDecreaseOnCorrect: 2,
);
});
test('increases priority on incorrect answer', () {
final question = Question(
id: 'q1',
prompt: 'Test',
answers: ['A', 'B'],
correctAnswerIndices: [0],
priorityPoints: 10,
);
final updated = PriorityManager.applyAnswerResult(
question: question,
isCorrect: false,
config: config,
);
expect(updated.priorityPoints,
equals(10 + config.priorityIncreaseOnIncorrect));
});
test('decreases priority on correct answer', () {
final question = Question(
id: 'q1',
prompt: 'Test',
answers: ['A', 'B'],
correctAnswerIndices: [0],
priorityPoints: 10,
);
final updated = PriorityManager.applyAnswerResult(
question: question,
isCorrect: true,
config: config,
);
expect(updated.priorityPoints,
equals(10 - config.priorityDecreaseOnCorrect));
});
test('priority cannot go below 0', () {
final question = Question(
id: 'q1',
prompt: 'Test',
answers: ['A', 'B'],
correctAnswerIndices: [0],
priorityPoints: 1,
);
final updated = PriorityManager.applyAnswerResult(
question: question,
isCorrect: true,
config: config,
);
expect(updated.priorityPoints, equals(0));
});
test('resetPriority sets priority to 0', () {
final question = Question(
id: 'q1',
prompt: 'Test',
answers: ['A', 'B'],
correctAnswerIndices: [0],
priorityPoints: 100,
);
final updated = PriorityManager.resetPriority(question);
expect(updated.priorityPoints, equals(0));
});
test('withPriorityPoints enforces non-negative priority', () {
final question = Question(
id: 'q1',
prompt: 'Test',
answers: ['A', 'B'],
correctAnswerIndices: [0],
priorityPoints: 10,
);
final updated = question.withPriorityPoints(-5);
expect(updated.priorityPoints, equals(0));
});
});
}
@@ -0,0 +1,204 @@
import 'package:test/test.dart';
import 'package:practice_engine/models/question.dart';
import 'package:practice_engine/models/deck_config.dart';
import 'package:practice_engine/logic/deck_service.dart';
void main() {
group('Question State Transitions', () {
late DeckConfig config;
setUp(() {
config = const DeckConfig(requiredConsecutiveCorrect: 3);
});
test('correct answer increments streak', () {
final question = Question(
id: 'q1',
prompt: 'Test',
answers: ['A', 'B'],
correctAnswerIndices: [0],
consecutiveCorrect: 1,
);
final updated = DeckService.updateQuestionAfterAnswer(
question: question,
isCorrect: true,
config: config,
currentAttemptIndex: 0,
);
expect(updated.consecutiveCorrect, equals(2));
});
test('incorrect answer resets streak', () {
final question = Question(
id: 'q1',
prompt: 'Test',
answers: ['A', 'B'],
correctAnswerIndices: [0],
consecutiveCorrect: 2,
);
final updated = DeckService.updateQuestionAfterAnswer(
question: question,
isCorrect: false,
config: config,
currentAttemptIndex: 0,
);
expect(updated.consecutiveCorrect, equals(0));
});
test('question becomes known when streak reaches threshold', () {
final question = Question(
id: 'q1',
prompt: 'Test',
answers: ['A', 'B'],
correctAnswerIndices: [0],
consecutiveCorrect: 2,
isKnown: false,
);
final updated = DeckService.updateQuestionAfterAnswer(
question: question,
isCorrect: true,
config: config,
currentAttemptIndex: 0,
);
expect(updated.consecutiveCorrect, equals(3));
expect(updated.isKnown, equals(true));
});
test('incorrect answer sets isKnown to false', () {
final question = Question(
id: 'q1',
prompt: 'Test',
answers: ['A', 'B'],
correctAnswerIndices: [0],
consecutiveCorrect: 3,
isKnown: true,
);
final updated = DeckService.updateQuestionAfterAnswer(
question: question,
isCorrect: false,
config: config,
currentAttemptIndex: 0,
);
expect(updated.isKnown, equals(false));
expect(updated.consecutiveCorrect, equals(0));
});
test('priority increases on incorrect answer', () {
final question = Question(
id: 'q1',
prompt: 'Test',
answers: ['A', 'B'],
correctAnswerIndices: [0],
priorityPoints: 5,
);
final updated = DeckService.updateQuestionAfterAnswer(
question: question,
isCorrect: false,
config: config,
currentAttemptIndex: 0,
);
expect(updated.priorityPoints,
equals(5 + config.priorityIncreaseOnIncorrect));
});
test('priority decreases on correct answer', () {
final question = Question(
id: 'q1',
prompt: 'Test',
answers: ['A', 'B'],
correctAnswerIndices: [0],
priorityPoints: 10,
);
final updated = DeckService.updateQuestionAfterAnswer(
question: question,
isCorrect: true,
config: config,
currentAttemptIndex: 0,
);
expect(updated.priorityPoints,
equals(10 - config.priorityDecreaseOnCorrect));
});
test('priority cannot go below 0', () {
final question = Question(
id: 'q1',
prompt: 'Test',
answers: ['A', 'B'],
correctAnswerIndices: [0],
priorityPoints: 1,
);
final updated = DeckService.updateQuestionAfterAnswer(
question: question,
isCorrect: true,
config: config,
currentAttemptIndex: 0,
);
expect(updated.priorityPoints, equals(0));
});
test('lastAttemptIndex is updated', () {
final question = Question(
id: 'q1',
prompt: 'Test',
answers: ['A', 'B'],
correctAnswerIndices: [0],
lastAttemptIndex: 5,
);
final updated = DeckService.updateQuestionAfterAnswer(
question: question,
isCorrect: true,
config: config,
currentAttemptIndex: 10,
);
expect(updated.lastAttemptIndex, equals(10));
});
test('totalAttempts increments on both correct and incorrect', () {
final question = Question(
id: 'q1',
prompt: 'Test',
answers: ['A', 'B'],
correctAnswerIndices: [0],
totalAttempts: 5,
totalCorrectAttempts: 3,
);
final updatedCorrect = DeckService.updateQuestionAfterAnswer(
question: question,
isCorrect: true,
config: config,
currentAttemptIndex: 0,
);
expect(updatedCorrect.totalAttempts, equals(6));
expect(updatedCorrect.totalCorrectAttempts, equals(4));
final updatedIncorrect = DeckService.updateQuestionAfterAnswer(
question: question,
isCorrect: false,
config: config,
currentAttemptIndex: 0,
);
expect(updatedIncorrect.totalAttempts, equals(6));
expect(updatedIncorrect.totalCorrectAttempts, equals(3));
});
});
}
@@ -0,0 +1,99 @@
import 'package:test/test.dart';
import 'package:practice_engine/models/deck.dart';
import 'package:practice_engine/models/deck_config.dart';
import 'package:practice_engine/models/question.dart';
import 'package:practice_engine/logic/deck_service.dart';
void main() {
group('Reset Logic', () {
late DeckConfig config;
late Deck deck;
setUp(() {
config = const DeckConfig(requiredConsecutiveCorrect: 3);
deck = Deck(
id: 'deck1',
title: 'Test Deck',
description: 'Test',
questions: [
Question(
id: 'q1',
prompt: 'Question 1',
answers: ['A', 'B'],
correctAnswerIndices: [0],
consecutiveCorrect: 5,
isKnown: true,
priorityPoints: 10,
lastAttemptIndex: 20,
totalCorrectAttempts: 15,
totalAttempts: 20,
),
Question(
id: 'q2',
prompt: 'Question 2',
answers: ['A', 'B'],
correctAnswerIndices: [0],
consecutiveCorrect: 2,
isKnown: false,
priorityPoints: 5,
lastAttemptIndex: 15,
totalCorrectAttempts: 8,
totalAttempts: 12,
),
],
config: config,
currentAttemptIndex: 25,
);
});
test('resetDeck resets all question states', () {
final reset = DeckService.resetDeck(deck: deck);
for (final question in reset.questions) {
expect(question.consecutiveCorrect, equals(0));
expect(question.isKnown, equals(false));
expect(question.priorityPoints, equals(0));
expect(question.lastAttemptIndex, equals(-1));
}
expect(reset.currentAttemptIndex, equals(0));
});
test('resetDeck preserves attempt counts by default', () {
final reset = DeckService.resetDeck(deck: deck);
expect(reset.questions[0].totalAttempts, equals(20));
expect(reset.questions[0].totalCorrectAttempts, equals(15));
expect(reset.questions[1].totalAttempts, equals(12));
expect(reset.questions[1].totalCorrectAttempts, equals(8));
});
test('resetDeck resets attempt counts when requested', () {
final reset = DeckService.resetDeck(
deck: deck,
resetAttemptCounts: true,
);
for (final question in reset.questions) {
expect(question.totalAttempts, equals(0));
expect(question.totalCorrectAttempts, equals(0));
}
});
test('resetDeck resets currentAttemptIndex to 0', () {
final reset = DeckService.resetDeck(deck: deck);
expect(reset.currentAttemptIndex, equals(0));
});
test('resetDeck preserves deck metadata', () {
final reset = DeckService.resetDeck(deck: deck);
expect(reset.id, equals(deck.id));
expect(reset.title, equals(deck.title));
expect(reset.description, equals(deck.description));
expect(reset.config, equals(deck.config));
});
});
}
@@ -0,0 +1,101 @@
import 'package:test/test.dart';
import 'package:practice_engine/models/question.dart';
import 'package:practice_engine/algorithms/spaced_repetition.dart';
void main() {
group('SpacedRepetition', () {
test('baseKnownProbability for never-seen known question', () {
final weight = SpacedRepetition.calculateKnownQuestionWeight(
lastAttemptIndex: -1,
currentAttemptIndex: 0,
);
expect(weight, equals(SpacedRepetition.baseKnownProbability));
});
test('probability increases with attempts since last seen', () {
final weight1 = SpacedRepetition.calculateKnownQuestionWeight(
lastAttemptIndex: 0,
currentAttemptIndex: 10,
);
final weight2 = SpacedRepetition.calculateKnownQuestionWeight(
lastAttemptIndex: 0,
currentAttemptIndex: 30,
);
expect(weight2, greaterThan(weight1));
});
test('probability is capped at maxKnownProbability', () {
final weight = SpacedRepetition.calculateKnownQuestionWeight(
lastAttemptIndex: 0,
currentAttemptIndex: 1000,
);
expect(weight, lessThanOrEqualTo(SpacedRepetition.maxKnownProbability));
});
test('probability increases gradually', () {
final weights = <double>[];
for (int i = 0; i <= 30; i++) {
final weight = SpacedRepetition.calculateKnownQuestionWeight(
lastAttemptIndex: 0,
currentAttemptIndex: i,
);
weights.add(weight);
}
// Should be monotonically increasing
for (int i = 1; i < weights.length; i++) {
expect(weights[i], greaterThanOrEqualTo(weights[i - 1]));
}
});
test('getQuestionWeight uses priority for unknown questions', () {
final question = Question(
id: 'q1',
prompt: 'Test',
answers: ['A', 'B'],
correctAnswerIndices: [0],
isKnown: false,
priorityPoints: 10,
);
final weight = SpacedRepetition.getQuestionWeight(
question: question,
currentAttemptIndex: 0,
basePriorityWeight: 1.0,
);
// Should be priority + 1
expect(weight, equals(11.0));
});
test('getQuestionWeight uses spaced repetition for known questions', () {
final question = Question(
id: 'q1',
prompt: 'Test',
answers: ['A', 'B'],
correctAnswerIndices: [0],
isKnown: true,
lastAttemptIndex: 5,
priorityPoints: 0,
);
final weight = SpacedRepetition.getQuestionWeight(
question: question,
currentAttemptIndex: 10,
basePriorityWeight: 1.0,
);
// Should use spaced repetition probability
final expected = SpacedRepetition.calculateKnownQuestionWeight(
lastAttemptIndex: 5,
currentAttemptIndex: 10,
);
expect(weight, closeTo(expected, 0.001));
});
});
}
@@ -0,0 +1,138 @@
import 'package:test/test.dart';
import 'package:practice_engine/models/question.dart';
import 'package:practice_engine/algorithms/weighted_selector.dart';
void main() {
group('WeightedSelector', () {
test('selects correct number of questions', () {
final selector = WeightedSelector(seed: 42);
final questions = List.generate(10, (i) {
return Question(
id: 'q$i',
prompt: 'Question $i',
answers: ['A', 'B'],
correctAnswerIndices: [0],
priorityPoints: i,
);
});
final selected = selector.selectQuestions(
candidates: questions,
count: 5,
currentAttemptIndex: 0,
);
expect(selected.length, equals(5));
});
test('returns all questions if count >= candidates length', () {
final selector = WeightedSelector(seed: 42);
final questions = List.generate(5, (i) {
return Question(
id: 'q$i',
prompt: 'Question $i',
answers: ['A', 'B'],
correctAnswerIndices: [0],
);
});
final selected = selector.selectQuestions(
candidates: questions,
count: 10,
currentAttemptIndex: 0,
);
expect(selected.length, equals(5));
});
test('returns empty list for empty candidates', () {
final selector = WeightedSelector(seed: 42);
final selected = selector.selectQuestions(
candidates: [],
count: 5,
currentAttemptIndex: 0,
);
expect(selected, isEmpty);
});
test('returns empty list for count <= 0', () {
final selector = WeightedSelector(seed: 42);
final questions = List.generate(5, (i) {
return Question(
id: 'q$i',
prompt: 'Question $i',
answers: ['A', 'B'],
correctAnswerIndices: [0],
);
});
final selected = selector.selectQuestions(
candidates: questions,
count: 0,
currentAttemptIndex: 0,
);
expect(selected, isEmpty);
});
test('no duplicates in selection', () {
final selector = WeightedSelector(seed: 42);
final questions = List.generate(10, (i) {
return Question(
id: 'q$i',
prompt: 'Question $i',
answers: ['A', 'B'],
correctAnswerIndices: [0],
priorityPoints: 5,
);
});
final selected = selector.selectQuestions(
candidates: questions,
count: 5,
currentAttemptIndex: 0,
);
final ids = selected.map((q) => q.id).toSet();
expect(ids.length, equals(selected.length));
});
test('higher priority questions are more likely to be selected', () {
final selector = WeightedSelector(seed: 42);
final questions = [
Question(
id: 'low',
prompt: 'Low priority',
answers: ['A', 'B'],
correctAnswerIndices: [0],
priorityPoints: 1,
),
Question(
id: 'high',
prompt: 'High priority',
answers: ['A', 'B'],
correctAnswerIndices: [0],
priorityPoints: 100,
),
];
// Run multiple selections and count occurrences
int highSelected = 0;
for (int i = 0; i < 100; i++) {
final selected = selector.selectQuestions(
candidates: questions,
count: 1,
currentAttemptIndex: 0,
);
if (selected.first.id == 'high') {
highSelected++;
}
}
// High priority should be selected more often
expect(highSelected, greaterThan(50));
});
});
}