package uk.co.bytemark.vm.enigma.inquisition.quiz; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.beans.PropertyChangeSupport; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import uk.co.bytemark.vm.enigma.inquisition.gui.misc.QuizConfig; import uk.co.bytemark.vm.enigma.inquisition.misc.Utils; import uk.co.bytemark.vm.enigma.inquisition.questions.Answer; import uk.co.bytemark.vm.enigma.inquisition.questions.DragAndDropQuestion; import uk.co.bytemark.vm.enigma.inquisition.questions.MultipleChoiceQuestion; import uk.co.bytemark.vm.enigma.inquisition.questions.Question; import uk.co.bytemark.vm.enigma.inquisition.questions.QuestionSet; public class QuizState implements Serializable { private static final Logger LOGGER = Logger.getLogger( QuizState.class.getName() ); // Reference: private final QuizConfig quizConfig; private final List<Question> questionSequence; // Misc: private transient PropertyChangeSupport propertyChangeSupport; // State: private int currentQuestionIndex; // TODO: We should use indices here because there could be multiples of the same question private final Map<Question, Answer> answers; // TODO: We should use indices here because there could be multiples of the same question private final Set<Question> markedForReview; private boolean inExplanationMode; private boolean keepInExplanationModeWhileNavigating; private boolean timerRunning; private long startTime; private int secondsRemaining; private QuestionSet questionSet; private boolean dirty; public QuizState( QuestionSet questionSet, QuizConfig quizConfig ) { Utils.checkArgumentNotNull( questionSet, "questionSet" ); Utils.checkArgumentNotNull( quizConfig, "quizConfig" ); if ( questionSet.size() == 0 ) throw new IllegalArgumentException( "Must have at least one question in an exam" ); this.questionSet = questionSet; this.quizConfig = quizConfig; this.answers = new HashMap<Question, Answer>(); this.questionSequence = getQuestionSequence( questionSet, quizConfig ); for ( Question question : questionSequence ) answers.put( question, question.initialAnswer() ); this.currentQuestionIndex = 0; this.markedForReview = new HashSet<Question>(); this.propertyChangeSupport = new PropertyChangeSupport( this ); this.inExplanationMode = false; this.keepInExplanationModeWhileNavigating = false; this.timerRunning = false; this.startTime = -1; this.secondsRemaining = quizConfig.isQuizTimed() ? quizConfig.getTimeAllowed() : -1; this.dirty = false; } private List<Question> getQuestionSequence( QuestionSet questionSet_, QuizConfig quizConfig_ ) { List<Question> questions = new ArrayList<Question>( questionSet_.getQuestions() ); if ( quizConfig_.shouldShuffleQuestionOrder() ) Collections.shuffle( questions ); return questions; } private void writeObject( ObjectOutputStream os ) throws Exception { try { os.defaultWriteObject(); } catch ( Exception e ) { LOGGER.log( Level.SEVERE, "Exception serializing quiz state", e ); throw e; } } private void readObject( ObjectInputStream is ) throws Exception { try { is.defaultReadObject(); this.propertyChangeSupport = new PropertyChangeSupport( this ); } catch ( Exception e ) { LOGGER.log( Level.SEVERE, "Exception deserializing quiz state", e ); throw e; } } // -- Listeners ------------------------------------------------------------------------------------------------ public void addPropertyChangeListener( PropertyChangeListener listener ) { this.propertyChangeSupport.addPropertyChangeListener( listener ); } public void removePropertyChangeListener( PropertyChangeListener listener ) { this.propertyChangeSupport.removePropertyChangeListener( listener ); } private void firePropertyChange() { this.propertyChangeSupport.firePropertyChange( new PropertyChangeEvent( this, null, null, null ) ); } // -- Model updates -------------------------------------------------------------------------------------------- public void goToFirstQuestion() { goToQuestionIndex( 0 ); } public void goToNextQuestion() { if ( isLastQuestion() ) throw new IllegalStateException( "Already at last question" ); goToQuestionIndex( currentQuestionIndex + 1 ); } public void goToPreviousQuestion() { if ( isFirstQuestion() ) throw new IllegalStateException( "Already at last question" ); goToQuestionIndex( currentQuestionIndex - 1 ); } public void goToLastQuestion() { goToQuestionIndex( questionSequence.size() - 1 ); } public void goToQuestion( int questionNumber ) { checkBounds( questionNumber ); currentQuestionIndex = questionNumber - 1; goToQuestionIndex( questionNumber - 1 ); } public void goToNextMarked() { if ( !isAnyQuestionMarked() ) throw new IllegalStateException( "No question is marked" ); for ( int i = 1; i <= questionSequence.size(); i++ ) { int index = ( currentQuestionIndex + i ) % questionSequence.size(); Question question = questionSequence.get( index ); if ( markedForReview.contains( question ) ) { goToQuestionIndex( index ); return; } } throw new AssertionError( "Should never reach here" ); } public void goToNextUnanswered() { if ( !isAnyQuestionUnanswered() ) throw new IllegalStateException( "No question is unanswered" ); for ( int i = 1; i <= questionSequence.size(); i++ ) { int index = ( currentQuestionIndex + i ) % questionSequence.size(); Question question = questionSequence.get( index ); if ( !isAnswered( question ) ) { goToQuestionIndex( index ); return; } } throw new AssertionError( "Should never reach here" ); } public void goToNextIncorrect() { if ( !isAnyQuestionIncorrect() ) throw new IllegalStateException( "No question is incorrect" ); for ( int i = 1; i <= questionSequence.size(); i++ ) { int index = ( currentQuestionIndex + i ) % questionSequence.size(); Question question = questionSequence.get( index ); if ( !isAnswered( question ) ) { goToQuestionIndex( index ); return; } } throw new AssertionError( "Should never reach here" ); } public void markCurrentQuestionForReview( boolean markQuestionForReview ) { int questionNumber = getQuestionNumber(); markQuestionForReview( questionNumber, markQuestionForReview ); } public void markQuestionForReview( int questionNumber, boolean markQuestionForReview ) { Question question = questionSequence.get( questionNumber - 1 ); if ( markQuestionForReview ) { if ( markedForReview.contains( question ) ) throw new IllegalStateException( "Question already marked for review" ); else markedForReview.add( question ); } else { if ( !markedForReview.contains( question ) ) throw new IllegalStateException( "Question already not marked for review" ); else markedForReview.remove( question ); } dirty = true; firePropertyChange(); } public void pauseTimer() { throwExceptionIfQuizIsNotTimed(); if ( isTimerExpired() ) throw new IllegalStateException( "Timer has expired" ); if ( !isTimerRunning() ) throw new IllegalStateException( "Timer is already paused" ); secondsRemaining = getTimeRemaining(); startTime = -1; timerRunning = false; firePropertyChange(); } public void startTimer() { throwExceptionIfQuizIsNotTimed(); if ( isTimerExpired() ) throw new IllegalStateException( "Timer has expired" ); if ( isTimerRunning() ) throw new IllegalStateException( "Timer is already running" ); timerRunning = true; startTime = System.currentTimeMillis(); firePropertyChange(); } public void setAnswer( Answer answer ) { // TODO: Check type? Question currentQuestion = getQuestion(); answers.put( currentQuestion, answer ); dirty = true; firePropertyChange(); } public void setKeepInExplanationModeWhileNavigating( boolean keepInExplanationModeWhileNavigating ) { if ( this.keepInExplanationModeWhileNavigating == keepInExplanationModeWhileNavigating ) throw new IllegalStateException(); this.keepInExplanationModeWhileNavigating = keepInExplanationModeWhileNavigating; firePropertyChange(); } public void toggleExplanationMode() { this.inExplanationMode = !inExplanationMode; firePropertyChange(); } public void setNotDirty() { this.dirty = false; } // -- Model queries -------------------------------------------------------------------------------------------- public boolean isLastQuestion() { return currentQuestionIndex == questionSequence.size() - 1; } public boolean isFirstQuestion() { return currentQuestionIndex == 0; } public int getQuestionNumber() { return currentQuestionIndex + 1; } public int getNumberOfQuestions() { return questionSequence.size(); } public int getNumberOfAnsweredQuestions() { int count = 0; for ( Question question : questionSequence ) if ( isAnswered( question ) ) count++; return count; } public int getNumberOfCorrectQuestions() { int count = 0; for ( int questionNumber = 1; questionNumber <= getNumberOfQuestions(); questionNumber++ ) if ( isCorrect( questionNumber ) ) count++; return count; } public boolean isAnyQuestionIncorrect() { for ( Question question : questionSequence ) { Answer answer = answers.get( question ); if ( !question.isCorrect( answer ) ) return true; } return false; } public boolean isAnyQuestionUnanswered() { return getNumberOfAnsweredQuestions() != getNumberOfQuestions(); } public boolean isAnyQuestionMarked() { return markedForReview.size() > 0; } public boolean shouldStateNumberOfOptionsNeededForMultipleChoice() { return quizConfig.shouldStateNumberOfOptionsNeededForMultipleChoice(); } public Question getQuestion() { return questionSequence.get( currentQuestionIndex ); } public Answer getAnswer() { return answers.get( getQuestion() ); } public String getQuestionText() { return getQuestion().getSubstitutedQuestionText(); } public String getExplanationText() { return getQuestion().getSubstitutedExplanationText(); } public boolean isAnswered( int questionNumber ) { checkBounds( questionNumber ); return isAnswered( questionSequence.get( questionNumber - 1 ) ); } public String getQuestionType( int questionNumber ) { return questionSequence.get( questionNumber - 1 ).getQuestionTypeName(); } public boolean isMarked( int questionNumber ) { checkBounds( questionNumber ); return markedForReview.contains( getQuestion( questionNumber ) ); } public boolean isMarked() { return isMarked( getQuestionNumber() ); } public boolean isCorrect() { return isCorrect( getQuestionNumber() ); } public boolean isCorrect( int questionNumber ) { checkBounds( questionNumber ); Question question = getQuestion( questionNumber ); Answer answer = answers.get( question ); return question.isCorrect( answer ); } public String questionType( int questionNumber ) { return getQuestion( questionNumber ).getQuestionTypeName(); } public boolean isQuizTimed() { return quizConfig.isQuizTimed(); } public boolean isTimerRunning() { throwExceptionIfQuizIsNotTimed(); return timerRunning; } // In seconds public int getTimeRemaining() { throwExceptionIfQuizIsNotTimed(); if ( timerRunning ) { long now = System.currentTimeMillis(); int elapsedSeconds = (int) ( ( now - startTime ) / 1000 ); return secondsRemaining - elapsedSeconds; } else { return secondsRemaining; } } public boolean isTimerExpired() { throwExceptionIfQuizIsNotTimed(); return getTimeRemaining() < 0; } public boolean isInExplanationMode() { return inExplanationMode; } public boolean keepInExplanationModeWhileNavigating() { return keepInExplanationModeWhileNavigating; } public String getQuestionSetName() { return questionSet.getName(); } public boolean isAnyQuestionAnswered() { return getNumberOfAnsweredQuestions() > 0; } public boolean isDirty() { return this.dirty; } // -- Helper --------------------------------------------------------------- private Question getQuestion( int questionNumber ) { return questionSequence.get( questionNumber - 1 ); } private boolean isAnswered( Question question ) { Answer answer = answers.get( question ); boolean answered; if ( question instanceof MultipleChoiceQuestion ) { MultipleChoiceQuestion multipleChoiceQuestion = (MultipleChoiceQuestion) question; boolean numberOfOptionsNeededIsShown = shouldStateNumberOfOptionsNeededForMultipleChoice(); answered = multipleChoiceQuestion.isAnswered( answer, numberOfOptionsNeededIsShown ); } else if ( question instanceof DragAndDropQuestion ) { DragAndDropQuestion dragAndDropQuestion = (DragAndDropQuestion) question; answered = dragAndDropQuestion.isAnswered( answer ); } else { throw new AssertionError( "Unknown Question type: " + question ); } return answered; } private void checkBounds( int questionNumber ) { if ( ! ( questionNumber >= 1 && questionNumber <= questionSequence.size() ) ) throw new IllegalArgumentException( "No such question: " + questionNumber ); } private void throwExceptionIfQuizIsNotTimed() { if ( !isQuizTimed() ) throw new IllegalArgumentException( "Quiz is not timed" ); } private void updateExplanationMode() { if ( inExplanationMode && !keepInExplanationModeWhileNavigating ) inExplanationMode = false; } private void goToQuestionIndex( int questionIndex ) { currentQuestionIndex = questionIndex; updateExplanationMode(); firePropertyChange(); } }