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)

Reimplementation Details

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 = 'Please select only one correct answer for all questions' if @correct_count > 1
    @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 = 'valid' if @correct_count > 1
    @valid
  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
 

The following test case has been added to test complete method-

describe "#complete" do
    before do
      id = quiz_question.id
      expected_html = "<label for=\"" + id.to_s + "\">Question Text</label><br><input name = \"" + id.to_s + "\" id = \"" + id.to_s + "_1\" value = \"Choice 1\" type=\"radio\"/>Choice 1</br><input name = \"" + id.to_s + "\" id = \"" + id.to_s + "_2\" value = \"Choice 2\" type=\"radio\"/>Choice 2</br><input name = \"" + id.to_s + "\" id = \"" + id.to_s + "_3\" value = \"Choice 3\" type=\"radio\"/>Choice 3</br><input name = \"" + id.to_s + "\" id = \"" + id.to_s + "_4\" value = \"Choice 4\" type=\"radio\"/>Choice 4</br>"
      expect(quiz_question.complete).to eq(expected_html)
    end
    it "returns the completed HTML for the quiz question" do
    end
  end 

view_completed_question

The view_completed_question method was also not initially implemented within QuizQuestion and each of the child classes had its own implementation. To reimplement this method, we removed it from its subclass and moved it to the superclass in quiz_question.rb. The HTML code generated by this program is used to check the completed question and also to verify the response of the user. An indication is provided that indicates whether the user's response was right or wrong, and it is highlighted with help of check or delete icon which you can find here-assets/images.

The implementation of view_completed_question is shown below-

  def view_completed_question(user_answer)
    quiz_question_choices = self.quiz_question_choices

    html = ''
    quiz_question_choices.each do |answer|
      html += if answer.iscorrect
                '<b>' + answer.txt + '</b> -- Correct <br>'
              else
                answer.txt + '<br>'
              end
    end

    html += '<br>Your answer is: '
    html += '<b>' + user_answer.first.comments.to_s + '</b>'
    html += if user_answer.first.answer == 1
              '<img src="/assets/Check-icon.png"/>'
            else
              '<img src="/assets/delete_icon.png"/>'
            end
    html += '</b>'
    html += '<br><br><hr>'
    html.html_safe
  end 

The following test case has been added to test view_completed_question method-

  describe '#view_completed_question' do
    it 'returns the correct HTML for a completed question' do
      quiz_question = QuizQuestion.new(txt: 'which is the latest Iphone?')
      quiz_question_choice_1 = QuizQuestionChoice.new(txt: 'Iphone14', iscorrect: true)
      quiz_question_choice_2 = QuizQuestionChoice.new(txt: 'Iphone13', iscorrect: false)
      quiz_question_choice_3 = QuizQuestionChoice.new(txt: 'Iphone12', iscorrect: false)
      quiz_question.quiz_question_choices << quiz_question_choice_1
      quiz_question.quiz_question_choices << quiz_question_choice_2
      quiz_question.quiz_question_choices << quiz_question_choice_3
      user_answer = double('user_answer')
      allow(user_answer).to receive_message_chain(:first, :comments).and_return('Iphone14')
      allow(user_answer).to receive_message_chain(:first, :answer).and_return(1)
            expected_html = '<b>Iphone14</b> -- Correct <br>Iphone13<br>Iphone12<br><br>Your answer is: <b>Iphone14</b><img src="/assets/Check-icon.png"/></b><br><br><hr>'
            expect(quiz_question.view_completed_question(user_answer)).to eq(expected_html)
    end
    end 

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 


Test Plan

Overall Plan

The overall plan is to perform automated RSpec testing of all the methods that have been moved to the superclass, namely QuizQuestion. These methods are:

  • edit
  • view_question_text
  • complete
  • view_completed_question
  • isvalid

Most tests will test the methods in QuizQuestion class as the subclasses mainly inherit these methods without extension. These test will be included in quiz_question_spec.rb (full path: /spec/requests/model/quiz_question_spec.rb). Methods that are extended by subclasses will also be tested in a separate spec file. For example, isvalid method is fully tested for QuizQuestion model in quiz_question_spec.rb. Since TrueFalse and MultipleChoiceRadio inherit this method fully from the superclass without extension, no extra test is created to test the method for the two subclasses. However, MultipleChoiceCheckbox extended the isvalid method. As a result, extra tests are created to test isvalid for MultipleChoiceCheckbox; these tests are then included in multiple_choice_checkbox_spec.rb

edit

The goal in our automated testing of the edit function is to ensure that, given a QuizQuestion or QuizQuestion subclass object, the edit method of the class should display the HTML returned for the edit functionality as expected. In each of our Rspec test files, an object of the class under test (QuizQuestion or subclasses) is created to simulate what a similar database-driven object would appear like to our model classes.

As the edit function in each class is slightly different, we implemented specific tests for each of the classes we reimplemented.

  • Within spec/requests/model/quiz_question_spec.rb the edit method is tested to ensure the default question prefix HTML is returned.
  • Within spec/requests/model/true_false_spec.rb, spec/requests/model/multiple_choice_radio_spec.rb, and spec/requests/model/multiple_choice_checkbox_spec.rb the edit method is tested to ensure given an object filled out with QuizQuestionChoices, question text, question id, and weight the HTML is rendered appropriately.

These tests will fail if the underlying HTML returned from the model changes, which would be indicative of possible negative downstream effects in the application where the QuizQuestion subclasses are used.

isvalid

To test isvalid several tests were written to check for the behavior and returns of the method:

  • Test the return message if the input contains text for the question prompt, text for all question choices, and only one of the choices is correct (expect "valid")
  • Test the return message if the input is missing text for the question prompt (expect "Please make sure all questions have text")
  • Test the return message if the input is missing text for at least one of the question choices (expect "Please make sure every question has text for all options")
  • Test the return message if the input provides no correct answer (expect "Please select a correct answer for all questions")
  • Test the return message if the input provides more than 1 correct answer (expect "Please select only one correct answer for all questions")

isvalid is extended in MultipleChoiceCheckbox as the requirements for MultipleChoiceCheckbox to be valid are slightly different from those of QuizQuestion, TrueFalse, and MultipleChoiceRadio

  • Test the return message if the input provides only 1 correct answer (expect "A multiple-choice checkbox question should have more than one correct answer.")
  • Test the return message if the input provides more than 1 correct answer (expect "valid")
# 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
  describe '#isvalid' do
    context 'when there are more than one correct choices' do
      it 'returns "Please select only one correct answer for all questions"' do
        questions = 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(quiz_question.isvalid(questions)).to eq('Please select only one 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 

view_question_text

The test for view_question_text is fairly straight forward as once the question input passes isvalid, we only need to make sure that view_question_text display the information in a predetermined format. The test for view_question_text is shown below

  describe '#view_question_text' do
    it 'returns the text of the questions' do
      weight = quiz_question.weight
      expect(quiz_question.view_question_text).to eq('<b>Question Text</b><br />Question Type: MultipleChoiceRadio<br />Question Weight: ' + 
                                                      weight.to_s + '<br />  - <b>Choice 1</b><br />   - Choice 2<br />   - Choice 3<br />   - 
                                                      Choice 4<br /> <br />')
    end
  end 

RSpec Test Setup

Note that for each test file 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