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:
Brian Palmer 2012-04-24 11:27:29 -06:00
parent ea2e704904
commit 59d6a9af8d
14 changed files with 636 additions and 226 deletions

View File

@ -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 }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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]}
}

View File

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