CSC/ECE 517 Fall 2023 - E2372. Reimplement QuizQuestion and its child classes

From Expertiza_Wiki
Jump to navigation Jump to search

Objectives

There are four methods in QuizQuestion super class. edit, view_question_text, view_completed_question, and complete. These 4 (model) methods will return different html strings depends 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)
  • complete: What to display if a student is filling out a questionnaire (response_controller.rb)
  • view_completed_question: What to display if a student is viewing a filled-out questionnaire (response_controller.rb)

Our goals are

  1. Add a new method isvalid to check the validity of inputs (question and answer texts).
  2. Make a complete UML diagram for these classes with their attributes, methods, and class methods.
  3. Check all implementations of instance methods, extract duplicated codes, and put them into the highest-level class.
  4. Thoroughly test the QuizQuestion class

Overview of the Classes

quiz_question.rb

QuizQuestion is a base class used to represent a question in a quiz. It inherits from the Question class. This class is associated with QuizQuestionChoice objects through a has_many relationship, indicating that a QuizQuestion can have multiple choices. It handles the creation of HTML for different views related to a question, such as editing, completing, and viewing the question text.

Methods:

  • edit: to generate the HTML form for editing a question,
  • complete: used for completing the question in the quiz
  • view_question_text: to display the question and its choices)
  • view_completed_question: abstract method used after quiz has been submitted, and
  • isvalid: to validate the question, especially ensuring it has text

multiple_choice_radio.rb

This class extends QuizQuestion and represents a multiple-choice question where the participant can select only one answer from a set of radio buttons.

Methods:

  • edit: overridden to add HTML for a set of radio buttons that allows the user to select one choice as the correct answer; also includes text inputs for the choices' text
  • complete: renders HTML for the user to complete this type of question in a quiz interface
  • view_completed_question: displays the correct answer, the user's answer, and visual feedback on whether the user's answer was correct
  • isvalid: extended to ensure that not only does each choice have text, but also that exactly one correct answer is selected

multiple_choice_checkbox.rb

The MultipleChoiceCheckbox class is a subclass of QuizQuestion and represents a multiple-choice question where the participant can select multiple answers through checkboxes.

Methods:

  • edit: add HTML input elements for checkboxes, allowing multiple correct answers to be selected, along with text inputs for entering the choices.
  • complete: allows users to complete the question, displaying checkboxes for them to select their answers
  • view_completed_question: shows correct answers and whether the user's selections were correct
  • isvalid: validates the question, ensures there are 2 correct answers if it's a checkbox question.

true_false.rb

This class also extends QuizQuestion and is specialized for true/false questions.

Methods:

  • edit: includes HTML radio buttons specifically for 'True' and 'False' options
  • complete: render the true/false question for the user to answer
  • view_completed_question: displays the correct answer and the user's answer
  • isvalid: checks to ensure that the question has text and that a correct answer is designated

Our Solution (Phase 1)

  • We implemented isvalid(choice_info) in QuizQuestion.
def isvalid(choice_info)
  @valid = 'valid'
  @valid = 'Please make sure all questions have text' if txt == ''
  @valid
end
  • We factored out duplicate code in edit from the subclasses and placed it into the superclass QuizQuestion. Now edit in the subclasses inherit partially from QuizQuestion.

edit in QuizQuestion

def edit
    @quiz_question_choices = QuizQuestionChoice.where(question_id: id)

    @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>'
end

edit in MultipleChoiceCheckbox

def edit
    super
    # for i in 0..3
    [0, 1, 2, 3].each do |i|
      @html += '<tr><td>'

      @html += '<input type="hidden" name="quiz_question_choices[' + id.to_s + '][MultipleChoiceCheckbox][' + (i + 1).to_s + '][iscorrect]" '
      @html += 'id="quiz_question_choices_' + id.to_s + '_MultipleChoiceCheckbox_' + (i + 1).to_s + '_iscorrect" value="0" />'

      @html += '<input type="checkbox" name="quiz_question_choices[' + id.to_s + '][MultipleChoiceCheckbox][' + (i + 1).to_s + '][iscorrect]" '
      @html += 'id="quiz_question_choices_' + id.to_s + '_MultipleChoiceCheckbox_' + (i + 1).to_s + '_iscorrect" value="1" '
      @html += 'checked="checked" ' if @quiz_question_choices[i].iscorrect
      @html += '/>'

      @html += '<input type="text" name="quiz_question_choices[' + id.to_s + '][MultipleChoiceCheckbox][' + (i + 1).to_s + '][txt]" '
      @html += 'id="quiz_question_choices_' + id.to_s + '_MultipleChoiceCheckbox_' + (i + 1).to_s + '_txt" '
      @html += 'value="' + @quiz_question_choices[i].txt + '" size="40" />'

      @html += '</td></tr>'
    end

    @html.html_safe
    # safe_join(@html)
end
  • We reimplemented QuizQuestion class and child classes methods to use @html (an instance variable) instead of html.

Final Phase Planning

  • Further refactor quiz_question.rb and subclasses
  • Currently, quiz_question.rb is a subclass of question.rb, however it does not follow the Liskov Substitution Principle because the method isvalid is not present in the superclass. By refactoring isvalid into the superclass, the entire Question hierarchy can be made more maintainable.
  • Most methods of quiz_question.rb contain hard-coded HTML, which makes the code inflexible and prone to breaking, and also violates SOP. We plan to refactor the HTML building logic out of the methods to improve readability and maintainability. This would also make testing easier.

UML Diagram

Final Phase Changes

Refactoring QuizQuestion and its child classes

1. Extract Method
We applied the extract method to make the code more readable. For example, in the edit method in MultipleChoiceRadio, we created new methods and copied the relevant code fragments from edit into the new methods.

def edit
  @quiz_question_choices = QuizQuestionChoice.where(question_id: id)
  @html = create_html_content
end

Private methods:

def create_html_content
    html_content = ""
    html_content << create_textarea_row
    html_content << create_weight_input_row
    html_content
  end

def create_textarea_row
  "<tr><td><textarea cols='100' name='question[#{id}][txt]' id='question_#{id}_txt'>#{txt}</textarea></td></tr>"
end

def create_weight_input_row
  "<tr><td>Question Weight: <input type='number' name='question_weights[#{id}][txt]' id='question_wt_#{id}_txt' value='#{weight}' min='0' /></td></tr>"
end

2. Replacing += with << In Ruby, << can be used a string concatenation operator. We replaced all instances of using += to << because we are modifying an existing object html rather than creating a new object. Here's an example from QuizQuestion's view_question_text.

Before refactoring After refactoring
def view_question_text
  @html = '<b>' + txt + '</b><br />'
  @html += 'Question Type: ' + type + '<br />'
  @html += 'Question Weight: ' + weight.to_s + '<br />'
  if quiz_question_choices
    quiz_question_choices.each do |choices|
      @html += if choices.iscorrect?
                '  - <b>' + choices.txt + '</b><br /> '
              else
                '  - ' + choices.txt + '<br /> '
              end
    end
    @html += '<br />'
  end
  @html.html_safe
end
def view_question_text
  @html = "<b>#{txt}</b><br />"
  @html << "Question Type: #{type} <br />"
  @html << "Question Weight:#{weight.to_s} <br />"
  @html << create_choices if quiz_question_choices.present?
  @html.html_safe
end 

Private methods:

def create_choices
  choices_html = ""
  quiz_question_choices.each do |choice|
    choices_html << choice_html(choice)
  end
  choices_html << "<br />"
  choices_html
end

def choice_html(choice)
  if choice.iscorrect?
    "  - <b>#{choice.txt}</b><br /> "
  else
    "  - #{choice.txt}<br /> "
  end
end

def all_choices_have_text?(choice_info)
  choice_info.all? { |_idx, value| value[:txt].present? }
end

Testing HTML with Nokogiri

Testing with Nokogiri in Rspec allows us to selectively query parsed HTML to inspect certain elements. This allows us to write more targeted tests. Moreover, it allows us to write readable and expressive tests that closely resemble the behavior of Expertiza. Here's an example from true_false_spec.rb where we broke down the original HTML string into smaller, more readable chunks for testing.

Before refactoring:

expect(true_false.edit).to eq('<tr><td><textarea cols="100" name="question[1][txt]" id="question_1_txt"> Test question:</textarea></td></tr><tr><td>Question Weight: <input type="number" name="question_weights[1][txt]"  id="question_wt_1_txt" value="1" min="0" /></td></tr><tr><td><input type="radio" name="quiz_question_choices[1][TrueFalse][1][iscorrect]" id="quiz_question_choices_1_TrueFalse_1_iscorrect_True" value="True" checked="checked" />True</td></tr> <tr><td><input type="radio" name="quiz_question_choices[1][TrueFalse][1][iscorrect]" id="quiz_question_choices_1_TrueFalse_1_iscorrect_True" value="False" />False</td></tr>')

After refactoring:

html = Nokogiri::HTML(true_false.edit)
# Test for presence of a text area for the question text
expect(html.css('textarea[name="question[1][txt]"]')).not_to be_empty
# Test for presence of an input for the question weight
expect(html.css('input[name="question_weights[1][txt]"]')).not_to be_empty
# Test for the correct number of choices
expect(html.css('input[type="radio"][name^="quiz_question_choices[1][TrueFalse]"]').size).to eq(2)

Adding multiple_choice_radio_spec.rb

We created an additional rspec file multiple_choice_radio_spec.rb to enable testing for the MultipleChoiceRadio class. We included testing for all four methods (edit, complete, view_completed_question and isvalid).

Why isvalid cannot be added to QuizQuestion's superclass Question

Originally, we had planned to move isvalid to Question so that it follows the Liskov Substitution Principle. However, upon further inspection, there are other subclasses of Question that do not use isvalid, therefore it makes more sense to leave the implementation as it is.

Testing

Refactoring existing tests

As described above, using Nokogiri allows us to search for specific elements in the HTML code returned. This allows for some variability in the outputs while still ensuring that key aspects such as the number of questions, the text of the questions, and whether they are valid, are still being correctly tested. Additional tests are also added to existing test files to improve code coverage as shown by SimpleCov.

In general, we tested QuizQuestion and all of its subclasses, including:

  • MultipleChoiceCheckbox
  • MultipleChoiceRadio
  • TrueFalse

Preparation for testing

The below example shows the preparation for testing multiple_choice_radio.

let(:multiple_choice_radio) { build(:multiple_choice_radio, id: 1) }
let(:questionnaire1) { build(:questionnaire, id: 1, type: "ReviewQuestionnaire") }
let(:questionnaire2) { build(:questionnaire, id: 2, type: "MetareviewQuestionnaire") }
let(:team) { build(:assignment_team, id: 1, name: "no team") }
let(:participant) { build(:participant, id: 1) }
let(:assignment) { build(:assignment, id: 1, name: "no assignment", participants: [participant], teams: [team]) }
let(:scored_question) { build(:scored_question, id: 1) }
let(:question) { build(:question) }

The first line builds the MultipleChoiceRadio dummy for testing, and the rest of the lines builds the necessary infrastructure for testing methods from the superclass Question that MultipleChoiceRadio does not override. These additional tests ensure that the reimplementations are still compatible with the logic of the superclass methods.

Testing the reimplemented methods

Since all subclasses of QuizQuestion override the same methods, the testing structure for the subclasses are very similar. Therefore, in the descriptions below, we will show one example test code snippet for each of edit, view_completed_question, and isvalid.

Testing edit

The test for edit in multiple_choice_checkbox_spec.rb is shown below:

it "returns the html" do
    qc = double("QuizQuestionChoice")
    allow(QuizQuestionChoice).to receive(:where).with(question_id: 1).and_return([qc, qc, qc, qc])
    allow(qc).to receive(:iscorrect).and_return(true)
    allow(qc).to receive(:txt).and_return("question text")

    html = Nokogiri::HTML(multiple_choice_checkbox.edit)

   expect(html.css('textarea[name="question[1][txt]"]')).not_to be_empty
   expect(html.css('input[name="question_weights[1][txt]"]')).not_to be_empty
   expect(html.css('input[type="checkbox"][name^="quiz_question_choices[1][MultipleChoiceCheckbox]"]').size).to eq(4)
   html.css('input[type="checkbox"][name^="quiz_question_choices[1][MultipleChoiceCheckbox]"]').each do |checkbox|
      expect(checkbox["checked"]).to eq("checked")
    end
   expect(html.css('input[type="text"][name^="quiz_question_choices[1][MultipleChoiceCheckbox]"]').first["value"]).to eq("question text")
  end
end

This test first sets up a multiple choice question with 4 choices. Then, it calls edit which returns a block of HTML code. Using Nokogiri to parse the HTML, it makes sure that the question’s text is not empty and that the question has a weight. It also checks that the question has the correct number of choices, and that the resulting checkboxes are marked as correct where appropriate, which in this case is all 4.

Testing view_completed_question

In general, 2 cases are tested for this method: one for a correct answer, and one for an incorrect answer. The below example shows the “correct” case in true_false_spec.rb

context "when correct" do
  it "returns the html showing correct answer(s)" do
    allow(true_false).to receive(:txt).and_return("question")
    qc1 = double("QuizQuestionChoice")
    qc2 = double("QuizQuestionChoice")
    allow(QuizQuestionChoice).to receive(:where).with(question_id: 1).and_return([qc1, qc2])
    allow(qc1).to receive(:iscorrect).and_return(true)
    allow(qc2).to receive(:iscorrect).and_return(false)
    answer = [Answer.new(answer: 1, comments: "true text", question_id: 1)]
    html = true_false.view_completed_question(answer)
    expect(html).to include("Your answer is: <b> true text<img src=\"/assets/Check-icon.png\"/> </b>")
  end
end

This test first sets up a true false question and an Answer instance containing the correct answer choice. Then, it calls view_completed_question with the Answer object as parameter. The method returns HTML code to show feedback to the provided answer. The test then checks the HTML returned for the expected response text for a correct answer, as well as a checkmark icon.

In the case of an incorrect answer, the test is almost identical except that the Answer object passed would be incorrect, and a different returned HTML is expected:

expect(html).to include("Your answer is: <b> false text<img src=\"/assets/delete_icon.png\"/> </b>")

Testing isvalid

The main purpose of isvalid is to check for various ways that a QuizQuestion could be malformed. This can happen if the question does not have text, if one or more choices do not have text, or if a correct answer is not specified. Therefore, the method is tested using these cases to ensure that all types of malformed questions are caught. The below example is for multiple_choice_radio.

describe "#isvalid" do
    context "when the question itself does not have txt" do
      it "returns 'Please make sure all questions have text'" do
        allow(multiple_choice_radio).to receive(:txt).and_return("")
        choices = { "1" => { txt: "choice text", iscorrect: "0" }, "2" => { txt: "choice text", iscorrect: "1" }, "3" => { txt: "choice text", iscorrect: "0" }, "4" => { txt: "choice text", iscorrect: "0" } }
        expect(multiple_choice_radio.isvalid(choices)).to eq("Please make sure all questions have text")
      end
    end
    context "when a choice does not have txt" do
      it 'returns "Please make sure every question has text for all options"' do
        allow(multiple_choice_radio).to receive(:txt).and_return("Question Text")
        choices = { "1" => { txt: "", iscorrect: "0" }, "2" => { txt: "", iscorrect: "1" }, "3" => { txt: "", iscorrect: "0" }, "4" => { txt: "", iscorrect: "0" } }
        expect(multiple_choice_radio.isvalid(choices)).to eq("Please make sure every question has text for all options")
      end
    end
    context "when no choices are correct" do
      it 'returns "Please select a correct answer for all questions"' do
        allow(multiple_choice_radio).to receive(:txt).and_return("Question Text")
        choices = { "1" => { txt: "choice text", iscorrect: 0 }, "2" => { txt: "choice text", iscorrect: 0 }, "3" => { txt: "choice text", iscorrect: 0 }, "4" => { txt: "choice text", iscorrect: 0 } }
        expect(multiple_choice_radio.isvalid(choices)).to eq("Please select exactly one correct answer")
      end
    end
    context "when one choice is correct" do 
      it 'returns valid' do
        allow(multiple_choice_radio).to receive(:txt).and_return("Question Text")
        choices = { "1" => { txt: "choice text", iscorrect: 0 }, "2" => { txt: "choice text", iscorrect: "1" }, "3" => { txt: "choice text", iscorrect: 0 }, "4" => { txt: "choice text", iscorrect: 0 } }
        expect(multiple_choice_radio.isvalid(choices)).to eq("valid")
      end
    end
  end

There are 4 scenarios tested.

  • In the first scenario, the question has an empty :txt field, which means that the question does not have text. The test expects the method to return a message prompting for the addition of text to the question.
  • The second scenario attempts to create a question where the question choices have empty :txt fields, which is expected to return a similar prompt.
  • The third scenario attempts to create a question with no correct choice selected, which expects a prompt requesting that one correct response be specified.
  • The fourth scenario specifies exactly one correct answer, which is the correct behavior. Therefore, this scenario should cause isvalid to return “valid”.

Test Coverage

Coverage for QuizQuestion

Coverage for MultipleChoiceCheckbox

Coverage for MultipleChoiceRadio

Coverage for TrueFalse

Manual Testing

Manual testing for these classes is not possible because there is currently no GUI implementation of these classes accessible on Expertiza.

Relevant Links

Github repository: https://github.com/opheliasin/expertiza

Pull request: https://github.com/expertiza/expertiza/pull/2682

Team

Mentor

Aditi Vakeel

Members

Nathan Sudduth (ncsuddut@ncsu.edu)

Ophelia Sin (oysin@ncsu.edu)

Yi Chen (ychen282@ncsu.edu)