CSC/ECE 517 Spring 2023 - E2311. Reimplement QuizQuestion and its child classes

From Expertiza_Wiki
Jump to navigation Jump to search

Background

Current State

Currently, in Expertiza there is a model class called QuizQuestion which serves as a superclass of classes MultipleChoiceCheckbox, MultipleChoiceRadio, and TrueFalse.

There are four methods in the QuizQuestion superclass which return HTML strings, depending on the question type:

  • edit -- What to display if an instructor (etc.) is creating or editing a questionnaire (questionnaires_controller.rb)
  • view_question_text -- What to display if an instructor (etc.) is viewing a questionnaire (questionnaires_controller.rb)
  • view_completed_question -- What to display if a student is viewing a filled-out questionnaire (response_controller.rb)
  • complete -- What to display if a student is filling out a questionnaire (response_controller.rb)

Additionally, the inheriting classes contain a method isvalid that is not in QuizQuestion.

Included below is the UML diagram of the design of the QuizQuestion and its child classes in the current state

Our Implementation Project

For this project, we are tasked with redesigning the above UML diagram and reimplementing the five methods (mentioned in the previous section) in the superclass (QuizQuestion). The reimplementation of the methods in the superclass will provide inheritances to the subclasses (MultipleChoiceCheckbox, MultipleChoiceRadio, and TrueFalse), and will use overriding/overloading to avoid type-checking.

Included below is the redesign of the UML diagram of the relationship between QuizQuestion and its child classes. As shown in the diagram, all methods are moved to the parent class. Child classes either inherit purely from the parent class' methods or extend it through overriding/overloading.

Note that the methods reimplemented in the parent class (QuizQuestion) still render HTML components to provide to the front-end (through the response_controller.rb and questionnaires_controller.rb).

Overview and Test Suite Video

A video summarizing our work and display our successful run of RSpec test cases can be viewed here

Future Direction

In the future, as the Expertiza app is being migrated to the combination of Ruby API and React front-end (instead of legacy Rails), these methods will be modified to use Ruby API, and to handle and return React objects for the front-end.

As part of our work on this project, we implemented a sample API controller that is able to return QuizQuestion child classes in JSON form. We believe this would be an initial step in converting the QuizQuestion models for use with a Ruby back-end / React front-end approach.

As this was not formally part of our project requirements, we have left our work in a draft pull request on our repository

The following links are to our demonstration API instance on the NCSU VCL:

Team

  • Aileen Jacob (amjacob2)
  • Anh Nguyen (anguyen9)
  • Joe Johnson (jdjohns4)
  • Mentor Jialin Cui (jcui9)

Detailed Description of Changes

isvalid

isvalid method is implemented in all subclasses but not the superclass. The majority of these implementations are repetitive. Hence, the method is reimplemented and moved to the superclass (quiz_question.rb). In the subclasses, this method is inherited ('MultipleChoiceRadio and TrueFalse) with the exception of MultipleChoiceCheckbox where the method is extended. The implementation of isvalid is shown below

# quiz_question.rb
class QuizQuestion < Question
  has_many :quiz_question_choices, class_name: 'QuizQuestionChoice', foreign_key: 'question_id', inverse_of: false, dependent: :nullify

  ... [other methods] ...
  def isvalid(choice_info)
    @valid = 'valid'
    return @valid = 'Please make sure all questions have text' if txt == ''
    @correct_count = 0
    choice_info.each_value do |value|
      if (value[:txt] == '') || value[:txt].empty? || value[:txt].nil?
        @valid = 'Please make sure every question has text for all options'
        return @valid
      end
      @correct_count += 1 if value[:iscorrect]
    end
    @valid = 'Please select a correct answer for all questions' if @correct_count.zero?
    @valid
  end
end 
# multiple_choice_checkbox.rb
class MultipleChoiceCheckbox < QuizQuestion
  ... [other methods] ...

  def isvalid(choice_info)
    super
    if @correct_count == 1
      @valid = 'A multiple-choice checkbox question should have more than one correct answer.'
    end
    @valid
  end
end 

To test isvalid several tests were written to check for the behavior and returns of the method. Since the method is extended in MultipleChoiceCheckbox, extra tests were written to make sure that the method correctly evaluate a question and its choices. Below are the tests used for isvalid method

# quiz_question_spec.rb
require 'swagger_helper'

describe QuizQuestion do
  let(:quiz_question) { QuizQuestion.new }
   ... [setup] ...
  end
 
  ... [other tests] ...

  describe '#isvalid' do
    context 'when the question and its choices have valid text' do
      it 'returns "valid"' do
        questions = { '1' => { txt: 'question text', iscorrect: true }, '2' => { txt: 'question text', iscorrect: false }, 
                      '3' => { txt: 'question text', iscorrect: false }, '4' => { txt: 'question text', iscorrect: false } }
        expect(quiz_question.isvalid(questions)).to eq('valid')
      end
    end
  end
  describe '#isvaid' do
    let(:no_text_question) {QuizQuestion.new}
    context 'when the question itself does not have txt' do
      it 'returns "Please make sure all questions have text"' do
        allow(no_text_question).to receive(:txt).and_return('')
        questions = { '1' => { txt: 'question text', iscorrect: true }, '2' => { txt: 'question text', iscorrect: false }, 
                      '3' => { txt: 'question text', iscorrect: false }, '4' => { txt: 'question text', iscorrect: false } }
        expect(no_text_question.isvalid(questions)).to eq('Please make sure all questions have text')
      end
    end
  end
  describe '#isvalid' do
    context 'when a choice does not have text' do
      it 'returns "Please make sure every question has text for all options"' do
        questions = { '1' => { txt: 'question text', iscorrect: true }, '2' => { txt: '', iscorrect: true }, 
                      '3' => { txt: 'question text', iscorrect: false }, '4' => { txt: 'question text', iscorrect: false } }
        expect(quiz_question.isvalid(questions)).to eq('Please make sure every question has text for all options')
      end
    end
  end
  describe '#isvalid' do
    context 'when no choices are correct' do
      it 'returns "Please select a correct answer for all questions"' do
        questions = { '1' => { txt: 'question text', iscorrect: false }, '2' => { txt: 'question text', iscorrect: false }, 
                      '3' => { txt: 'question text', iscorrect: false }, '4' => { txt: 'question text', iscorrect: false } }
        expect(quiz_question.isvalid(questions)).to eq('Please select a correct answer for all questions')
      end
    end
  end
end 

Extended tests for isvalid in MultipleChoiceCheckbox

# multiple_choice_checkbox+spec.rb
require 'swagger_helper'

describe MultipleChoiceCheckbox do
  let(:multiple_choice_question) { MultipleChoiceCheckbox.new }
    ... [setup] ...
  end

  ... [other tests] ...

  describe '#isvalid' do
    context 'when there is only 1 correct answer' do
      it 'returns "A multiple-choice checkbox question should have more than one correct answer."' do
        questions = { '1' => { txt: 'question text', iscorrect: true }, '2' => { txt: 'question text', iscorrect: false }, 
                      '3' => { txt: 'question text', iscorrect: false }, '4' => { txt: 'question text', iscorrect: false } }
        expect(multiple_choice_question.isvalid(questions)).to eq('A multiple-choice checkbox question should have more than one correct answer.')
      end
    end
  end

  describe '#isvalid' do
    context 'when there is more than 1 correct answer' do
      it 'returns "valid"' do
        questions = { '1' => { txt: 'question text', iscorrect: true }, '2' => { txt: 'question text', iscorrect: false }, 
                      '3' => { txt: 'question text', iscorrect: false }, '4' => { txt: 'question text', iscorrect: true } }
        expect(multiple_choice_question.isvalid(questions)).to eq('valid')
      end
    end
  end
end 

edit

The edit method was originally not implemented within QuizQuestion and each of the child classes had their own implementation. These implementations had the exact same beginning HTML block:

    html = '<tr><td>'
    html += '<textarea cols="100" name="question[' + id.to_s + '][txt]" '
    html += 'id="question_' + id.to_s + '_txt">' + txt + '</textarea>'
    html += '</td></tr>'

    html += '<tr><td>'
    html += 'Question Weight: '
    html += '<input type="number" name="question_weights[' + id.to_s + '][txt]" '
    html += 'id="question_wt_' + id.to_s + '_txt" '
    html += 'value="' + weight.to_s + '" min="0" />'
    html += '</td></tr>' 

Given this, we were able to move this block up to the edit method within the parent QuizQuesiton class, and extend the method in the child classes. Each of the child classes differed in how their type of question was displayed, so each still has a custom implementation of the edit method.

An example from TrueFalse:

  def edit
    html = super

    html += '<tr><td>'
    html += '<input type="radio" name="quiz_question_choices[' + id.to_s + '][TrueFalse][1][iscorrect]" '
    html += 'id="quiz_question_choices_' + id.to_s + '_TrueFalse_1_iscorrect_True" value="True" '
    html += 'checked="checked" ' if self.quiz_question_choices[0].iscorrect
    html += '/>True'
    html += '</td></tr>'

    html += '<tr><td>'
    html += '<input type="radio" name="quiz_question_choices[' + id.to_s + '][TrueFalse][1][iscorrect]" '
    html += 'id="quiz_question_choices_' + id.to_s + '_TrueFalse_1_iscorrect_True" value="False" '
    html += 'checked="checked" ' if self.quiz_question_choices[1].iscorrect
    html += '/>False'
    html += '</td></tr>'

    html.html_safe
  end 

We implemented tests for the edit method in both the parent QuizQuestion class to verify the preamble HTML is being returned as expected, and in each test class for the three subclasses to verify their specific implementations of edit are returning the HTML as expected.

complete

The complete method was initially not implemented within QuizQuestion and each of the child classes had its own implementation. To reimplement this method, I removed it from its subclass and moved it to the superclass in quiz_question.rb. It generates HTML code for completing a question. The quiz question's associated answer alternatives are displayed and can be selected using radio buttons. The implementation of complete is shown below-

# quiz_question.rb
class QuizQuestion < Question
  has_many :quiz_question_choices, class_name: 'QuizQuestionChoice', 
foreign_key: 'question_id', inverse_of: false, dependent: :nullify

  ... [other methods] ...
def complete
    quiz_question_choices = self.quiz_question_choices
    html = '<label for="' + id.to_s + '">' + txt + '</label><br>'
    [0, 1, 2, 3].each do |i|
      html += '<input name = ' + "\"#{id}\" "
      html += 'id = ' + "\"#{id}" + '_' + "#{i + 1}\" "
      html += 'value = ' + "\"#{quiz_question_choices[i].txt}\" "
      html += 'type="radio"/>'
      html += quiz_question_choices[i].txt.to_s
      html += '</br>'
    end
    html
  end
 

view_completed_question

QuizQuestionChoice

The QuizQuestionChoice class represents a single choice in a true/false or multiple choice question. A QuizQuestion or QuizQuestion subclass has a 'has_many' relationship with QuizQuestionChoice. In the original implementation, every method that referenced the QuizQuestion's QuizQuestionChoices would do a retrieve -- this single line of code was duplicated multiple times through the four classes.

In order to improve this, we moved the fetching of a QuizQuestion's QuizQuestionChoices into a method that is run after an instance is initialized. From there the QuizQuestionChoices can be referenced as an instance variable instead of having the same fetch referenced over and over.

The referenced method from the new implementation of QuizQuestion:

  after_initialize :load_choices
  def load_choices
    @quiz_question_choices = QuizQuestionChoice.where(question_id: id)
  end 

RSpec Test Setup

Note that for each test we included a before block that setup a QuizQuestion subclass object of the given type being tested, assigned it the required parameters, and assigned QuizQuestionChoices that matched the type of question. Doing this allowed us to simulate in the test how the object would be used within the greater context of the application.

Example test setup from quiz_question_spec.rb:

let(:quiz_question) { QuizQuestion.new }
  let(:quiz_question_choice1) { QuizQuestionChoice.new }
  let(:quiz_question_choice2) { QuizQuestionChoice.new }
  let(:quiz_question_choice3) { QuizQuestionChoice.new }
  let(:quiz_question_choice4) { QuizQuestionChoice.new }
  before(:each) do
    quiz_question.quiz_question_choices = [quiz_question_choice1, quiz_question_choice2, quiz_question_choice3, quiz_question_choice4]
    quiz_question.txt = 'Question Text'
    allow(quiz_question).to receive(:type).and_return('MultipleChoiceRadio')
    allow(quiz_question).to receive(:id).and_return(99)
    allow(quiz_question).to receive(:weight).and_return(5)
    allow(quiz_question_choice1).to receive(:txt).and_return('Choice 1')
    allow(quiz_question_choice1).to receive(:question_id).and_return(99)
    allow(quiz_question_choice1).to receive(:iscorrect?).and_return(true)
    allow(quiz_question_choice2).to receive(:txt).and_return('Choice 2')
    allow(quiz_question_choice2).to receive(:question_id).and_return(99)
    allow(quiz_question_choice2).to receive(:iscorrect?).and_return(false)
    allow(quiz_question_choice3).to receive(:txt).and_return('Choice 3')
    allow(quiz_question_choice3).to receive(:question_id).and_return(99)
    allow(quiz_question_choice3).to receive(:iscorrect?).and_return(false)
    allow(quiz_question_choice4).to receive(:txt).and_return('Choice 4')
    allow(quiz_question_choice4).to receive(:question_id).and_return(99)
    allow(quiz_question_choice4).to receive(:iscorrect?).and_return(false)
  end 

Methods in Files Modified/Created

isvalid method

  • Reimplemented the method in quiz_question.rb
  • Removed from multiple_choice_radio.rb and true_false.rb
  • Extended in multiple_choice_checkbox.rb
  • Modified quiz_question_spec.rb and multiple_choice_checkbox_spec.rb to include tests for the method.

view_question_text method

  • left as is in quiz_question.rb

complete method

  • Reimplemented the method in quiz_question.rb
  • Removed from multiple_choice_radio.rb
  • Removed from multiple_choice_checkbox.rb
  • Added test case for complete method in quiz_question_spec.rb.

view_completed_question method

  • Reimplemented the method in quiz_question.rb
  • Removed from multiple_choice_radio.rb
  • Removed from multiple_choice_checkbox.rb
  • Added test case for view_completed_question method in quiz_question_spec.rb.

edit method

  • Implemented in quiz_question.rb
  • Extended in multiple_choice_radio.rb, multiple_choice_checkbox.rb, and true_false.rb
  • Tests cases added in quiz_question_spec.rb, multiple_choice_checkbox_spec.rb, and true_false_spec.rb

Additional Files Created

As part of our reimplementation, we needed to pull in some existing model classes from Expertiza in order to allow us to fully test our reimplemented models. These files were copied from the latest Expteriza main branch as of Program 3 assignment and were not modified. These files include:

  • app/models/question.rb
  • app/models/questionnaire.rb
  • app/models/quiz_quesiton_choice.rb

Additionally we created migration from these models (and our reimplemented classes):

  • db/migrate/20230311214056_create_quiz_questions.rb
  • db/migrate/20230314152206_create_questionnaires.rb
  • db/migrate/20230314152945_create_questions.rb
  • db/migrate/20230314153211_add_key_to_questions.rb
  • db/migrate/20230315172116_create_quiz_question_choices.rb

Executing Tests

  • To run the test suite written for our project, use the following command:
 bundle exec rspec spec/requests/model 
  • To run individual test files (separated by model that is being tested), use the following command:
 bundle exec rspec spec/requests/model/test_file_name.rb 

Useful Links