shufle order
This commit is contained in:
@@ -68,9 +68,15 @@ class AttemptService {
|
||||
final answerResults = <AnswerResult>[];
|
||||
final updatedQuestions = <Question>[];
|
||||
|
||||
// Process each question in the attempt
|
||||
for (final question in attempt.questions) {
|
||||
final userAnswer = answers[question.id];
|
||||
// Process each question in the attempt.
|
||||
// Use the deck's current question state (not the attempt snapshot) so that
|
||||
// manual "mark as known" during the attempt is preserved when applying results.
|
||||
for (final questionInAttempt in attempt.questions) {
|
||||
final currentQuestion = deck.questions
|
||||
.where((q) => q.id == questionInAttempt.id)
|
||||
.firstOrNull ?? questionInAttempt;
|
||||
|
||||
final userAnswer = answers[currentQuestion.id];
|
||||
if (userAnswer == null) {
|
||||
// Question not answered, treat as incorrect
|
||||
continue;
|
||||
@@ -89,28 +95,28 @@ class AttemptService {
|
||||
|
||||
// Check if answer is correct
|
||||
// For multiple correct answers: user must select all correct answers and no incorrect ones
|
||||
final correctIndices = question.correctIndices;
|
||||
final correctIndices = currentQuestion.correctIndices;
|
||||
final userSet = userAnswerIndices.toSet();
|
||||
final correctSet = correctIndices.toSet();
|
||||
final isCorrect = userSet.length == correctSet.length &&
|
||||
final isCorrect = userSet.length == correctSet.length &&
|
||||
userSet.every((idx) => correctSet.contains(idx));
|
||||
final userMarkedNeedsPractice = overrides[question.id] ?? false;
|
||||
final userMarkedNeedsPractice = overrides[currentQuestion.id] ?? false;
|
||||
|
||||
// Determine status change
|
||||
final oldIsKnown = question.isKnown;
|
||||
final oldStreak = question.consecutiveCorrect;
|
||||
// Determine status change (from current deck state)
|
||||
final oldIsKnown = currentQuestion.isKnown;
|
||||
final oldStreak = currentQuestion.consecutiveCorrect;
|
||||
|
||||
// Update question
|
||||
// Update question from current deck state so manual marks are preserved
|
||||
final updated = userMarkedNeedsPractice
|
||||
? DeckService.updateQuestionWithManualOverride(
|
||||
question: question,
|
||||
question: currentQuestion,
|
||||
isCorrect: isCorrect,
|
||||
userMarkedNeedsPractice: true,
|
||||
config: deck.config,
|
||||
currentAttemptIndex: deck.currentAttemptIndex,
|
||||
)
|
||||
: DeckService.updateQuestionAfterAnswer(
|
||||
question: question,
|
||||
question: currentQuestion,
|
||||
isCorrect: isCorrect,
|
||||
config: deck.config,
|
||||
currentAttemptIndex: deck.currentAttemptIndex,
|
||||
|
||||
@@ -76,6 +76,20 @@ class Deck {
|
||||
return (knownCount / questions.length) * 100.0;
|
||||
}
|
||||
|
||||
/// Progress percentage including partial credit: each question contributes
|
||||
/// 1.0 if known, otherwise (consecutiveCorrect / requiredConsecutiveCorrect)
|
||||
/// capped at 1.0. Averaged over all questions * 100.
|
||||
double get progressPercentage {
|
||||
if (questions.isEmpty) return 0.0;
|
||||
final required = config.requiredConsecutiveCorrect;
|
||||
final sum = questions.fold<double>(0.0, (sum, q) {
|
||||
if (q.isKnown) return sum + 1.0;
|
||||
final partial = (q.consecutiveCorrect / required).clamp(0.0, 1.0);
|
||||
return sum + partial;
|
||||
});
|
||||
return (sum / questions.length) * 100.0;
|
||||
}
|
||||
|
||||
/// Number of completed attempts.
|
||||
int get attemptCount => attemptHistory.length;
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ class DeckConfig {
|
||||
/// If false, known questions will be excluded from attempts.
|
||||
final bool includeKnownInAttempts;
|
||||
|
||||
/// Whether to shuffle answer order for each question in an attempt.
|
||||
/// When true, answer options appear in random order each attempt.
|
||||
final bool shuffleAnswerOrder;
|
||||
|
||||
/// Optional time limit for attempts in seconds.
|
||||
/// If null, no time limit is enforced.
|
||||
final int? timeLimitSeconds;
|
||||
@@ -30,6 +34,7 @@ class DeckConfig {
|
||||
this.priorityDecreaseOnCorrect = 2,
|
||||
this.immediateFeedbackEnabled = true,
|
||||
this.includeKnownInAttempts = false,
|
||||
this.shuffleAnswerOrder = true,
|
||||
this.timeLimitSeconds,
|
||||
});
|
||||
|
||||
@@ -41,6 +46,7 @@ class DeckConfig {
|
||||
int? priorityDecreaseOnCorrect,
|
||||
bool? immediateFeedbackEnabled,
|
||||
bool? includeKnownInAttempts,
|
||||
bool? shuffleAnswerOrder,
|
||||
int? timeLimitSeconds,
|
||||
}) {
|
||||
return DeckConfig(
|
||||
@@ -55,6 +61,7 @@ class DeckConfig {
|
||||
immediateFeedbackEnabled ?? this.immediateFeedbackEnabled,
|
||||
includeKnownInAttempts:
|
||||
includeKnownInAttempts ?? this.includeKnownInAttempts,
|
||||
shuffleAnswerOrder: shuffleAnswerOrder ?? this.shuffleAnswerOrder,
|
||||
timeLimitSeconds: timeLimitSeconds ?? this.timeLimitSeconds,
|
||||
);
|
||||
}
|
||||
@@ -70,6 +77,7 @@ class DeckConfig {
|
||||
priorityDecreaseOnCorrect == other.priorityDecreaseOnCorrect &&
|
||||
immediateFeedbackEnabled == other.immediateFeedbackEnabled &&
|
||||
includeKnownInAttempts == other.includeKnownInAttempts &&
|
||||
shuffleAnswerOrder == other.shuffleAnswerOrder &&
|
||||
timeLimitSeconds == other.timeLimitSeconds;
|
||||
|
||||
@override
|
||||
@@ -80,6 +88,7 @@ class DeckConfig {
|
||||
priorityDecreaseOnCorrect.hashCode ^
|
||||
immediateFeedbackEnabled.hashCode ^
|
||||
includeKnownInAttempts.hashCode ^
|
||||
shuffleAnswerOrder.hashCode ^
|
||||
(timeLimitSeconds?.hashCode ?? 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
import 'package:practice_engine/logic/deck_service.dart';
|
||||
|
||||
void main() {
|
||||
group('Attempt Flow', () {
|
||||
@@ -152,6 +153,51 @@ void main() {
|
||||
expect(updated.consecutiveCorrect, equals(3));
|
||||
expect(updated.isKnown, equals(true));
|
||||
});
|
||||
|
||||
test('processAttempt preserves manual mark as known when using current deck state', () {
|
||||
// One question, not known, no streak
|
||||
final question = Question(
|
||||
id: 'q1',
|
||||
prompt: 'Test',
|
||||
answers: ['A', 'B'],
|
||||
correctAnswerIndices: [0],
|
||||
consecutiveCorrect: 0,
|
||||
isKnown: false,
|
||||
);
|
||||
|
||||
final testDeck = Deck(
|
||||
id: 'deck1',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
questions: [question],
|
||||
config: const DeckConfig(requiredConsecutiveCorrect: 3),
|
||||
);
|
||||
|
||||
final attempt = attemptService.createAttempt(
|
||||
deck: testDeck,
|
||||
attemptSize: 1,
|
||||
);
|
||||
expect(attempt.questions.single.isKnown, isFalse);
|
||||
|
||||
// User marks as known during the attempt (as in the app)
|
||||
final deckWithManualKnown = DeckService.markQuestionAsKnown(
|
||||
deck: testDeck,
|
||||
questionId: question.id,
|
||||
);
|
||||
expect(deckWithManualKnown.questions.single.isKnown, isTrue);
|
||||
|
||||
// Complete attempt with correct answer, passing deck that has manual known
|
||||
final answers = {question.id: question.correctIndices.first};
|
||||
final result = attemptService.processAttempt(
|
||||
deck: deckWithManualKnown,
|
||||
attempt: attempt,
|
||||
answers: answers,
|
||||
);
|
||||
|
||||
// Manual "known" must be preserved in the result
|
||||
final updated = result.updatedDeck.questions.first;
|
||||
expect(updated.isKnown, isTrue, reason: 'Manual mark as known must count towards known after attempt');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,81 @@ void main() {
|
||||
expect(deck.practicePercentage, equals(100.0));
|
||||
});
|
||||
|
||||
test('progressPercentage is 0 for empty deck', () {
|
||||
final deck = Deck(
|
||||
id: 'deck1',
|
||||
title: 'Empty Deck',
|
||||
description: 'No questions',
|
||||
questions: [],
|
||||
config: defaultConfig,
|
||||
);
|
||||
|
||||
expect(deck.progressPercentage, equals(0.0));
|
||||
});
|
||||
|
||||
test('progressPercentage equals practicePercentage when no partial streak', () {
|
||||
final deck = Deck(
|
||||
id: 'deck1',
|
||||
title: 'Math Deck',
|
||||
description: 'Basic math',
|
||||
questions: sampleQuestions,
|
||||
config: defaultConfig,
|
||||
);
|
||||
|
||||
expect(deck.progressPercentage, closeTo(deck.practicePercentage, 0.01));
|
||||
});
|
||||
|
||||
test('progressPercentage 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.progressPercentage, equals(100.0));
|
||||
});
|
||||
|
||||
test('progressPercentage includes partial credit from consecutiveCorrect', () {
|
||||
// requiredConsecutiveCorrect is 3 (default). 3 questions: none known, each at 2/3
|
||||
final withPartial = [
|
||||
Question(id: 'q1', prompt: 'A', answers: ['x'], correctAnswerIndices: [0], consecutiveCorrect: 2, isKnown: false),
|
||||
Question(id: 'q2', prompt: 'B', answers: ['x'], correctAnswerIndices: [0], consecutiveCorrect: 2, isKnown: false),
|
||||
Question(id: 'q3', prompt: 'C', answers: ['x'], correctAnswerIndices: [0], consecutiveCorrect: 2, isKnown: false),
|
||||
];
|
||||
final deck = Deck(
|
||||
id: 'deck1',
|
||||
title: 'Partial',
|
||||
description: 'Partial progress',
|
||||
questions: withPartial,
|
||||
config: defaultConfig,
|
||||
);
|
||||
|
||||
expect(deck.practicePercentage, equals(0.0));
|
||||
expect(deck.progressPercentage, closeTo(66.67, 0.01)); // 3 * (2/3) / 3 * 100
|
||||
});
|
||||
|
||||
test('progressPercentage mixes known and partial correctly', () {
|
||||
final config = DeckConfig(requiredConsecutiveCorrect: 3);
|
||||
final questions = [
|
||||
Question(id: 'q1', prompt: 'A', answers: ['x'], correctAnswerIndices: [0], isKnown: true),
|
||||
Question(id: 'q2', prompt: 'B', answers: ['x'], correctAnswerIndices: [0], consecutiveCorrect: 2, isKnown: false),
|
||||
Question(id: 'q3', prompt: 'C', answers: ['x'], correctAnswerIndices: [0], consecutiveCorrect: 0, isKnown: false),
|
||||
];
|
||||
final deck = Deck(
|
||||
id: 'deck1',
|
||||
title: 'Mixed',
|
||||
description: 'Mixed',
|
||||
questions: questions,
|
||||
config: config,
|
||||
);
|
||||
|
||||
// 1.0 + 2/3 + 0 = 1.667; 1.667/3*100 ≈ 55.56
|
||||
expect(deck.progressPercentage, closeTo(55.56, 0.01));
|
||||
});
|
||||
|
||||
test('copyWith creates new deck with updated fields', () {
|
||||
final deck = Deck(
|
||||
id: 'deck1',
|
||||
|
||||
Reference in New Issue
Block a user