diff --git a/app/models/quiz_question/base.rb b/app/models/quiz_question/base.rb new file mode 100644 index 00000000000..015aad0f938 --- /dev/null +++ b/app/models/quiz_question/base.rb @@ -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 . +# + +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 } diff --git a/app/models/quiz_question/calculated_question.rb b/app/models/quiz_question/calculated_question.rb new file mode 100644 index 00000000000..a850d331bcf --- /dev/null +++ b/app/models/quiz_question/calculated_question.rb @@ -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 . +# + +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 diff --git a/app/models/quiz_question/essay_question.rb b/app/models/quiz_question/essay_question.rb new file mode 100644 index 00000000000..9f1efcb0c0d --- /dev/null +++ b/app/models/quiz_question/essay_question.rb @@ -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 . +# + +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 diff --git a/app/models/quiz_question/fill_in_multiple_blanks_question.rb b/app/models/quiz_question/fill_in_multiple_blanks_question.rb new file mode 100644 index 00000000000..b10adc9e5cc --- /dev/null +++ b/app/models/quiz_question/fill_in_multiple_blanks_question.rb @@ -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 . +# + +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 diff --git a/app/models/quiz_question/matching_question.rb b/app/models/quiz_question/matching_question.rb new file mode 100644 index 00000000000..03917bd4c5c --- /dev/null +++ b/app/models/quiz_question/matching_question.rb @@ -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 . +# + +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 diff --git a/app/models/quiz_question/multiple_answers_question.rb b/app/models/quiz_question/multiple_answers_question.rb new file mode 100644 index 00000000000..dbda6a5e1e8 --- /dev/null +++ b/app/models/quiz_question/multiple_answers_question.rb @@ -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 . +# + +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 diff --git a/app/models/quiz_question/multiple_choice_question.rb b/app/models/quiz_question/multiple_choice_question.rb new file mode 100644 index 00000000000..6dc84d182ac --- /dev/null +++ b/app/models/quiz_question/multiple_choice_question.rb @@ -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 . +# + +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 diff --git a/app/models/quiz_question/multiple_dropdowns_question.rb b/app/models/quiz_question/multiple_dropdowns_question.rb new file mode 100644 index 00000000000..7ad16b90485 --- /dev/null +++ b/app/models/quiz_question/multiple_dropdowns_question.rb @@ -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 . +# + +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 diff --git a/app/models/quiz_question/numerical_question.rb b/app/models/quiz_question/numerical_question.rb new file mode 100644 index 00000000000..9652991e3c3 --- /dev/null +++ b/app/models/quiz_question/numerical_question.rb @@ -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 . +# + +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 diff --git a/app/models/quiz_question/short_answer_question.rb b/app/models/quiz_question/short_answer_question.rb new file mode 100644 index 00000000000..3e1dc3fdb50 --- /dev/null +++ b/app/models/quiz_question/short_answer_question.rb @@ -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 . +# + +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 diff --git a/app/models/quiz_question/text_only_question.rb b/app/models/quiz_question/text_only_question.rb new file mode 100644 index 00000000000..6fa28bc2af7 --- /dev/null +++ b/app/models/quiz_question/text_only_question.rb @@ -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 . +# + +class QuizQuestion::TextOnlyQuestion < QuizQuestion::Base + def total_answer_parts + 0 + end +end diff --git a/app/models/quiz_question/user_answer.rb b/app/models/quiz_question/user_answer.rb new file mode 100644 index 00000000000..88d4ec6c27d --- /dev/null +++ b/app/models/quiz_question/user_answer.rb @@ -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 . +# + +# 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 diff --git a/app/models/quiz_submission.rb b/app/models/quiz_submission.rb index 4b3b68d1651..51f9feecb13 100644 --- a/app/models/quiz_submission.rb +++ b/app/models/quiz_submission.rb @@ -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]} } diff --git a/spec/models/quiz_submission_spec.rb b/spec/models/quiz_submission_spec.rb index 9c82985db21..f1526b7f760 100644 --- a/spec/models/quiz_submission_spec.rb +++ b/spec/models/quiz_submission_spec.rb @@ -541,9 +541,10 @@ describe QuizSubmission do it "should score an essay_question" do qd = essay_question_data - text = "that's too dang hard!" + text = "that's too dang hard! " + sanitized = "that's too dang 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