refactor and bug fixes for QuizQuestion.score_question
fixes #8267 This refactors the large switch into an individual class for each question type. The hope is that we can start using these classes for other type-specific code as well, such as the giant switch in the parse_question method. While refactoring, I fixed all the bugs that had pending specs in quiz_question_spec.rb test plan: verify that quizzes are still scored correctly. the only visible changes should be the bug fixes for the pending specs: * there should be no floating point errors in scoring numerical questions * there should be no issues with floating point errors causing some question types to give 0 instead of full credit in edge cases * a blank answer shouldn't be treated as 0.0 in numerical questions * no answer shouldn't be treated as a blank answer for the purposes of undefined_if_blank (not currently exposed in the UI) Change-Id: I74d2efcf6b087247adeafc5d4685cb3ea2bba9c2 Reviewed-on: https://gerrit.instructure.com/10263 Reviewed-by: Cody Cutrer <cody@instructure.com> Tested-by: Hudson <hudson@instructure.com>
This commit is contained in:
parent
ea2e704904
commit
59d6a9af8d
|
@ -0,0 +1,128 @@
|
|||
#
|
||||
# Copyright (C) 2012 Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
class QuizQuestion::Base
|
||||
# typically you'll call this factory method rather than instantiating a subclass directly
|
||||
def self.from_question_data(data)
|
||||
type_name = data[:question_type]
|
||||
klass = question_types[type_name]
|
||||
raise("unknown quiz question type: #{type_name}") unless klass
|
||||
klass.new(data)
|
||||
end
|
||||
|
||||
@@question_types = {}
|
||||
mattr_reader :question_types
|
||||
|
||||
def self.inherited(klass)
|
||||
type_name = klass.question_type
|
||||
raise("question type #{type_name} already exists") if question_types.key?(type_name)
|
||||
question_types[type_name] = klass
|
||||
end
|
||||
|
||||
# override to change the name of the question type, defaults to the underscore-ized class name
|
||||
def self.question_type
|
||||
self.name.demodulize.underscore
|
||||
end
|
||||
|
||||
def initialize(question_data)
|
||||
# currently all the attributes are synthesized from @question_data
|
||||
# since questions are stored in this format anyway, it prevents us from
|
||||
# having to do a bunch of translation to some other format
|
||||
@question_data = question_data
|
||||
end
|
||||
|
||||
def question_id
|
||||
@question_data[:id]
|
||||
end
|
||||
|
||||
def points_possible
|
||||
@question_data[:points_possible].to_f
|
||||
end
|
||||
|
||||
def incorrect_dock
|
||||
@question_data[:incorrect_dock].try(:to_f)
|
||||
end
|
||||
|
||||
#
|
||||
# Scoring Methods to override in the subclass
|
||||
#
|
||||
|
||||
# the total number of parts to a question
|
||||
# many questions have just one part (fill in the blank, etc)
|
||||
# some questions have many parts and the score is (correct / total) * points_possible
|
||||
# text-only questions have no parts
|
||||
def total_answer_parts
|
||||
1
|
||||
end
|
||||
|
||||
# where the scoring of the question happens
|
||||
#
|
||||
# a UserAnswer is passed in, and the # of parts correct in the answer is returned
|
||||
#
|
||||
# for questions types with just one answer part, return 1 for correct and 0 for incorrect
|
||||
# for questions types with multiple answer parts, return the total # of parts that are correct
|
||||
#
|
||||
# you can also return true for full credit, or false for no credit
|
||||
#
|
||||
# if no answer is given at all, return nil
|
||||
#
|
||||
# (note this means nil != false in this return value)
|
||||
def correct_answer_parts(user_answer)
|
||||
nil
|
||||
end
|
||||
|
||||
# Return the number of explicitly incorrect answer parts
|
||||
#
|
||||
# This will never be called before correct_answer_parts is called, so it can
|
||||
# be calculated there if that's easier.
|
||||
#
|
||||
# If this is > 0, the user will be docked for each answer part that they got
|
||||
# incorrect. Most question types leave this at 0, so the user isn't punished
|
||||
# extra for wrong answers.
|
||||
def incorrect_answer_parts(user_answer)
|
||||
0
|
||||
end
|
||||
|
||||
# override and return true if the answer can't be auto-scored
|
||||
def requires_manual_scoring?(user_answer)
|
||||
false
|
||||
end
|
||||
|
||||
def score_question(answer_data)
|
||||
user_answer = UserAnswer.new(self.question_id, self.points_possible, answer_data)
|
||||
user_answer.total_parts = total_answer_parts
|
||||
correct_parts = correct_answer_parts(user_answer)
|
||||
|
||||
if !correct_parts.nil?
|
||||
correct_parts = 0 if correct_parts == false
|
||||
correct_parts = user_answer.total_parts if correct_parts == true
|
||||
|
||||
user_answer.incorrect_parts = incorrect_answer_parts(user_answer)
|
||||
correct_parts = 0 if (correct_parts - user_answer.incorrect_parts) < user_answer.total_parts && @question_data[:allow_partial_credit] == false
|
||||
|
||||
user_answer.correct_parts = correct_parts
|
||||
user_answer.incorrect_dock = incorrect_dock if user_answer.incorrect_parts > 0
|
||||
elsif user_answer.undefined_if_blank? || requires_manual_scoring?(user_answer)
|
||||
user_answer.undefined = true
|
||||
end
|
||||
|
||||
user_answer
|
||||
end
|
||||
end
|
||||
|
||||
Dir[Rails.root + "app/models/quiz_question/*_question.rb"].each { |f| require_dependency f }
|
|
@ -0,0 +1,25 @@
|
|||
#
|
||||
# Copyright (C) 2012 Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
class QuizQuestion::CalculatedQuestion < QuizQuestion::NumericalQuestion
|
||||
def answers
|
||||
answer = @question_data[:answers].first
|
||||
return [] unless answer
|
||||
return [{ :id => answer[:id], :numerical_answer_type => "exact_answer", :exact => answer[:answer], :margin => @question_data[:answer_tolerance] }]
|
||||
end
|
||||
end
|
|
@ -0,0 +1,29 @@
|
|||
#
|
||||
# Copyright (C) 2012 Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
class QuizQuestion::EssayQuestion < QuizQuestion::Base
|
||||
def requires_manual_scoring?(user_answer)
|
||||
true
|
||||
end
|
||||
|
||||
def correct_answer_parts(user_answer)
|
||||
config = Instructure::SanitizeField::SANITIZE
|
||||
user_answer.answer_details[:text] = Sanitize.clean(user_answer.answer_text, config) || ""
|
||||
nil
|
||||
end
|
||||
end
|
|
@ -0,0 +1,60 @@
|
|||
#
|
||||
# Copyright (C) 2012 Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
class QuizQuestion::FillInMultipleBlanksQuestion < QuizQuestion::Base
|
||||
def total_answer_parts
|
||||
variables.length
|
||||
end
|
||||
|
||||
def variables
|
||||
@variables ||= @question_data[:answers].map{|a| a[:blank_id] }.uniq
|
||||
end
|
||||
|
||||
def find_chosen_answer(variable, response)
|
||||
response = (response || "").strip.downcase
|
||||
@question_data[:answers].detect{|answer| answer[:blank_id] == variable && (answer[:text] || "").strip.downcase == response } || { :text => response, :id => nil, :weight => 0 }
|
||||
end
|
||||
|
||||
def answer_text(answer)
|
||||
answer[:text]
|
||||
end
|
||||
|
||||
def correct_answer_parts(user_answer)
|
||||
chosen_answers = {}
|
||||
total_answers = 0
|
||||
|
||||
variables.each do |variable|
|
||||
variable_id = AssessmentQuestion.variable_id(variable)
|
||||
response = user_answer[variable_id]
|
||||
if response.present?
|
||||
total_answers += 1
|
||||
end
|
||||
chosen_answer = find_chosen_answer(variable, response)
|
||||
chosen_answers[variable] = chosen_answer
|
||||
end
|
||||
|
||||
return nil if total_answers == 0
|
||||
|
||||
return chosen_answers.count do |variable, answer|
|
||||
answer ||= { :id => nil, :text => nil, :weight => 0 }
|
||||
user_answer.answer_details["answer_for_#{variable}".to_sym] = answer_text(answer)
|
||||
user_answer.answer_details["answer_id_for_#{variable}".to_sym] = answer[:id]
|
||||
answer && answer[:weight] == 100 && !variables.empty?
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,47 @@
|
|||
#
|
||||
# Copyright (C) 2012 Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
class QuizQuestion::MatchingQuestion < QuizQuestion::Base
|
||||
def total_answer_parts
|
||||
@question_data[:answers].length
|
||||
end
|
||||
|
||||
def correct_answer_parts(user_answer)
|
||||
total_answers = 0
|
||||
correct_answers = 0
|
||||
|
||||
@question_data[:answers].each do |answer|
|
||||
answer_match = user_answer["answer_#{answer[:id]}"].to_s
|
||||
|
||||
if answer_match.present?
|
||||
total_answers += 1
|
||||
found_matched = @question_data[:answers].find {|a| a[:match_id].to_i == answer_match.to_i }
|
||||
if found_matched == answer || (found_matched && found_matched[:right] && found_matched[:right] == answer[:right])
|
||||
correct_answers += 1
|
||||
answer_match = answer[:match_id].to_s
|
||||
end
|
||||
end
|
||||
|
||||
user_answer.answer_details["answer_#{answer[:id]}".to_sym] = answer_match
|
||||
end
|
||||
|
||||
return nil if total_answers == 0
|
||||
|
||||
correct_answers
|
||||
end
|
||||
end
|
|
@ -0,0 +1,56 @@
|
|||
#
|
||||
# Copyright (C) 2012 Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
class QuizQuestion::MultipleAnswersQuestion < QuizQuestion::Base
|
||||
def total_answer_parts
|
||||
len = @question_data[:answers].select{|a| a[:weight] == 100}.length
|
||||
len = 1 if len == 0
|
||||
len
|
||||
end
|
||||
|
||||
def incorrect_answer_parts(user_answer)
|
||||
@incorrect_answers
|
||||
end
|
||||
|
||||
def correct_answer_parts(user_answer)
|
||||
total_answers = 0
|
||||
correct_answers = 0
|
||||
@incorrect_answers = 0
|
||||
|
||||
@question_data[:answers].each do |answer|
|
||||
response = user_answer["answer_#{answer[:id]}"]
|
||||
next unless response
|
||||
total_answers += 1
|
||||
user_answer.answer_details["answer_#{answer[:id]}".to_sym] = response
|
||||
|
||||
# Total possible is divided by the number of correct answers.
|
||||
# For every correct answer they correctly select, they get partial
|
||||
# points. For every correct answer they don't select, do nothing.
|
||||
# For every incorrect answer that they select, dock them partial
|
||||
# points.
|
||||
if answer[:weight] == 100 && response == "1"
|
||||
correct_answers += 1
|
||||
elsif answer[:weight] != 100 && response == "1"
|
||||
@incorrect_answers += 1
|
||||
end
|
||||
end
|
||||
|
||||
return nil if total_answers == 0
|
||||
return correct_answers
|
||||
end
|
||||
end
|
|
@ -0,0 +1,36 @@
|
|||
#
|
||||
# Copyright (C) 2012 Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
class QuizQuestion::MultipleChoiceQuestion < QuizQuestion::Base
|
||||
def correct_answer_parts(user_answer)
|
||||
answer_text = user_answer.answer_text
|
||||
return nil if answer_text.nil?
|
||||
answer_id = answer_text.to_i
|
||||
answer = @question_data[:answers].find { |a| a[:id] == answer_id }
|
||||
return 0 unless answer
|
||||
user_answer.answer_id = answer[:id]
|
||||
return 0 if answer[:weight] != 100
|
||||
return 1
|
||||
end
|
||||
end
|
||||
|
||||
class QuizQuestion::TrueFalseQuestion < QuizQuestion::MultipleChoiceQuestion
|
||||
end
|
||||
|
||||
class QuizQuestion::MissingWordQuestion < QuizQuestion::MultipleChoiceQuestion
|
||||
end
|
|
@ -0,0 +1,27 @@
|
|||
#
|
||||
# Copyright (C) 2012 Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
class QuizQuestion::MultipleDropdownsQuestion < QuizQuestion::FillInMultipleBlanksQuestion
|
||||
def find_chosen_answer(variable, response)
|
||||
@question_data[:answers].detect{ |answer| answer[:blank_id] == variable && answer[:id] == response.to_i } || { :text => nil, :id => nil, :weight => 0 }
|
||||
end
|
||||
|
||||
def answer_text(answer)
|
||||
answer[:id]
|
||||
end
|
||||
end
|
|
@ -0,0 +1,53 @@
|
|||
#
|
||||
# Copyright (C) 2012 Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
require 'bigdecimal'
|
||||
|
||||
class QuizQuestion::NumericalQuestion < QuizQuestion::Base
|
||||
def answers
|
||||
@question_data[:answers].sort_by{|a| a[:weight] || 0}
|
||||
end
|
||||
|
||||
def correct_answer_parts(user_answer)
|
||||
answer_text = user_answer.answer_text
|
||||
return nil if answer_text.nil?
|
||||
return false if answer_text.blank?
|
||||
|
||||
# we use BigDecimal here to avoid rounding errors at the edge of the tolerance
|
||||
# e.g. in floating point, -11.7 with margin of 0.02 isn't inclusive of the answer -11.72
|
||||
answer_number = BigDecimal.new(answer_text.to_s)
|
||||
|
||||
match = answers.find do |answer|
|
||||
if answer[:numerical_answer_type] == "exact_answer"
|
||||
val = BigDecimal.new(answer[:exact].to_s)
|
||||
margin = BigDecimal.new(answer[:margin].to_s)
|
||||
min = val - margin
|
||||
max = val + margin
|
||||
answer_number >= min && answer_number <= max
|
||||
else
|
||||
answer_number >= BigDecimal.new(answer[:start].to_s) && answer_number <= BigDecimal.new(answer[:end].to_s)
|
||||
end
|
||||
end
|
||||
|
||||
if match
|
||||
user_answer.answer_id = match[:id]
|
||||
end
|
||||
|
||||
!!match
|
||||
end
|
||||
end
|
|
@ -0,0 +1,36 @@
|
|||
#
|
||||
# Copyright (C) 2012 Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
class QuizQuestion::ShortAnswerQuestion < QuizQuestion::Base
|
||||
def correct_answer_parts(user_answer)
|
||||
answer_text = user_answer.answer_text
|
||||
return nil if answer_text.nil?
|
||||
|
||||
answer_text = CGI::escapeHTML(answer_text).strip.downcase
|
||||
|
||||
answer = @question_data[:answers].sort_by{|a| a[:weight] || 0}.find do |answer|
|
||||
(answer[:text] || "").strip.downcase == answer_text
|
||||
end
|
||||
|
||||
if answer
|
||||
user_answer.answer_id = answer[:id]
|
||||
end
|
||||
|
||||
!!answer
|
||||
end
|
||||
end
|
|
@ -0,0 +1,23 @@
|
|||
#
|
||||
# Copyright (C) 2012 Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
class QuizQuestion::TextOnlyQuestion < QuizQuestion::Base
|
||||
def total_answer_parts
|
||||
0
|
||||
end
|
||||
end
|
|
@ -0,0 +1,77 @@
|
|||
#
|
||||
# Copyright (C) 2012 Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
# Stores the scoring information for a user's answer to a quiz question
|
||||
class UserAnswer < Struct.new(:question_id, :points_possible, :total_parts, :correct_parts, :incorrect_parts, :answer_id, :undefined, :answer_details, :incorrect_dock)
|
||||
def initialize(question_id, points_possible, answer_data)
|
||||
super(question_id, points_possible, 1, 0, 0)
|
||||
@points = 0.0
|
||||
@answer_data = answer_data
|
||||
self.answer_details = { :text => answer_text || "" }
|
||||
end
|
||||
|
||||
def [](k)
|
||||
@answer_data["question_#{question_id}_#{k}".to_sym]
|
||||
end
|
||||
|
||||
def answer_text
|
||||
@answer_data["question_#{question_id}".to_sym]
|
||||
end
|
||||
|
||||
def score
|
||||
if total_parts == 0
|
||||
return 0
|
||||
end
|
||||
score = (correct_parts.to_f / total_parts) * points_possible
|
||||
if incorrect_parts > 0
|
||||
if incorrect_dock
|
||||
score -= incorrect_dock * incorrect_parts
|
||||
else
|
||||
score -= (incorrect_parts.to_f / total_parts) * points_possible
|
||||
end
|
||||
score = 0.0 if score < 0
|
||||
end
|
||||
score
|
||||
end
|
||||
|
||||
# this seems like it should be part of the question data, not the user answer data
|
||||
def undefined_if_blank?
|
||||
@answer_data[:undefined_if_blank]
|
||||
end
|
||||
|
||||
# returns whether the answer is correct, in a bit of an odd way --
|
||||
# returns boolean true or false if the answer is completely correct or incorrect
|
||||
# returns the string "partial" if the answer is partially correct
|
||||
# returns the string "no_score" for text-only questions
|
||||
# returns the string "undefined" if no answer was given and
|
||||
# undefined_if_blank is specified, or if the question can't be scored
|
||||
# automatically (like an essay question)
|
||||
def correctness
|
||||
if total_parts == 0
|
||||
"no_score"
|
||||
elsif undefined
|
||||
"undefined"
|
||||
elsif correct_parts == total_parts && incorrect_parts == 0
|
||||
true
|
||||
elsif (correct_parts - incorrect_parts) > 0
|
||||
"partial"
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
|
@ -474,179 +474,18 @@ class QuizSubmission < ActiveRecord::Base
|
|||
# added or the question answer they selected goes away, then the
|
||||
# the teacher gets the added burden of going back and manually assigning
|
||||
# scores for these questions per student.
|
||||
undefined_if_blank = params[:undefined_if_blank]
|
||||
answer_text = params["question_#{q[:id]}"] rescue ""
|
||||
user_answer = {}
|
||||
user_answer[:text] = answer_text || ""
|
||||
user_answer[:question_id] = q[:id]
|
||||
user_answer[:points] = 0
|
||||
user_answer[:correct] = false
|
||||
question_type = q[:question_type]
|
||||
q[:points_possible] = q[:points_possible].to_f
|
||||
if question_type == "multiple_choice_question" || question_type == "true_false_question" || question_type == "missing_word_question"
|
||||
q[:answers].each do |answer|
|
||||
if answer[:id] == answer_text.to_i
|
||||
user_answer[:answer_id] = answer[:id]
|
||||
user_answer[:correct] = answer[:weight] == 100
|
||||
user_answer[:points] = q[:points_possible]
|
||||
end
|
||||
end
|
||||
user_answer[:correct] = "undefined" if answer_text == nil && undefined_if_blank
|
||||
elsif question_type == "short_answer_question"
|
||||
answers = q[:answers].sort_by{|a| a[:weight] || 0}
|
||||
match = false
|
||||
answers.each do |answer|
|
||||
if (answer[:text] || "").strip.downcase == CGI::escapeHTML(answer_text || "").strip.downcase && !match
|
||||
match = true
|
||||
user_answer[:answer_id] = answer[:id]
|
||||
user_answer[:correct] = true
|
||||
user_answer[:points] = q[:points_possible]
|
||||
end
|
||||
end
|
||||
user_answer[:correct] = "undefined" if answer_text == nil && undefined_if_blank
|
||||
elsif question_type == "essay_question"
|
||||
config = Instructure::SanitizeField::SANITIZE
|
||||
user_answer[:text] = Sanitize.clean(user_answer[:text] || "", config)
|
||||
user_answer[:correct] = "undefined"
|
||||
elsif question_type == "text_only_question"
|
||||
user_answer[:correct] = "no_score"
|
||||
elsif question_type == "matching_question"
|
||||
user_answer[:points] = 0
|
||||
found_match = false
|
||||
q[:answers].each do |answer|
|
||||
answer_match = params["question_#{q[:id]}_answer_#{answer[:id]}"].to_s rescue ""
|
||||
found_match = true if answer_match != nil
|
||||
found_matched = q[:answers].find{|a| a[:match_id].to_i == answer_match.to_i}
|
||||
if found_matched == answer || (found_matched && found_matched[:right] && found_matched[:right] == answer[:right])
|
||||
user_answer[:points] += (q[:points_possible].to_f / q[:answers].length.to_f) rescue 0
|
||||
answer_match = answer[:match_id].to_s
|
||||
end
|
||||
user_answer["answer_#{answer[:id]}".to_sym] = answer_match
|
||||
end
|
||||
if q[:allow_partial_credit] == false && user_answer[:points] < q[:points_possible].to_f
|
||||
user_answer[:points] = 0
|
||||
end
|
||||
user_answer[:correct] = "partial"
|
||||
user_answer[:correct] = false if user_answer[:points] == 0
|
||||
user_answer[:correct] = true if user_answer[:points] == q[:points_possible]
|
||||
user_answer[:correct] = "undefined" if !found_match && undefined_if_blank
|
||||
elsif question_type == "numerical_question"
|
||||
answer_number = answer_text.to_f
|
||||
answers = q[:answers].sort_by{|a| a[:weight] || 0}
|
||||
match = false
|
||||
answers.each do |answer|
|
||||
if !match
|
||||
if answer[:numerical_answer_type] == "exact_answer"
|
||||
match = true if answer_number >= answer[:exact] - answer[:margin] && answer_number <= answer[:exact] + answer[:margin]
|
||||
else
|
||||
match = true if answer_number >= answer[:start] && answer_number <= answer[:end]
|
||||
end
|
||||
if match
|
||||
user_answer[:answer_id] = answer[:id]
|
||||
user_answer[:correct] = true
|
||||
user_answer[:points] = q[:points_possible]
|
||||
end
|
||||
end
|
||||
end
|
||||
user_answer[:correct] = "undefined" if answer_text == nil && undefined_if_blank
|
||||
elsif question_type == "calculated_question"
|
||||
answer_number = answer_text.to_f
|
||||
val = q[:answers].first[:answer].to_f rescue 0
|
||||
margin = q[:answer_tolerance].to_f
|
||||
min = val - margin
|
||||
max = val + margin
|
||||
user_answer[:answer_id] = q[:answers].first[:id]
|
||||
if answer_number >= min && answer_number <= max
|
||||
user_answer[:correct] = true
|
||||
user_answer[:points] = q[:points_possible]
|
||||
end
|
||||
elsif question_type == "multiple_answers_question"
|
||||
correct_sequence = ""
|
||||
user_sequence = ""
|
||||
found_any = false
|
||||
user_answer[:points] = 0
|
||||
n_correct = q[:answers].select{|a| a[:weight] == 100}.length
|
||||
n_correct = 1 if n_correct == 0
|
||||
q[:answers].each do |answer|
|
||||
response = params["question_#{q[:id]}_answer_#{answer[:id]}"] rescue ""
|
||||
response ||= ""
|
||||
found_any = true if response != nil
|
||||
user_answer["answer_#{answer[:id]}".to_sym] = response
|
||||
correct = nil
|
||||
# Total possible is divided by the number of correct answers.
|
||||
# For every correct answer they correctly select, they get partial
|
||||
# points. For every correct answer they don't select, do nothing.
|
||||
# For every incorrect answer that they select, dock them partial
|
||||
# points.
|
||||
correct = true if answer[:weight] == 100 && response == "1"
|
||||
correct = false if answer[:weight] != 100 && response == "1"
|
||||
if correct == true
|
||||
user_answer[:points] += (q[:points_possible].to_f / n_correct.to_f) rescue 0
|
||||
elsif correct == false
|
||||
dock = (q[:incorrect_dock] || (q[:points_possible].to_f / n_correct.to_f)).to_f rescue 0.0
|
||||
user_answer[:points] -= dock
|
||||
end
|
||||
correct_sequence = correct_sequence + (answer[:weight] == 100 ? "1" : "0")
|
||||
user_sequence = user_sequence + (response == "1" ? "1" : "0")
|
||||
end
|
||||
# even if they only selected wrong answers, they can't score less than 0
|
||||
user_answer[:points] = [user_answer[:points], 0].max
|
||||
if correct_sequence == user_sequence
|
||||
user_answer[:points] = q[:points_possible]
|
||||
end
|
||||
if q[:allow_partial_credit] == false && user_answer[:points] < q[:points_possible].to_f
|
||||
user_answer[:points] = 0
|
||||
end
|
||||
user_answer[:correct] = "partial"
|
||||
user_answer[:correct] = false if user_answer[:points] == 0
|
||||
user_answer[:correct] = true if user_answer[:points] == q[:points_possible]
|
||||
user_answer[:correct] = "undefined" if !found_any && undefined_if_blank
|
||||
elsif question_type == "multiple_dropdowns_question"
|
||||
chosen_answers = {}
|
||||
variables = q[:answers].map{|a| a[:blank_id] }.uniq
|
||||
variables.each do |variable|
|
||||
variable_id = AssessmentQuestion.variable_id(variable)
|
||||
response = (params["question_#{q[:id]}_#{variable_id}"] rescue nil).to_i
|
||||
chosen_answer = q[:answers].detect{|answer| answer[:blank_id] == variable && answer[:id] == response.to_i }
|
||||
chosen_answers[variable] = chosen_answer
|
||||
end
|
||||
answer_tally = 0
|
||||
chosen_answers.each do |variable, answer|
|
||||
answer_tally += q[:points_possible].to_f / variables.length.to_f if answer && answer[:weight] == 100 && !variables.empty?
|
||||
user_answer["answer_for_#{variable}".to_sym] = answer[:id] rescue nil
|
||||
user_answer["answer_id_for_#{variable}".to_sym] = answer[:id] rescue nil
|
||||
end
|
||||
user_answer[:points] = answer_tally
|
||||
user_answer[:correct] = true if answer_tally == q[:points_possible]
|
||||
user_answer[:correct] = "partial" if answer_tally > 0 && answer_tally < q[:points_possible]
|
||||
elsif question_type == "fill_in_multiple_blanks_question"
|
||||
chosen_answers = {}
|
||||
variables = q[:answers].map{|a| a[:blank_id] }.uniq
|
||||
variables.each do |variable|
|
||||
variable_id = AssessmentQuestion.variable_id(variable)
|
||||
response = params["question_#{q[:id]}_#{variable_id}"]
|
||||
response ||= ""
|
||||
chosen_answer = q[:answers].detect{|answer| answer[:blank_id] == variable && (answer[:text] || "").strip.downcase == response.strip.downcase }
|
||||
chosen_answers[variable] = chosen_answer || {:text => response, :weight => 0}
|
||||
end
|
||||
answer_tally = 0
|
||||
chosen_answers.each do |variable, answer|
|
||||
answer_tally += 1 if answer[:weight] == 100 && !variables.empty?
|
||||
user_answer["answer_for_#{variable}".to_sym] = answer[:text] rescue nil
|
||||
user_answer["answer_id_for_#{variable}".to_sym] = answer[:id] rescue nil
|
||||
end
|
||||
if !variables.empty?
|
||||
user_answer[:points] = (answer_tally / variables.length.to_f) * q[:points_possible].to_f
|
||||
end
|
||||
user_answer[:correct] = true if answer_tally == variables.length.to_i
|
||||
user_answer[:correct] = "partial" if answer_tally > 0 && answer_tally < variables.length.to_i
|
||||
else
|
||||
end
|
||||
user_answer[:points] = 0.0 unless user_answer[:correct]
|
||||
user_answer[:points] = (user_answer[:points] * 100.0).round.to_f / 100.0
|
||||
user_answer
|
||||
qq = QuizQuestion::Base.from_question_data(q)
|
||||
user_answer = qq.score_question(params)
|
||||
result = {
|
||||
:correct => user_answer.correctness,
|
||||
:points => user_answer.score,
|
||||
:question_id => user_answer.question_id,
|
||||
}
|
||||
result[:answer_id] = user_answer.answer_id if user_answer.answer_id
|
||||
result.merge!(user_answer.answer_details)
|
||||
return result
|
||||
end
|
||||
|
||||
|
||||
named_scope :before, lambda{|date|
|
||||
{:conditions => ['quiz_submissions.created_at < ?', date]}
|
||||
}
|
||||
|
|
|
@ -541,9 +541,10 @@ describe QuizSubmission do
|
|||
|
||||
it "should score an essay_question" do
|
||||
qd = essay_question_data
|
||||
text = "that's too <b>dang</b> hard!"
|
||||
text = "that's too <b>dang</b> hard! <script>alert(1)</script>"
|
||||
sanitized = "that's too <b>dang</b> hard! alert(1)"
|
||||
QuizSubmission.score_question(qd, { "question_1" => text }).should ==
|
||||
{ :question_id => 1, :correct => "undefined", :points => 0, :text => text }
|
||||
{ :question_id => 1, :correct => "undefined", :points => 0, :text => sanitized }
|
||||
|
||||
QuizSubmission.score_question(qd, {}).should ==
|
||||
{ :question_id => 1, :correct => "undefined", :points => 0, :text => "" }
|
||||
|
@ -620,10 +621,8 @@ describe QuizSubmission do
|
|||
"question_1_answer_7398" => "6068",
|
||||
"question_1_answer_7399" => "6069",
|
||||
})
|
||||
correct = user_answer.delete(:correct)
|
||||
points = user_answer.delete(:points)
|
||||
user_answer.should == {
|
||||
:question_id => 1, :text => "",
|
||||
:question_id => 1, :correct => true, :points => 50, :text => "",
|
||||
:answer_7396 => "6061",
|
||||
:answer_6081 => "3855",
|
||||
:answer_4224 => "1397",
|
||||
|
@ -631,10 +630,6 @@ describe QuizSubmission do
|
|||
:answer_7398 => "6068",
|
||||
:answer_7399 => "6069",
|
||||
}
|
||||
pending("floating point error") do
|
||||
correct.should == true
|
||||
points.should == 50.0
|
||||
end
|
||||
|
||||
# selected a different answer but the text of that answer was the same
|
||||
user_answer = QuizSubmission.score_question(q, {
|
||||
|
@ -645,10 +640,8 @@ describe QuizSubmission do
|
|||
"question_1_answer_7398" => "6068",
|
||||
"question_1_answer_7399" => "6069",
|
||||
})
|
||||
correct = user_answer.delete(:correct)
|
||||
points = user_answer.delete(:points)
|
||||
user_answer.should == {
|
||||
:question_id => 1, :text => "",
|
||||
:question_id => 1, :correct => true, :points => 50, :text => "",
|
||||
:answer_7396 => "6061",
|
||||
:answer_6081 => "3855",
|
||||
:answer_4224 => "1397",
|
||||
|
@ -656,23 +649,17 @@ describe QuizSubmission do
|
|||
:answer_7398 => "6068",
|
||||
:answer_7399 => "6069",
|
||||
}
|
||||
pending("floating point error") do
|
||||
correct.should == true
|
||||
points.should == 50.0
|
||||
end
|
||||
|
||||
# undefined
|
||||
pending("don't treat no answer as the blank string, breaking undefined_if_blank") do
|
||||
QuizSubmission.score_question(q, { "undefined_if_blank" => "1" }).should == {
|
||||
:question_id => 1, :correct => "undefined", :points => 0, :text => "",
|
||||
:answer_7396 => "",
|
||||
:answer_6081 => "",
|
||||
:answer_4224 => "",
|
||||
:answer_7397 => "",
|
||||
:answer_7398 => "",
|
||||
:answer_7399 => "",
|
||||
}
|
||||
end
|
||||
# no answer shouldn't be treated as a blank string, breaking undefined_if_blank
|
||||
QuizSubmission.score_question(q, { "undefined_if_blank" => "1" }).should == {
|
||||
:question_id => 1, :correct => "undefined", :points => 0, :text => "",
|
||||
:answer_7396 => "",
|
||||
:answer_6081 => "",
|
||||
:answer_4224 => "",
|
||||
:answer_7397 => "",
|
||||
:answer_7398 => "",
|
||||
:answer_7399 => "",
|
||||
}
|
||||
end
|
||||
|
||||
it "should score a numerical_question" do
|
||||
|
@ -707,12 +694,11 @@ describe QuizSubmission do
|
|||
QuizSubmission.score_question(qd, { :undefined_if_blank => "1" }).should == {
|
||||
:question_id => 1, :correct => "undefined", :points => 0, :text => "" }
|
||||
|
||||
pending("don't treat a blank answer as 0.0") do
|
||||
qd2 = qd.dup
|
||||
qd2["answers"] << { "exact" => 0, "numerical_answer_type" => "exact_answer", "margin" => 0, "weight" => 100, "id" => 1234 }
|
||||
QuizSubmission.score_question(qd2, {}).should == {
|
||||
:question_id => 1, :correct => false, :points => 0, :text => "" }
|
||||
end
|
||||
# blank answer should not be treated as 0.0
|
||||
qd2 = qd.dup
|
||||
qd2["answers"] << { "exact" => 0, "numerical_answer_type" => "exact_answer", "margin" => 0, "weight" => 100, "id" => 1234 }
|
||||
QuizSubmission.score_question(qd2, { "question_1" => "" }).should == {
|
||||
:question_id => 1, :correct => false, :points => 0, :text => "" }
|
||||
end
|
||||
|
||||
it "should score a calculated_question" do
|
||||
|
@ -725,15 +711,13 @@ describe QuizSubmission do
|
|||
:question_id => 1, :correct => true, :points => 26.2, :text => "-11.68", :answer_id => 6396 }
|
||||
|
||||
QuizSubmission.score_question(qd, { "question_1" => "-11.675" }).should == {
|
||||
:question_id => 1, :correct => false, :points => 0, :text => "-11.675", :answer_id => 6396 }
|
||||
:question_id => 1, :correct => false, :points => 0, :text => "-11.675" }
|
||||
|
||||
QuizSubmission.score_question(qd, {}).should == {
|
||||
:question_id => 1, :correct => false, :points => 0, :text => "", :answer_id => 6396 }
|
||||
:question_id => 1, :correct => false, :points => 0, :text => "" }
|
||||
|
||||
pending("floating point error") do
|
||||
QuizSubmission.score_question(qd, { "question_1" => "-11.72" }).should == {
|
||||
:question_id => 1, :correct => true, :points => 26.2, :text => "-11.72", :answer_id => 6396 }
|
||||
end
|
||||
QuizSubmission.score_question(qd, { "question_1" => "-11.72" }).should == {
|
||||
:question_id => 1, :correct => true, :points => 26.2, :text => "-11.72", :answer_id => 6396 }
|
||||
end
|
||||
|
||||
it "should score a multiple_answers_question" do
|
||||
|
@ -850,9 +834,8 @@ describe QuizSubmission do
|
|||
"question_1_answer_9701" => "0",
|
||||
"question_1_answer_7381" => "1",
|
||||
})
|
||||
user_answer.delete(:points).should == 0
|
||||
user_answer.should == {
|
||||
:question_id => 1, :correct => false, :text => "",
|
||||
:question_id => 1, :correct => false, :points => 0, :text => "",
|
||||
:answer_9761 => "0",
|
||||
:answer_3079 => "1",
|
||||
:answer_5194 => "0",
|
||||
|
@ -891,10 +874,8 @@ describe QuizSubmission do
|
|||
:answer_7381 => "0",
|
||||
}
|
||||
|
||||
pending("don't treat no answer as the blank string, breaking undefined_if_blank") do
|
||||
QuizSubmission.score_question(qd, { "undefined_if_blank" => "1" }).should ==
|
||||
{ :question_id => 1, :correct => "undefined", :points => 0, :text => "" }
|
||||
end
|
||||
QuizSubmission.score_question(qd, { "undefined_if_blank" => "1" }).should ==
|
||||
{ :question_id => 1, :correct => "undefined", :points => 0, :text => "" }
|
||||
end
|
||||
|
||||
it "should score a multiple_dropdowns_question" do
|
||||
|
@ -948,10 +929,8 @@ describe QuizSubmission do
|
|||
}
|
||||
|
||||
user_answer = QuizSubmission.score_question(q, { "question_1630873_4e6185159bea49c4d29047379b400ad5"=>"6994", "question_1630873_3f507e80e33ef092a02948a064433ec5"=>"7676", "question_1630873_78635a3709b540a59678c806b102d038"=>"9908", "question_1630873_657b11f1c17376f178c4d80c4c25d0ab"=>"1121", "question_1630873_02c8346333761ffe9bbddee7b1c5a537"=>"4390", "question_1630873_1865cbc77c83d7571ed8b3a108d11d3d"=>"7604", "question_1630873_94239fc44b4f8aaf36bd3596768f4816"=>"6955", "question_1630873_cd073d17d0d9558fb2be7d7bf9a1c840"=>"3353", "question_1630873_69d0969351d989767d7096f28daf7461"=>"3390"})
|
||||
correct = user_answer.delete(:correct)
|
||||
points = user_answer.delete(:points)
|
||||
user_answer.should == {
|
||||
:question_id => 1630873, :text => "",
|
||||
:question_id => 1630873, :correct => true, :points => 0.5, :text => "",
|
||||
:answer_for_structure1 => 4390,
|
||||
:answer_id_for_structure1 => 4390,
|
||||
:answer_for_event1 => 3390,
|
||||
|
@ -971,11 +950,6 @@ describe QuizSubmission do
|
|||
:answer_for_structure7 => 1121,
|
||||
:answer_id_for_structure7 => 1121,
|
||||
}
|
||||
|
||||
pending("floating point error") do
|
||||
correct.should == true
|
||||
points.should == 0.5
|
||||
end
|
||||
end
|
||||
|
||||
it "should score a fill_in_multiple_blanks_question" do
|
||||
|
|
Loading…
Reference in New Issue