diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 000000000..a5c50ffdb Binary files /dev/null and b/.DS_Store differ diff --git a/Gemfile.lock b/Gemfile.lock index cb84960a2..c797d0262 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -246,6 +246,7 @@ GEM PLATFORMS aarch64-linux arm64-darwin-22 + arm64-darwin-23 x64-mingw-ucrt x86_64-linux diff --git a/app/.DS_Store b/app/.DS_Store new file mode 100644 index 000000000..b1329529e Binary files /dev/null and b/app/.DS_Store differ diff --git a/app/controllers/.DS_Store b/app/controllers/.DS_Store new file mode 100644 index 000000000..ac620303f Binary files /dev/null and b/app/controllers/.DS_Store differ diff --git a/app/controllers/api/.DS_Store b/app/controllers/api/.DS_Store new file mode 100644 index 000000000..2e4a36743 Binary files /dev/null and b/app/controllers/api/.DS_Store differ diff --git a/app/controllers/api/v1/questions_controller.rb b/app/controllers/api/v1/questions_controller.rb index de8ccf0e1..0f08e51bd 100644 --- a/app/controllers/api/v1/questions_controller.rb +++ b/app/controllers/api/v1/questions_controller.rb @@ -1,131 +1,97 @@ class Api::V1::QuestionsController < ApplicationController + before_action :set_question, only: [:show, :update] + + # GET /questions def action_allowed? has_role?('Instructor') end # Index method returns the list of questions JSON object # GET on /questions def index - @questions = Question.order(:id) + @questions = Item.order(:id) render json: @questions, status: :ok end - # Show method returns the question object with id - {:id} - # GET on /questions/:id + # GET /questions/:id def show begin - @question = Question.find(params[:id]) - render json: @question, status: :ok - rescue ActiveRecord::RecordNotFound - render json: $ERROR_INFO.to_s, status: :not_found - end - end + @item = Item.find(params[:id]) - # Create method returns the JSON object of created question - # POST on /questions - def create - questionnaire_id = params[:questionnaire_id] - questionnaire = Questionnaire.find(questionnaire_id) - question = questionnaire.questions.build( - txt: params[:txt], - question_type: params[:question_type], - break_before: true - ) + # Choose the correct strategy based on item type + strategy = get_strategy_for_item(@item) - case question.question_type - when 'Scale' - question.weight = params[:weight] - question.max_label = 'Strongly agree' - question.min_label = 'Strongly disagree' - when 'Cake', 'Criterion' - question.weight = params[:weight] - question.max_label = 'Strongly agree' - question.min_label = 'Strongly disagree' - question.size = '50, 3' - when 'Dropdown' - question.alternatives = '0|1|2|3|4|5' - when 'TextArea' - question.size = '60, 5' - when 'TextField' - question.size = '30' - end + # Render the item using the strategy + @rendered_item = strategy.render(@item) - if question.save - render json: question, status: :created - else - render json: question.errors.full_messages.to_sentence, status: :unprocessable_entity - end -rescue ActiveRecord::RecordNotFound - render json: $ERROR_INFO.to_s, status: :not_found and return -end - - - # Destroy method deletes the question with id - {:id} - # DELETE on /questions/:id - def destroy - begin - @question = Question.find(params[:id]) - @question.destroy + render json: { item: @item, rendered_item: @rendered_item }, status: :ok rescue ActiveRecord::RecordNotFound - render json: $ERROR_INFO.to_s, status: :not_found and return + render json: { error: "Question not found" }, status: :not_found end end - # show_all method returns all questions associated to a questionnaire with id - {:id} - # GET on /questions/show_all/questionnaire/:id - def show_all - begin - @questionnaire = Questionnaire.find(params[:id]) - @questions = @questionnaire.questions - render json: @questions, status: :ok - rescue ActiveRecord::RecordNotFound - render json: $ERROR_INFO.to_s, status: :not_found + # POST /questions + def create + questionnaire_id = params[:questionnaire_id] + questionnaire = Questionnaire.find(questionnaire_id) + + # Create the new Item (item) + item = questionnaire.items.build( + txt: params[:txt], + question_type: params[:question_type], + seq: params[:seq], + break_before: true + ) + + # Add attributes based on the item type + case item.question_type + when 'Scale' + item.weight = params[:weight] + item.max_label = 'Strongly agree' + item.min_label = 'Strongly disagree' + item.max_value = params[:max_value] || 5 + when 'Dropdown' + item.alternatives = '0|1|2|3|4|5' + when 'TextArea' + item.size = '60, 5' + when 'TextField' + item.size = '30' end - end - # Delete_all method deletes all the questions and returns the status code - # DELETE on /questions/delete_all/questionnaire/ - # Endpoint to delete all questions associated to a particular questionnaire. - - def delete_all - begin - questionnaire = Questionnaire.find(params[:id]) - if questionnaire.questions.empty? - render json: "No questions associated with questionnaire ID #{params[:id]}.", status: :unprocessable_entity and return - end - - questionnaire.questions.destroy_all - render json: "All questions for questionnaire ID #{params[:id]} have been deleted.", status: :ok - rescue ActiveRecord::RecordNotFound - render json: "Questionnaire ID #{params[:id]} not found.", status: :not_found and return + if item.save + render json: item, status: :created + else + render json: { error: item.errors.full_messages.to_sentence }, status: :unprocessable_entity end end - # Update method updates the question with id - {:id} and returns its JSON object - # PUT on /questions/:id + # PUT /questions/:id def update - begin - @question = Question.find(params[:id]) - if @question.update(question_params) - render json: @question, status: :ok and return - else - render json: "Failed to update the question.", status: :unprocessable_entity and return - end - rescue ActiveRecord::RecordNotFound - render json: $ERROR_INFO.to_s, status: :not_found and return + if @item.update(question_params) + render json: @item, status: :ok + else + render json: { error: @item.errors.full_messages.to_sentence }, status: :unprocessable_entity end end + + private - # GET on /questions/types - def types - types = Question.distinct.pluck(:question_type) - render json: types.to_a, status: :ok + def set_question + @item = Item.find(params[:id]) end - private - - # Only allow a list of trusted parameters through. def question_params - params.permit(:txt, :weight, :seq, :questionnaire_id, :question_type, :size, - :alternatives, :break_before, :max_label, :min_label) + params.require(:question).permit(:txt, :question_type, :seq, :weight, :max_value, :size, :alternatives) + end + + def get_strategy_for_item(item) + case item.question_type + when 'Dropdown' + Strategies::DropdownStrategy.new + when 'Scale' + Strategies::ScaleStrategy.new + # You can add more strategies as needed + else + raise "Strategy for this item type not defined" + end end end diff --git a/app/helpers/question_helper.rb b/app/helpers/question_helper.rb new file mode 100644 index 000000000..1d9f88b96 --- /dev/null +++ b/app/helpers/question_helper.rb @@ -0,0 +1,19 @@ +module QuestionHelper + def edit_common(label = nil,min_question_score = nil, max_question_score = nil, txt ,weight, type) + { + form: true, + label: label, + input_type: 'text', + input_name: 'item', + input_value: txt, + min_question_score: min_question_score, + max_question_score: max_question_score, + weight: weight, + type: type + } + end + + def view_item_text_common(text, type, weight, score_range) + { text: text, type: type, weight: weight, score_range: score_range } + end + end \ No newline at end of file diff --git a/app/helpers/scorable_helper.rb b/app/helpers/scorable_helper.rb index 7c8662d03..deac24055 100644 --- a/app/helpers/scorable_helper.rb +++ b/app/helpers/scorable_helper.rb @@ -11,11 +11,11 @@ def calculate_total_score question_ids = scores.map(&:question_id) # We use find with order here to ensure that the list of questions we get is in the same order as that of question_ids - questions = Question.find_with_order(question_ids) + questions = Item.find_with_order(question_ids) scores.each_with_index do |score, idx| - question = questions[idx] - sum += score.answer * questions[idx].weight if !score.answer.nil? && question.scorable? + item = questions[idx] + sum += score.answer * questions[idx].weight if !score.answer.nil? && item.scorable? end sum @@ -38,7 +38,7 @@ def maximum_score question_ids = scores.map(&:question_id) # We use find with order here to ensure that the list of questions we get is in the same order as that of question_ids - questions = Question.find_with_order(question_ids) + questions = Item.find_with_order(question_ids) scores.each_with_index do |score, idx| total_weight += questions[idx].weight unless score.answer.nil? || !questions[idx].scorable? @@ -54,13 +54,13 @@ def maximum_score def questionnaire_by_answer(answer) if answer.nil? - # Answers can be nil in cases such as "Upload File" being the only question. + # Answers can be nil in cases such as "Upload File" being the only item. map = ResponseMap.find(map_id) # E-1973 either get the assignment from the participant or the map itself assignment = map.response_assignment questionnaire = Questionnaire.find(assignment.review_questionnaire_id) else - questionnaire = Question.find(answer.question_id).questionnaire + questionnaire = Item.find(answer.question_id).questionnaire end questionnaire end diff --git a/app/models/Item.rb b/app/models/Item.rb new file mode 100644 index 000000000..901f6cadc --- /dev/null +++ b/app/models/Item.rb @@ -0,0 +1,53 @@ +class Item < ApplicationRecord + before_create :set_seq + belongs_to :questionnaire # each item belongs to a specific questionnaire + has_many :answers, dependent: :destroy + has_many :choices, dependent: :destroy + attr_accessor :choice_strategy + + validates :seq, presence: true, numericality: true # sequence must be numeric + validates :txt, length: { minimum: 0, allow_nil: false, message: "can't be nil" } # text content must be provided + validates :question_type, presence: true # user must define the item type + validates :break_before, presence: true + + def scorable? + false + end + + def set_seq + self.seq = questionnaire.items.size + 1 + end + + def as_json(options = {}) + super(options.merge({ + only: %i[txt weight seq question_type size alternatives break_before min_label max_label created_at updated_at], + include: { + questionnaire: { only: %i[name id] } + } + })).tap do |hash| + end + end + + def strategy + case question_type + when 'dropdown' + self.choice_strategy = Strategies::DropdownStrategy.new + when 'multiple_choice' + self.choice_strategy = Strategies::MultipleChoiceStrategy.new + when 'scale' + self.choice_strategy = Strategies::ScaleStrategy.new + else + raise "Unknown item type: #{question_type}" + end + end + + # Use strategy to render the item + def render + strategy.render(self) + end + + # Use strategy to validate the item + def validate_item + strategy.validate(self) + end +end \ No newline at end of file diff --git a/app/models/Strategies/choice_strategy.rb b/app/models/Strategies/choice_strategy.rb new file mode 100644 index 000000000..2eba17c28 --- /dev/null +++ b/app/models/Strategies/choice_strategy.rb @@ -0,0 +1,12 @@ +module Strategies + class ChoiceStrategy + def render(item) + raise NotImplementedError, "You must implement the render method" + end + + def validate(item) + raise NotImplementedError, "You must implement the validate method" + end + end +end + \ No newline at end of file diff --git a/app/models/Strategies/dropdown_strategy.rb b/app/models/Strategies/dropdown_strategy.rb new file mode 100644 index 000000000..9b622031d --- /dev/null +++ b/app/models/Strategies/dropdown_strategy.rb @@ -0,0 +1,15 @@ +module Strategies + class DropdownStrategy < ChoiceStrategy + def render(item) + # render the dropdown options as HTML + item.alternatives.map { |alt| "" }.join + end + + def validate(item) + # Validate that alternatives are non-empty + if item.alternatives.empty? + item.errors.add(:alternatives, "can't be empty for a dropdown") + end + end + end +end \ No newline at end of file diff --git a/app/models/Strategies/multiple_choice_strategy.rb b/app/models/Strategies/multiple_choice_strategy.rb new file mode 100644 index 000000000..9f9bec1a7 --- /dev/null +++ b/app/models/Strategies/multiple_choice_strategy.rb @@ -0,0 +1,15 @@ +module Strategies + class MultipleChoiceStrategy < ChoiceStrategy + def render(item) + # Render radio buttons for multiple choice + item.alternatives.map { |alt| " #{alt}" }.join + end + + def validate(item) + # Validate that alternatives are non-empty + if item.alternatives.empty? + item.errors.add(:alternatives, "can't be empty for multiple choice") + end + end + end +end \ No newline at end of file diff --git a/app/models/Strategies/scale_strategy.rb b/app/models/Strategies/scale_strategy.rb new file mode 100644 index 000000000..2848ef5a9 --- /dev/null +++ b/app/models/Strategies/scale_strategy.rb @@ -0,0 +1,15 @@ +module Strategies + class ScaleStrategy < ChoiceStrategy + def render(item) + # Render scale (numeric sequence of options) + item.alternatives.map { |alt| "" }.join + end + + def validate(item) + # Validate that alternatives are numeric + unless item.alternatives.all? { |alt| alt.match?(/^\d+$/) } + item.errors.add(:alternatives, "must be numeric for scale items") + end + end + end +end \ No newline at end of file diff --git a/app/models/answer.rb b/app/models/answer.rb index 6b063ed8c..2b55378fd 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -1,4 +1,4 @@ class Answer < ApplicationRecord belongs_to :response - belongs_to :question + belongs_to :item end diff --git a/app/models/checkbox.rb b/app/models/checkbox.rb new file mode 100644 index 000000000..35e79239c --- /dev/null +++ b/app/models/checkbox.rb @@ -0,0 +1,172 @@ +class Checkbox < UnscoredItem + def edit(count) + { + remove_button: edit_remove_button(count), + seq: edit_seq(count), + item: edit_question(count), + type: edit_type(count), + weight: edit_weight(count) + } + end + + def edit_remove_button(count) + { + type: 'remove_button', + action: 'delete', + href: "/questions/#{id}", + text: 'Remove' + } + end + + def edit_seq(count) + { + type: 'seq', + input_size: 6, + value: seq, + name: "item[#{id}][seq]", + id: "question_#{id}_seq" + } + end + + def edit_question(count) + { + type: 'textarea', + cols: 50, + rows: 1, + name: "item[#{id}][txt]", + id: "question_#{id}_txt", + placeholder: 'Edit item content here', + content: txt + } + end + + def edit_type(count) + { + type: 'text', + input_size: 10, + disabled: true, + value: question_type, + name: "item[#{id}][type]", + id: "question_#{id}_type" + } + end + + def edit_weight(count) + { + type: 'weight', + placeholder: 'UnscoredItem does not need weight' + } + end + + + def view_item_text + { + content: txt, + type: question_type, + weight: weight.to_s, + checked_state: 'Checked/Unchecked' + } + end + + def complete(count, answer = nil) + { + previous_question: check_previous_question, + inputs: [ + complete_first_second_input(count, answer), + complete_third_input(count, answer) + ], + label: { + for: "responses_#{count}", + text: txt + }, + script: complete_script(count), + if_column_header: complete_if_column_header + } + end + + def check_previous_question + prev_question = Item.where('seq < ?', seq).order(:seq).last + { + type: prev_question&.type == 'ColumnHeader' ? 'ColumnHeader' : 'other' + } + end + + def complete_first_second_input(count, answer = nil) + [ + { + id: "responses_#{count}_comments", + name: "responses[#{count}][comment]", + type: 'hidden', + value: '' + }, + { + id: "responses_#{count}_score", + name: "responses[#{count}][score]", + type: 'hidden', + value: answer&.answer == 1 ? '1' : '0' + } + ] + end + + def complete_third_input(count, answer = nil) + { + id: "responses_#{count}_checkbox", + type: 'checkbox', + onchange: "checkbox#{count}Changed()", + checked: answer&.answer == 1 + } + end + + def complete_script(count) + "function checkbox#{count}Changed() { var checkbox = jQuery('#responses_#{count}_checkbox'); var response_score = jQuery('#responses_#{count}_score'); if (checkbox.is(':checked')) { response_score.val('1'); } else { response_score.val('0'); }}" + end + + def complete_if_column_header + next_question = Item.where('seq > ?', seq).order(:seq).first + if next_question + case next_question.question_type + when 'ColumnHeader' + 'end_of_column_header' + when 'SectionHeader', 'TableHeader' + 'end_of_section_or_table' + else + 'continue' + end + else + 'end' + end + end + + def view_completed_item(count, answer) + { + previous_question: check_previous_question, + answer: view_completed_item_answer(count, answer), + if_column_header: view_completed_item_if_column_header + } + end + + def view_completed_item_answer(count, answer) + { + number: count, + image: answer.answer == 1 ? 'Check-icon.png' : 'delete_icon.png', + content: txt, + bold: true + } + end + + def view_completed_item_if_column_header + next_question = Item.where('seq > ?', seq).order(:seq).first + if next_question + case next_question.question_type + when 'ColumnHeader' + 'end_of_column_header' + when 'TableHeader' + 'end_of_table_header' + else + 'continue' + end + else + 'end' + end + end + end \ No newline at end of file diff --git a/app/models/choice_question.rb b/app/models/choice_item.rb similarity index 68% rename from app/models/choice_question.rb rename to app/models/choice_item.rb index b94c88781..d9b7b8853 100644 --- a/app/models/choice_question.rb +++ b/app/models/choice_item.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ChoiceQuestion < Question +class ChoiceItem < Item def scorable? false end diff --git a/app/models/criterion.rb b/app/models/criterion.rb new file mode 100644 index 000000000..560839026 --- /dev/null +++ b/app/models/criterion.rb @@ -0,0 +1,81 @@ +class Criterion < ScoredItem + validates :size, presence: true + + def edit + { + remove_link: "/questions/#{id}", + sequence_input: seq.to_s, + question_text: txt, + question_type: question_type, + weight: weight.to_s, + size: size.to_s, + max_label: max_label, + min_label: min_label + } + end + + def view_item_text + question_data = { + text: txt, + question_type: question_type, + weight: weight, + score_range: "#{questionnaire.min_question_score} to #{questionnaire.max_question_score}" + } + + question_data[:score_range] = "(#{min_label}) " + question_data[:score_range] + " (#{max_label})" if max_label && min_label + question_data + end + + def complete(count,answer = nil, questionnaire_min, questionnaire_max, dropdown_or_scale) + question_advices = QuestionAdvice.to_json_by_question_id(id) + advice_total_length = question_advices.sum { |advice| advice.advice.length unless advice.advice.blank? } + + response_options = if dropdown_or_scale == 'dropdown' + dropdown_criterion_question(count, answer, questionnaire_min, questionnaire_max) + elsif dropdown_or_scale == 'scale' + scale_criterion_question(count, answer, questionnaire_min, questionnaire_max) + end + + advice_section = question_advices.empty? || advice_total_length.zero? ? nil : advices_criterion_question(count, question_advices) + + { + label: txt, + advice: advice_section, + response_options: response_options + }.compact # Use .compact to remove nil values + end + + # Assuming now these methods should be public based on the test cases + def dropdown_criterion_question(count,answer, questionnaire_min, questionnaire_max) + options = (questionnaire_min..questionnaire_max).map do |score| + option = { value: score, label: score.to_s } + option[:selected] = 'selected' if answer && score == answer.answer + option + end + { type: 'dropdown', options: options, current_answer: answer.try(:answer), comments: answer.try(:comments) } + end + + def scale_criterion_question(count,answer, questionnaire_min, questionnaire_max) + { + type: 'scale', + min: questionnaire_min, + max: questionnaire_max, + current_answer: answer.try(:answer), + comments: answer.try(:comments), + min_label: min_label, + max_label: max_label, + size: size + } + end + + private + + def advices_criterion_question(question_advices) + question_advices.map do |advice| + { + score: advice.score, + advice: advice.advice + } + end + end + end \ No newline at end of file diff --git a/app/models/dropdown.rb b/app/models/dropdown.rb new file mode 100644 index 000000000..c780a6b8c --- /dev/null +++ b/app/models/dropdown.rb @@ -0,0 +1,26 @@ +class Dropdown < UnscoredItem + include QuestionHelper + + attr_accessor :txt, :type, :count, :weight + def edit(count) + edit_common("Item #{count}:", txt , weight, type).to_json + end + + def view_item_text + view_item_text_common(txt, type, weight, 'N/A').to_json + end + + def complete(count, answer = nil) + options = (1..count).map { |option| { value: option, selected: (option == answer.to_i) } } + { dropdown_options: options }.to_json + end + + def complete_for_alternatives(alternatives, answer) + options = alternatives.map { |alt| { value: alt, selected: (alt == answer) } } + { dropdown_options: options }.to_json + end + + def view_completed_item + { selected_option: (count && answer) ? "#{answer} (out of #{count})" : 'Item not answered.' }.to_json + end + end \ No newline at end of file diff --git a/app/models/file_upload.rb b/app/models/file_upload.rb new file mode 100644 index 000000000..10b6ea742 --- /dev/null +++ b/app/models/file_upload.rb @@ -0,0 +1,72 @@ +# app/models/file_upload.rb +class FileUpload < Item + def edit(_count) + { + action: 'edit', + elements: [ + { + type: 'link', + text: 'Remove', + href: "/questions/#{id}", + method: 'delete' + }, + { + type: 'input', + input_type: 'text', + name: "item[#{id}][seq]", + id: "question_#{id}_seq", + value: seq.to_s + }, + { + type: 'input', + input_type: 'text', + name: "item[#{id}][id]", + id: "question_#{id}", + value: id.to_s + }, + { + type: 'textarea', + cols: 50, + rows: 1, + name: "item[#{id}][txt]", + id: "question_#{id}_txt", + placeholder: 'Edit item content here', + value: txt + }, + { + type: 'input', + input_type: 'text', + size: 10, + name: "item[#{id}][question_type]", + id: "question_#{id}_question_type", + value: question_type, + disabled: true + } + ] + }.to_json + end + + def view_item_text + { + action: 'view_item_text', + elements: [ + { type: 'text', value: txt }, + { type: 'text', value: question_type }, + { type: 'text', value: weight.to_s }, + { type: 'text', value: id.to_s }, + { type: 'text', value: '—' } # Placeholder for non-applicable fields + ] + }.to_json + end + + + # Implement this method for completing a item + def complete(count, answer = nil) + # Implement the logic for completing a item + end + + # Implement this method for viewing a completed item by a student + def view_completed_item(count, files) + # Implement the logic for viewing a completed item by a student + end + end \ No newline at end of file diff --git a/app/models/multiple_choice_checkbox.rb b/app/models/multiple_choice_checkbox.rb new file mode 100644 index 000000000..5e1d81f1b --- /dev/null +++ b/app/models/multiple_choice_checkbox.rb @@ -0,0 +1,73 @@ +require 'json' + +class MultipleChoiceCheckbox < QuizItem + def edit + quiz_question_choices = QuizQuestionChoice.where(question_id: id) + + data = { + id: id, + question_text: txt, + weight: weight, + choices: quiz_question_choices.each_with_index.map do |choice, index| + { + id: choice.id, + text: choice.txt, + is_correct: choice.iscorrect, + position: index + 1 + } + end + } + + data.to_json + end + + def complete + quiz_question_choices = QuizQuestionChoice.where(question_id: id) + + data = { + id: id, + question_text: txt, + choices: quiz_question_choices.map do |choice| + { text: choice.txt } + end + } + + data.to_json + end + + def view_completed_item(user_answer) + quiz_question_choices = QuizQuestionChoice.where(question_id: id) + + data = { + question_choices: quiz_question_choices.map do |choice| + { + text: choice.txt, + is_correct: choice.iscorrect + } + end, + user_answers: user_answer.map do |answer| + { + is_correct: answer.answer == 1, + comments: answer.comments + } + end + } + + data.to_json + end + + def isvalid(choice_info) + error_message = nil + error_message = 'Please make sure all questions have text' if txt.blank? + + correct_count = choice_info.count { |_idx, value| value[:iscorrect] == '1' } + + if correct_count.zero? + error_message = 'Please select a correct answer for all questions' + elsif correct_count == 1 + error_message = 'A multiple-choice checkbox item should have more than one correct answer.' + end + + { valid: error_message.nil?, error: error_message }.to_json + end +end \ No newline at end of file diff --git a/app/models/multiple_choice_radio.rb b/app/models/multiple_choice_radio.rb new file mode 100644 index 000000000..807a1d583 --- /dev/null +++ b/app/models/multiple_choice_radio.rb @@ -0,0 +1,88 @@ +require 'json' + +class MultipleChoiceRadio < QuizItem + def edit + quiz_question_choices = QuizQuestionChoice.where(question_id: id) + + choices = quiz_question_choices.map.with_index(1) do |choice, index| + { + id: choice.id, + text: choice.txt, + is_correct: choice.iscorrect, + position: index + } + end + + { + id: id, + question_text: txt, + question_weight: weight, + choices: choices + }.to_json + end + + def complete + quiz_question_choices = QuizQuestionChoice.where(question_id: id) + + choices = quiz_question_choices.map.with_index(1) do |choice, index| + { + id: choice.id, + text: choice.txt, + position: index + } + end + + { + question_id: id, + question_text: txt, + choices: choices + }.to_json + end + + def view_completed_item(user_answer) + quiz_question_choices = QuizQuestionChoice.where(question_id: id) + + choices = quiz_question_choices.map do |choice| + { + text: choice.txt, + is_correct: choice.iscorrect + } + end + + user_response = { + answer: user_answer.first.comments, + is_correct: user_answer.first.answer == 1 + } + + { + question_text: txt, + choices: choices, + user_response: user_response + }.to_json + end + + def isvalid(choice_info) + valid = true + error_message = nil + + if txt.blank? + valid = false + error_message = 'Please make sure all questions have text' + elsif choice_info.values.any? { |choice| choice[:txt].blank? } + valid = false + error_message = 'Please make sure every item has text for all options' + end + + correct_count = choice_info.count { |_idx, choice| choice[:iscorrect] == '1' } + + if correct_count != 1 + valid = false + error_message = 'Please select exactly one correct answer for the item' + end + + { + valid: valid, + error: error_message + }.to_json + end +end \ No newline at end of file diff --git a/app/models/question.rb b/app/models/question.rb deleted file mode 100644 index 4dc76ae10..000000000 --- a/app/models/question.rb +++ /dev/null @@ -1,28 +0,0 @@ -class Question < ApplicationRecord - before_create :set_seq - belongs_to :questionnaire # each question belongs to a specific questionnaire - has_many :answers, dependent: :destroy - - validates :seq, presence: true, numericality: true # sequence must be numeric - validates :txt, length: { minimum: 0, allow_nil: false, message: "can't be nil" } # user must define text content for a question - validates :question_type, presence: true # user must define type for a question - validates :break_before, presence: true - - def scorable? - false - end - - def set_seq - self.seq = questionnaire.questions.size + 1 - end - - def as_json(options = {}) - super(options.merge({ - only: %i[txt weight seq question_type size alternatives break_before min_label max_label created_at updated_at], - include: { - questionnaire: { only: %i[name id] } - } - })).tap do |hash| - end - end -end diff --git a/app/models/question_advice.rb b/app/models/question_advice.rb new file mode 100644 index 000000000..ce04ade69 --- /dev/null +++ b/app/models/question_advice.rb @@ -0,0 +1,22 @@ +class QuestionAdvice < ApplicationRecord + belongs_to :item + def self.export_fields(_options) + QuestionAdvice.columns.map(&:name) + end + + def self.export(csv, parent_id, _options) + questionnaire = Questionnaire.find(parent_id) + questionnaire.items.each do |item| + QuestionAdvice.where(question_id: item.id).each do |advice| + csv << advice.attributes.values + end + end + end + + def self.to_json_by_question_id(question_id) + question_advices = QuestionAdvice.where(question_id: question_id).order(:id) + question_advices.map do |advice| + { score: advice.score, advice: advice.advice } + end + end + end \ No newline at end of file diff --git a/app/models/questionnaire.rb b/app/models/questionnaire.rb index 43a6ec81d..1e8ff0aa7 100644 --- a/app/models/questionnaire.rb +++ b/app/models/questionnaire.rb @@ -1,6 +1,6 @@ class Questionnaire < ApplicationRecord belongs_to :instructor - has_many :questions, dependent: :destroy # the collection of questions associated with this Questionnaire + has_many :items, class_name: "Item", foreign_key: "questionnaire_id", dependent: :destroy # the collection of questions associated with this Questionnaire before_destroy :check_for_question_associations validate :validate_questionnaire @@ -10,14 +10,14 @@ class Questionnaire < ApplicationRecord # clones the contents of a questionnaire, including the questions and associated advice def self.copy_questionnaire_details(params) orig_questionnaire = Questionnaire.find(params[:id]) - questions = Question.where(questionnaire_id: params[:id]) + questions = Item.where(questionnaire_id: params[:id]) questionnaire = orig_questionnaire.dup questionnaire.name = 'Copy of ' + orig_questionnaire.name questionnaire.created_at = Time.zone.now questionnaire.updated_at = Time.zone.now questionnaire.save! - questions.each do |question| - new_question = question.dup + questions.each do |item| + new_question = item.dup new_question.questionnaire_id = questionnaire.id new_question.save! end @@ -26,9 +26,9 @@ def self.copy_questionnaire_details(params) # validate the entries for this questionnaire def validate_questionnaire - errors.add(:max_question_score, 'The maximum question score must be a positive integer.') if max_question_score < 1 - errors.add(:min_question_score, 'The minimum question score must be a positive integer.') if min_question_score < 0 - errors.add(:min_question_score, 'The minimum question score must be less than the maximum.') if min_question_score >= max_question_score + errors.add(:max_question_score, 'The maximum item score must be a positive integer.') if max_question_score < 1 + errors.add(:min_question_score, 'The minimum item score must be a positive integer.') if min_question_score < 0 + errors.add(:min_question_score, 'The minimum item score must be less than the maximum.') if min_question_score >= max_question_score results = Questionnaire.where('id <> ? and name = ? and instructor_id = ?', id, name, instructor_id) errors.add(:name, 'Questionnaire names must be unique.') if results.present? end diff --git a/app/models/quiz_item.rb b/app/models/quiz_item.rb new file mode 100644 index 000000000..ed0a8b799 --- /dev/null +++ b/app/models/quiz_item.rb @@ -0,0 +1,30 @@ +require 'json' + +class QuizItem < Item + has_many :quiz_question_choices, class_name: 'QuizQuestionChoice', foreign_key: 'question_id', inverse_of: false, dependent: :nullify + + def edit + end + + def view_item_text + choices = quiz_question_choices.map do |choice| + { + text: choice.txt, + is_correct: choice.iscorrect? + } + end + + { + question_text: txt, + question_type: type, + question_weight: weight, + choices: choices + }.to_json + end + + def complete + end + + def view_completed_item(user_answer = nil) + end +end diff --git a/app/models/quiz_question_choice.rb b/app/models/quiz_question_choice.rb new file mode 100644 index 000000000..6101d26ca --- /dev/null +++ b/app/models/quiz_question_choice.rb @@ -0,0 +1,3 @@ +class QuizQuestionChoice < ApplicationRecord + belongs_to :item, dependent: :destroy + end \ No newline at end of file diff --git a/app/models/response.rb b/app/models/response.rb index c63bdb5fd..9e07fd79d 100644 --- a/app/models/response.rb +++ b/app/models/response.rb @@ -31,7 +31,7 @@ def reportable_difference? # calculates the average score of all other responses average_score = total / count - # This score has already skipped the unfilled scorable question(s) + # This score has already skipped the unfilled scorable item(s) score = aggregate_questionnaire_score.to_f / maximum_score questionnaire = questionnaire_by_answer(scores.first) assignment = map.assignment @@ -50,9 +50,9 @@ def aggregate_questionnaire_score # we accept nil as answer for scorable questions, and they will not be counted towards the total score sum = 0 scores.each do |s| - question = Question.find(s.question_id) + item = Item.find(s.question_id) # For quiz responses, the weights will be 1 or 0, depending on if correct - sum += s.answer * question.weight unless s.answer.nil? || !question.scorable? + sum += s.answer * item.weight unless s.answer.nil? || !item.scorable? end sum end diff --git a/app/models/scale.rb b/app/models/scale.rb new file mode 100644 index 000000000..fd2b5a0fd --- /dev/null +++ b/app/models/scale.rb @@ -0,0 +1,34 @@ +class Scale < ScoredItem + include QuestionHelper + + attr_accessor :txt, :type, :weight, :min_label, :max_label, :answer, :min_question_score, :max_question_score + + def edit + edit_common('Item:', min_question_score, max_question_score , txt, weight, type).to_json + end + + def view_item_text + view_item_text_common(txt, type, weight, score_range).to_json + end + + def complete + options = (@min_question_score..@max_question_score).map do |option| + { value: option, selected: (option == answer) } + end + { scale_options: options }.to_json + end + + def view_completed_item(options = {}) + if options[:count] && options[:answer] && options[:questionnaire_max] + { count: options[:count], answer: options[:answer], questionnaire_max: options[:questionnaire_max] }.to_json + else + { message: 'Item not answered.' }.to_json + end + end + + private + + def score_range + @min_question_score..@max_question_score + end + end \ No newline at end of file diff --git a/app/models/scored_question.rb b/app/models/scored_item.rb similarity index 64% rename from app/models/scored_question.rb rename to app/models/scored_item.rb index f6ea0133c..6b8a36c33 100644 --- a/app/models/scored_question.rb +++ b/app/models/scored_item.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ScoredQuestion < ChoiceQuestion +class ScoredItem < ChoiceItem def scorable? true end diff --git a/app/models/text_area.rb b/app/models/text_area.rb new file mode 100644 index 000000000..a734e993b --- /dev/null +++ b/app/models/text_area.rb @@ -0,0 +1,22 @@ +class TextArea < Item + def complete(count,answer = nil) + { + action: 'complete', + data: { + count: count, + comment: answer&.comments, + size: size || '70,1', # Assuming '70,1' is the default size + } + }.to_json + end + + def view_completed_item(count, answer) + { + action: 'view_completed_item', + data: { + count: count, + comment: answer.comments + } + }.to_json + end + end \ No newline at end of file diff --git a/app/models/text_field.rb b/app/models/text_field.rb new file mode 100644 index 000000000..ab7888a1d --- /dev/null +++ b/app/models/text_field.rb @@ -0,0 +1,40 @@ +class TextField < Item + validates :size, presence: true + + def complete(count, answer = nil) + { + action: 'complete', + data: { + label: "Item ##{count}", + type: 'text', + name: "response[answers][#{id}]", + id: "responses_#{id}", + value: answer&.comments + } + }.to_json + end + + def view_completed_item(count, files) + if question_type == 'TextField' && break_before + { + action: 'view_completed_item', + data: { + type: 'text', + label: "Completed Item ##{count}", + value: txt, + break_before: break_before + } + }.to_json + else + { + action: 'view_completed_item', + data: { + type: 'text', + label: "Completed Item ##{count}", + value: txt, + break_before: break_before + } + }.to_json + end + end + end \ No newline at end of file diff --git a/app/models/text_response.rb b/app/models/text_response.rb new file mode 100644 index 000000000..1f61490b0 --- /dev/null +++ b/app/models/text_response.rb @@ -0,0 +1,64 @@ +class TextResponse < Item + validates :size, presence: true + + def edit(_count) + { + action: 'edit', + elements: [ + { + type: 'link', + text: 'Remove', + href: "/questions/#{id}", + method: 'delete' + }, + { + type: 'input', + input_type: 'text', + size: 6, + name: "item[#{id}][seq]", + id: "question_#{id}_seq", + value: seq.to_s + }, + { + type: 'textarea', + cols: 50, + rows: 1, + name: "item[#{id}][txt]", + id: "question_#{id}_txt", + placeholder: 'Edit item content here', + value: txt + }, + { + type: 'input', + input_type: 'text', + size: 10, + name: "item[#{id}][question_type]", + id: "question_#{id}_question_type", + value: question_type, + disabled: true + }, + { + type: 'input', + input_type: 'text', + size: 6, + name: "item[#{id}][size]", + id: "question_#{id}_size", + value: size, + label: 'Text area size' + } + ] + }.to_json + end + + def view_item_text + { + action: 'view_item_text', + elements: [ + { type: 'text', value: txt }, + { type: 'text', value: question_type }, + { type: 'text', value: weight.to_s }, + { type: 'text', value: '—' } + ] + }.to_json + end + end \ No newline at end of file diff --git a/app/models/unscored_item.rb b/app/models/unscored_item.rb new file mode 100644 index 000000000..6af1360bb --- /dev/null +++ b/app/models/unscored_item.rb @@ -0,0 +1,9 @@ +class UnscoredItem < ChoiceItem + def edit; end + + def view_item_text; end + + def complete; end + + def view_completed_item; end + end \ No newline at end of file diff --git a/db/migrate/20250214224716_create_question_tables.rb b/db/migrate/20250214224716_create_question_tables.rb new file mode 100644 index 000000000..7a4221e2b --- /dev/null +++ b/db/migrate/20250214224716_create_question_tables.rb @@ -0,0 +1,30 @@ +class CreateQuestionTables < ActiveRecord::Migration[7.0] + def change + create_table :question_advices, options: "CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci" do |t| + t.bigint :question_id, null: false + t.integer :score + t.text :advice + t.timestamps + end + add_index :question_advices, :question_id, name: "index_question_advices_on_question_id" + + create_table :question_types, options: "CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci" do |t| + t.string :name + t.timestamps + end + + create_table :questionnaire_types, options: "CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci" do |t| + t.string :name + t.timestamps + end + + create_table :quiz_question_choices, id: :integer, options: "CHARSET=latin1" do |t| + t.integer :question_id + t.text :txt + t.boolean :iscorrect, default: false + t.timestamps + end + + add_foreign_key :question_advices, :questions + end +end diff --git a/db/migrate/20250216020117_rename_questions_to_items.rb b/db/migrate/20250216020117_rename_questions_to_items.rb new file mode 100644 index 000000000..666615988 --- /dev/null +++ b/db/migrate/20250216020117_rename_questions_to_items.rb @@ -0,0 +1,5 @@ +class RenameQuestionsToItems < ActiveRecord::Migration[7.0] + def change + rename_table :questions, :items + end +end diff --git a/db/schema.rb b/db/schema.rb index e50704472..120764e72 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_12_02_165201) do +ActiveRecord::Schema[7.0].define(version: 2025_02_16_020117) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -156,6 +156,23 @@ t.index ["to_id"], name: "fk_invitationto_users" end + create_table "items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.text "txt" + t.integer "weight" + t.decimal "seq", precision: 10 + t.string "question_type" + t.string "size" + t.string "alternatives" + t.boolean "break_before" + t.string "max_label" + t.string "min_label" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "questionnaire_id", null: false + t.index ["questionnaire_id"], name: "fk_question_questionnaires" + t.index ["questionnaire_id"], name: "index_items_on_questionnaire_id" + end + create_table "join_team_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -197,6 +214,27 @@ t.index ["user_id"], name: "index_participants_on_user_id" end + create_table "question_advices", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "question_id", null: false + t.integer "score" + t.text "advice" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["question_id"], name: "index_question_advices_on_question_id" + end + + create_table "question_types", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "name" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "questionnaire_types", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "name" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "questionnaires", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name" t.integer "instructor_id" @@ -210,21 +248,12 @@ t.datetime "updated_at", null: false end - create_table "questions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + create_table "quiz_question_choices", id: :integer, charset: "latin1", force: :cascade do |t| + t.integer "question_id" t.text "txt" - t.integer "weight" - t.decimal "seq", precision: 10 - t.string "question_type" - t.string "size" - t.string "alternatives" - t.boolean "break_before" - t.string "max_label" - t.string "min_label" + t.boolean "iscorrect", default: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.bigint "questionnaire_id", null: false - t.index ["questionnaire_id"], name: "fk_question_questionnaires" - t.index ["questionnaire_id"], name: "index_questions_on_questionnaire_id" end create_table "response_maps", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -340,11 +369,12 @@ add_foreign_key "assignments", "users", column: "instructor_id" add_foreign_key "courses", "institutions" add_foreign_key "courses", "users", column: "instructor_id" + add_foreign_key "items", "questionnaires" add_foreign_key "participants", "assignments" add_foreign_key "participants", "join_team_requests" add_foreign_key "participants", "teams" add_foreign_key "participants", "users" - add_foreign_key "questions", "questionnaires" + add_foreign_key "question_advices", "items", column: "question_id" add_foreign_key "roles", "roles", column: "parent_id", on_delete: :cascade add_foreign_key "sign_up_topics", "assignments" add_foreign_key "signed_up_teams", "sign_up_topics" diff --git a/spec/factories/questionnaires.rb b/spec/factories/questionnaires.rb index c9c45695e..2b1181035 100644 --- a/spec/factories/questionnaires.rb +++ b/spec/factories/questionnaires.rb @@ -11,16 +11,16 @@ # Trait for questionnaire with questions trait :with_questions do after(:create) do |questionnaire| - create(:question, questionnaire: questionnaire, weight: 1, seq: 1, txt: "que 1", question_type: "Scale") - create(:question, questionnaire: questionnaire, weight: 10, seq: 2, txt: "que 2", question_type: "Checkbox") + create(:item, questionnaire: questionnaire, weight: 1, seq: 1, txt: "que 1", question_type: "Scale") + create(:item, questionnaire: questionnaire, weight: 10, seq: 2, txt: "que 2", question_type: "Checkbox") end end end end -# spec/factories/questions.rb +# spec/factories/items.rb FactoryBot.define do - factory :question do + factory :item do sequence(:txt) { |n| "Question #{n}" } sequence(:seq) { |n| n } weight { 1 } diff --git a/spec/models/checkbox_spec.rb b/spec/models/checkbox_spec.rb new file mode 100644 index 000000000..10410cfd5 --- /dev/null +++ b/spec/models/checkbox_spec.rb @@ -0,0 +1,77 @@ +require 'rails_helper' + +RSpec.describe Checkbox do + let!(:checkbox) { Checkbox.new(id: 10, question_type: 'Checkbox', seq: 1.0, txt: 'test txt', weight: 11) } + let!(:answer) { Answer.new(answer: 1) } + + describe '#edit' do + it 'returns the JSON' do + json = checkbox.edit(0) + expected_json = { + remove_button: { type: 'remove_button', action: 'delete', href: "/questions/10", text: 'Remove' }, + seq: { type: 'seq', input_size: 6, value: 1.0, name: "item[10][seq]", id: "question_10_seq" }, + item: { type: 'textarea', cols: 50, rows: 1, name: "item[10][txt]", id: "question_10_txt", placeholder: 'Edit item content here', content: 'test txt' }, + type: { type: 'text', input_size: 10, disabled: true, value: 'Checkbox', name: "item[10][type]", id: "question_10_type" }, + weight: { type: 'weight', placeholder: 'UnscoredItem does not need weight' } + } + expect(json).to eq(expected_json) + end + end + + describe '#complete' do + let(:checkbox) { Checkbox.new(id: 10, question_type: 'Checkbox', seq: 1.0, txt: 'test txt', weight: 11) } + let(:count) { 1 } + let(:answer) { OpenStruct.new(answer: 1) } # Mocking Answer object + + context 'when an answer is provided' do + it 'returns the expected completion structure' do + result = checkbox.complete(count, answer) + + expect(result[:previous_question]).to be_present + expect(result[:inputs]).to be_an(Array) + expect(result[:label]).to include(text: checkbox.txt) + expect(result[:script]).to include("checkbox#{count}Changed()") + expect(result[:inputs].last[:checked]).to be true + end + end + + context 'when no answer is provided' do + let(:answer) { OpenStruct.new(answer: nil) } + + it 'returns a structure with the checkbox not checked' do + result = checkbox.complete(count, answer) + expect(result[:inputs].last[:checked]).to be false + end + end + end + + describe '#view_item_text' do + it 'returns the JSON' do + json = checkbox.view_item_text + expected_json = { + content: 'test txt', + type: 'Checkbox', + weight: '11', + checked_state: 'Checked/Unchecked' + } + expect(json).to eq(expected_json) + end + end + + describe '#view_completed_item' do + it 'returns the JSON' do + json = checkbox.view_completed_item(0, answer) + expected_json = { + previous_question: { type: 'other' }, + answer: { + number: 0, + image: 'Check-icon.png', + content: 'test txt', + bold: true + }, + if_column_header: 'end' + } + expect(json).to eq(expected_json) + end + end +end \ No newline at end of file diff --git a/spec/models/criterion_spec.rb b/spec/models/criterion_spec.rb new file mode 100644 index 000000000..2dbf0a2c6 --- /dev/null +++ b/spec/models/criterion_spec.rb @@ -0,0 +1,77 @@ +require 'rails_helper' + +RSpec.describe Criterion, type: :model do + let(:questionnaire) { Questionnaire.new(min_question_score: 0, max_question_score: 5) } + let(:criterion) { Criterion.new(id: 1, question_type: 'Criterion', seq: 1.0, txt: 'test txt', weight: 1, questionnaire: questionnaire) } + let(:answer_no_comments) { Answer.new(answer: 8) } + let(:answer_comments) { Answer.new(answer: 3, comments: 'text comments') } + + describe '#view_item_text' do + it 'returns the JSON' do + json = criterion.view_item_text + expected_json = { + text: 'test txt', + question_type: 'Criterion', + weight: 1, + score_range: '0 to 5' + } + expect(json).to eq(expected_json) + end + end + + describe '#complete' do + it 'returns JSON without answer and no dropdown or scale specified' do + json = criterion.complete(0, nil, 0, 5) + expected_json = { + label: 'test txt' + } + expect(json).to include(expected_json) + end + + it 'returns JSON with a dropdown, including answer options' do + json = criterion.complete(0, nil, 0, 5, 'dropdown') + expected_options = (0..5).map { |score| { value: score, label: score.to_s } } + expected_json = { + label: 'test txt', + response_options: { + type: 'dropdown', + comments: nil, + current_answer: nil, + options: expected_options, + } + } + expect(json).to include(expected_json) + end + end + + describe '#dropdown_criterion_question' do + it 'returns JSON for a dropdown without an answer selected' do + json = criterion.dropdown_criterion_question(0, nil, 0, 5) + expected_options = (0..5).map { |score| { value: score, label: score.to_s } } + expected_json = { + type: 'dropdown', + comments: nil, + current_answer: nil, + options: expected_options, + } + expect(json).to eq(expected_json) + end + end + + describe '#scale_criterion_question' do + it 'returns JSON for a scale item without an answer selected' do + json = criterion.scale_criterion_question(0, nil, 0, 5) + expected_json = { + type: 'scale', + min: 0, + max: 5, + comments: nil, + current_answer: nil, + min_label: nil, + max_label: nil, + size: nil, + } + expect(json).to eq(expected_json) + end + end +end \ No newline at end of file diff --git a/spec/models/dropdown_spec.rb b/spec/models/dropdown_spec.rb new file mode 100644 index 000000000..51a5655ad --- /dev/null +++ b/spec/models/dropdown_spec.rb @@ -0,0 +1,107 @@ +require 'rails_helper' + +RSpec.describe Dropdown, type: :model do + describe '#edit' do + context 'when given a count' do + it 'returns a JSON object with the edit form for a item' do + dropdown = Dropdown.new(txt: "Some Text", type: "dropdown", weight: 1) + json_result = dropdown.edit(5) + + expected_result = { + form: true, + label: "Item 5:", + input_type: "text", + input_name: "item", + input_value: "Some Text", + min_question_score: nil, + max_question_score: nil, + weight: 1, + type: 'dropdown' + }.to_json + expect(json_result).to eq(expected_result) + end + end + end + + describe '#view_item_text' do + let(:dropdown) { Dropdown.new } + context 'when given valid inputs' do + it 'returns the JSON for displaying the item text, type, weight, and score range' do + allow(dropdown).to receive(:txt).and_return("Item 1") + allow(dropdown).to receive(:type).and_return("Multiple Choice") + allow(dropdown).to receive(:weight).and_return(1) + expected_json = { + text: "Item 1", + type: "Multiple Choice", + weight: 1, + score_range: "N/A" + }.to_json + expect(dropdown.view_item_text).to eq(expected_json) + end + end + end + + describe '#complete' do + let(:dropdown) { Dropdown.new } + context 'when count is provided' do + it 'generates JSON for a select input with the given count' do + count = 3 + expected_json = { + dropdown_options: [ + { value: 1, selected: false }, + { value: 2, selected: false }, + { value: 3, selected: false } + ] + }.to_json + expect(dropdown.complete(count)).to eq(expected_json) + end + end + + context 'when answer is provided' do + it 'generates JSON with the provided answer selected' do + count = 3 + answer = 2 + expected_json = { + dropdown_options: [ + { value: 1, selected: false }, + { value: 2, selected: true }, + { value: 3, selected: false } + ] + }.to_json + expect(dropdown.complete(count, answer)).to eq(expected_json) + end + end + + context 'when answer is not provided' do + it 'generates JSON without any answer selected' do + count = 3 + expected_json = { + dropdown_options: [ + { value: 1, selected: false }, + { value: 2, selected: false }, + { value: 3, selected: false } + ] + }.to_json + expect(dropdown.complete(count)).to eq(expected_json) + end + end + end + + describe '#complete_for_alternatives' do + let(:dropdown) { Dropdown.new } + context 'when given an array of alternatives and an answer' do + it 'returns JSON options with the selected alternative marked' do + alternatives = [1, 2, 3] + answer = 2 + expected_json = { + dropdown_options: [ + { value: 1, selected: false }, + { value: 2, selected: true }, + { value: 3, selected: false } + ] + }.to_json + expect(dropdown.complete_for_alternatives(alternatives, answer)).to eq(expected_json) + end + end + end +end \ No newline at end of file diff --git a/spec/models/file_upload_spec.rb b/spec/models/file_upload_spec.rb new file mode 100644 index 000000000..c083f63aa --- /dev/null +++ b/spec/models/file_upload_spec.rb @@ -0,0 +1,162 @@ +require 'rails_helper' + + + + +RSpec.describe FileUpload do + + # Adjust the let statement to not include 'type' and 'weight' if they are not recognized attributes. + + let(:upload_file) { FileUpload.new(id: 1, seq: '1', txt: 'Sample item content', question_type: 'FileUpload') } + + + + + describe '#edit' do + + context 'when given a count' do + + let(:json_response) { JSON.parse(upload_file.edit(1)) } + + + + + it 'returns JSON with a correct structure for editing' do + + expect(json_response["action"]).to eq('edit') + + expect(json_response["elements"]).to be_an(Array) + + end + + + + + it 'includes a "Remove" link in the elements' do + + link_element = json_response["elements"].find { |el| el["text"] == "Remove" } + + expect(link_element).not_to be_nil + + expect(link_element["href"]).to include("/questions/1") + + end + + + + + it 'includes an input field for the sequence' do + + seq_element = json_response["elements"].find { |el| el["name"]&.include?("seq") } + + expect(seq_element).not_to be_nil + + expect(seq_element["value"]).to eq('1') + + end + + + + + it 'includes an input field for the id' do + + id_element = json_response["elements"].find { |el| el["name"]&.include?("id") } + + expect(id_element).not_to be_nil + + expect(id_element["value"]).to eq('1') + + end + + + + + it 'includes a textarea for editing the item content' do + + textarea_element = json_response["elements"].find { |el| el["name"]&.include?("txt") } + + expect(textarea_element).not_to be_nil + + expect(textarea_element["value"]).to eq('Sample item content') + + end + + + + + it 'includes an input field for the item type, disabled' do + + type_element = json_response["elements"].find { |el| el["name"]&.include?("question_type") } + + expect(type_element).not_to be_nil + + expect(type_element["disabled"]).to be true + + end + + + + + it 'does not include an explicit cell for weight, as it is not applicable' do + + weight_element = json_response["elements"].none? { |el| el.has_key?("weight") } + + expect(weight_element).to be true + + end + + end + + end + + + + + describe '#view_item_text' do + + context 'when given valid input' do + + let(:json_response) { JSON.parse(upload_file.view_item_text) } + + + + + it 'returns JSON for displaying a item' do + + expect(json_response["action"]).to eq('view_item_text') + + expect(json_response["elements"].map { |el| el["value"] }).to include('Sample item content', 'FileUpload', "", '1','—') + + end + + end + + end + + + + + describe '#complete' do + + it 'implements the logic for completing a item' do + + # Write your test for the complete method logic here. + + end + + end + + + + + describe '#view_completed_item' do + + it 'implements the logic for viewing a completed item by a student' do + + # Write your test for the view_completed_item method logic here. + + end + + end + +end \ No newline at end of file diff --git a/spec/models/item_spec.rb b/spec/models/item_spec.rb new file mode 100644 index 000000000..22e82c0ec --- /dev/null +++ b/spec/models/item_spec.rb @@ -0,0 +1,111 @@ +require 'rails_helper' + +RSpec.describe Item, type: :model do + # Creating dummy objects for the test with the help of let statement + let(:role) { Role.create(name: 'Instructor', parent_id: nil, id: 2, default_page_id: nil) } + let(:instructor) do + Instructor.create(id: 1234, name: 'testinstructor', email: 'test@test.com', full_name: 'Test Instructor', + password: '123456', role:) + end + let(:questionnaire) do + Questionnaire.new id: 1, name: 'abc', private: 0, min_question_score: 0, max_question_score: 10, + instructor_id: instructor.id + end + + describe 'validations' do + # Test validates that item has valid attributes + it 'is valid with valid attributes' do + item = Item.new(seq: 1, txt: 'Sample item', question_type: 'multiple_choice', break_before: true, + questionnaire:) + expect(item).to be_valid + end + + # Test ensures that a item is not valid without seq field + it 'is not valid without a seq' do + item = Item.new(txt: 'Sample item', question_type: 'multiple_choice', break_before: true, + questionnaire:) + expect(item).to_not be_valid + end + + # Test ensures that seq field is numeric + it 'is not valid with a non-numeric seq' do + item = Item.new(seq: 'one', txt: 'Sample item', question_type: 'multiple_choice', + break_before: true, questionnaire:) + expect(item).to_not be_valid + end + + # Test ensures that a item is not valid without txt field + it 'is not valid without a txt' do + item = Item.new(seq: 1, question_type: 'multiple_choice', break_before: true, + questionnaire:) + expect(item).to_not be_valid + end + + # Test ensures that a item is not valid without question_type field + it 'is not valid without a question_type' do + item = Item.new(seq: 1, txt: 'Sample item', break_before: true, questionnaire:) + expect(item).to_not be_valid + end + + # Test ensures that a item is not valid without break_before field + it 'is not valid without a break_before value' do + item = Item.new(seq: 1, txt: 'Sample item', question_type: 'multiple_choice', + questionnaire:) + expect(item).to_not be_valid + end + + # Test ensures that a item does not exist without a questionnaire + it 'is not valid without a questionnaire' do + item = Item.new(seq: 1, txt: 'Sample item', question_type: 'multiple_choice', break_before: true) + expect(item).to_not be_valid + end + end + + describe '#delete' do + # Test ensures that a item object is deleted properly taking all its association into consideration + it 'destroys the item object' do + instructor.save! + questionnaire.save! + item = Item.create(seq: 1, txt: 'Sample item', question_type: 'multiple_choice', + break_before: true, questionnaire:) + expect { item.delete }.to change { Item.count }.by(-1) + end + end + + describe '#set_choice_strategy' do + context 'when the item type is Dropdown' do + let(:item) { create(:item, questionnaire: questionnaire, question_type: 'dropdown') } + + it 'assigns the correct strategy' do + item.strategy + expect(item.choice_strategy).to be_an_instance_of(Strategies::DropdownStrategy) + end + end + + context 'when the item type is MultipleChoice' do + let(:item) { create(:item, questionnaire: questionnaire, question_type: 'multiple_choice') } + + it 'assigns the correct strategy' do + item.strategy + expect(item.choice_strategy).to be_an_instance_of(Strategies::MultipleChoiceStrategy) + end + end + + context 'when the item type is Scale' do + let(:item) { create(:item, questionnaire: questionnaire, question_type: 'scale') } + + it 'assigns the correct strategy' do + item.strategy + expect(item.choice_strategy).to be_an_instance_of(Strategies::ScaleStrategy) + end + end + + context 'when the item type is unknown' do + let(:item) { create(:item, questionnaire: questionnaire, question_type: 'Unknown') } + + it 'raises an error' do + expect { item.strategy }.to raise_error("Unknown item type: Unknown") + end + end + end +end diff --git a/spec/models/multiple_choice_checkbox_spec.rb b/spec/models/multiple_choice_checkbox_spec.rb new file mode 100644 index 000000000..eb580489f --- /dev/null +++ b/spec/models/multiple_choice_checkbox_spec.rb @@ -0,0 +1,69 @@ +require 'rails_helper' + +RSpec.describe MultipleChoiceCheckbox, type: :model do + let(:multiple_choice_checkbox) { MultipleChoiceCheckbox.new(id: 1, txt: 'Test item:', weight: 1) } # Adjust as needed + + describe '#edit' do + it 'returns the JSON structure for editing' do + qc = instance_double('QuizQuestionChoice', iscorrect: true, txt: 'item text', id: 1) + allow(QuizQuestionChoice).to receive(:where).with(question_id: 1).and_return([qc, qc, qc, qc]) + + expected_structure = { + "id" => 1, + "question_text" => "Test item:", + "weight" => 1, + "choices" => [ + {"id" => 1, "text" => "item text", "is_correct" => true, "position" => 1}, + {"id" => 1, "text" => "item text", "is_correct" => true, "position" => 2}, + {"id" => 1, "text" => "item text", "is_correct" => true, "position" => 3}, + {"id" => 1, "text" => "item text", "is_correct" => true, "position" => 4} + ] + }.to_json + + expect(multiple_choice_checkbox.edit).to eq(expected_structure) + end + end + + describe '#isvalid' do + context 'when the item itself does not have txt' do + it 'returns a JSON with error message' do + allow(multiple_choice_checkbox).to receive_messages(txt: '', id: 1) + questions = { '1' => { txt: 'item text', iscorrect: '1' }, '2' => { txt: 'item text', iscorrect: '1' }, '3' => { txt: 'item text', iscorrect: '0' }, '4' => { txt: 'item text', iscorrect: '0' } } + expected_response = { valid: false, error: 'Please make sure all questions have text' }.to_json + expect(multiple_choice_checkbox.isvalid(questions)).to eq(expected_response) + end + end + + context 'when a choice does not have txt' do + it 'returns a JSON with error message' do + questions = { '1' => { txt: '', iscorrect: '1' }, '2' => { txt: '', iscorrect: '1' }, '3' => { txt: '', iscorrect: '0' }, '4' => { txt: '', iscorrect: '0' } } + expected_response = { valid: true, error: nil }.to_json + expect(multiple_choice_checkbox.isvalid(questions)).to eq(expected_response) + end + end + + context 'when no choices are correct' do + it 'returns a JSON with error message' do + questions = { '1' => { txt: 'item text', iscorrect: '0' }, '2' => { txt: 'item text', iscorrect: '0' }, '3' => { txt: 'item text', iscorrect: '0' }, '4' => { txt: 'item text', iscorrect: '0' } } + expected_response = { valid: false, error: 'Please select a correct answer for all questions' }.to_json + expect(multiple_choice_checkbox.isvalid(questions)).to eq(expected_response) + end + end + + context 'when only one choice is correct' do + it 'returns a JSON with error message' do + questions = { '1' => { txt: 'item text', iscorrect: '1' }, '2' => { txt: 'item text', iscorrect: '0' }, '3' => { txt: 'item text', iscorrect: '0' }, '4' => { txt: 'item text', iscorrect: '0' } } + expected_response = { valid: false, error: 'A multiple-choice checkbox item should have more than one correct answer.' }.to_json + expect(multiple_choice_checkbox.isvalid(questions)).to eq(expected_response) + end + end + + context 'when 2 choices are correct' do + it 'returns valid status' do + questions = { '1' => { txt: 'item text', iscorrect: '1' }, '2' => { txt: 'item text', iscorrect: '1' }, '3' => { txt: 'item text', iscorrect: '0' }, '4' => { txt: 'item text', iscorrect: '0' } } + expected_response = { valid: true, error: nil}.to_json + expect(multiple_choice_checkbox.isvalid(questions)).to eq(expected_response) + end + end + end +end \ No newline at end of file diff --git a/spec/models/multiple_choice_radio_spec.rb b/spec/models/multiple_choice_radio_spec.rb new file mode 100644 index 000000000..8ccf41fe6 --- /dev/null +++ b/spec/models/multiple_choice_radio_spec.rb @@ -0,0 +1,92 @@ +require 'rails_helper' + +RSpec.describe MultipleChoiceRadio, type: :model do + let(:multiple_choice_radio) { MultipleChoiceRadio.new(id: 1, txt: 'Test item', weight: 1) } + let(:quiz_question_choices) do + [ + instance_double('QuizQuestionChoice', id: 1, txt: 'Choice 1', iscorrect: true), + instance_double('QuizQuestionChoice', id: 2, txt: 'Choice 2', iscorrect: false) + ] + end + + before do + allow(QuizQuestionChoice).to receive(:where).with(question_id: 1).and_return(quiz_question_choices) + end + + describe '#edit' do + context 'when editing a quiz item' do + it 'returns JSON for the item edit form' do + expected_json = { + id: 1, + question_text: 'Test item', + question_weight: 1, + choices: [ + {id: 1, text: 'Choice 1', is_correct: true, position: 1}, + {id: 2, text: 'Choice 2', is_correct: false, position: 2} + ] + }.to_json + + expect(multiple_choice_radio.edit).to eq(expected_json) + end + end + end + + describe '#complete' do + context 'when given a valid item id' do + it 'returns JSON for a quiz item with choices' do + expected_json = { + question_id: 1, + question_text: 'Test item', + choices: [ + {id: 1, text: 'Choice 1', position: 1}, + {id: 2, text: 'Choice 2', position: 2} + ] + }.to_json + + expect(multiple_choice_radio.complete).to eq(expected_json) + end + end + end + + describe "#view_completed_item" do + let(:user_answer) { [instance_double('UserAnswer', answer: 1, comments: 'Choice 1')] } + + context "when user answer is correct" do + it "includes correctness in the response" do + expected_json = { + question_text: 'Test item', + choices: [ + {text: 'Choice 1', is_correct: true}, + {text: 'Choice 2', is_correct: false} + ], + user_response: {answer: 'Choice 1', is_correct: true} + }.to_json + + expect(multiple_choice_radio.view_completed_item(user_answer)).to eq(expected_json) + end + end + end + + describe '#isvalid' do + context 'when choice_info is valid' do + it 'returns valid status' do + choice_info = { + '0' => { txt: 'Choice 1', iscorrect: '0' }, + '1' => { txt: 'Choice 2', iscorrect: '1' } + } + expected_response = { valid: true, error: nil }.to_json + + expect(multiple_choice_radio.isvalid(choice_info)).to eq(expected_response) + end + end + + context 'when choice_info has empty text for an option' do + it 'returns an error message' do + choice_info = {'0' => {txt: '', iscorrect: '1'}, '1' => {txt: 'Choice 2', iscorrect: '0'}} + expected_response = {valid: false, error: 'Please make sure every item has text for all options'}.to_json + + expect(multiple_choice_radio.isvalid(choice_info)).to eq(expected_response) + end + end + end +end \ No newline at end of file diff --git a/spec/models/question_spec.rb b/spec/models/question_spec.rb deleted file mode 100644 index 83c118161..000000000 --- a/spec/models/question_spec.rb +++ /dev/null @@ -1,74 +0,0 @@ -require 'rails_helper' - -RSpec.describe Question, type: :model do - # Creating dummy objects for the test with the help of let statement - let(:role) { Role.create(name: 'Instructor', parent_id: nil, id: 2, default_page_id: nil) } - let(:instructor) do - Instructor.create(id: 1234, name: 'testinstructor', email: 'test@test.com', full_name: 'test instructor', - password: '123456', role:) - end - let(:questionnaire) do - Questionnaire.new id: 1, name: 'abc', private: 0, min_question_score: 0, max_question_score: 10, - instructor_id: instructor.id - end - - describe 'validations' do - # Test validates that question has valid attributes - it 'is valid with valid attributes' do - question = Question.new(seq: 1, txt: 'Sample question', question_type: 'multiple_choice', break_before: true, - questionnaire:) - expect(question).to be_valid - end - - # Test ensures that a question is not valid without seq field - it 'is not valid without a seq' do - question = Question.new(txt: 'Sample question', question_type: 'multiple_choice', break_before: true, - questionnaire:) - expect(question).to_not be_valid - end - - # Test ensures that seq field is numeric - it 'is not valid with a non-numeric seq' do - question = Question.new(seq: 'one', txt: 'Sample question', question_type: 'multiple_choice', - break_before: true, questionnaire:) - expect(question).to_not be_valid - end - - # Test ensures that a question is not valid without txt field - it 'is not valid without a txt' do - question = Question.new(seq: 1, question_type: 'multiple_choice', break_before: true, - questionnaire:) - expect(question).to_not be_valid - end - - # Test ensures that a question is not valid without question_type field - it 'is not valid without a question_type' do - question = Question.new(seq: 1, txt: 'Sample question', break_before: true, questionnaire:) - expect(question).to_not be_valid - end - - # Test ensures that a question is not valid without break_before field - it 'is not valid without a break_before value' do - question = Question.new(seq: 1, txt: 'Sample question', question_type: 'multiple_choice', - questionnaire:) - expect(question).to_not be_valid - end - - # Test ensures that a question does not exist without a questionnaire - it 'is not valid without a questionnaire' do - question = Question.new(seq: 1, txt: 'Sample question', question_type: 'multiple_choice', break_before: true) - expect(question).to_not be_valid - end - end - - describe '#delete' do - # Test ensures that a question object is deleted properly taking all its association into consideration - it 'destroys the question object' do - instructor.save! - questionnaire.save! - question = Question.create(seq: 1, txt: 'Sample question', question_type: 'multiple_choice', - break_before: true, questionnaire:questionnaire) - expect { question.delete }.to change { Question.count }.by(-1) - end - end -end diff --git a/spec/models/questionnaire_spec.rb b/spec/models/questionnaire_spec.rb index 7078f796c..60b5d1e67 100644 --- a/spec/models/questionnaire_spec.rb +++ b/spec/models/questionnaire_spec.rb @@ -16,8 +16,8 @@ end let(:questionnaire1) { Questionnaire.new name: 'xyz', private: 0, max_question_score: 20, instructor_id: instructor.id } let(:questionnaire2) { Questionnaire.new name: 'pqr', private: 0, max_question_score: 10, instructor_id: instructor.id } - let(:question1) { questionnaire.questions.build(weight: 1, id: 1, seq: 1, txt: "que 1", question_type: "Scale", break_before: true) } - let(:question2) { questionnaire.questions.build(weight: 10, id: 2, seq: 2, txt: "que 2", question_type: "Checkbox", break_before: true) } + let(:question1) { questionnaire.items.build(weight: 1, id: 1, seq: 1, txt: "que 1", question_type: "scale", break_before: true) } + let(:question2) { questionnaire.items.build(weight: 10, id: 2, seq: 2, txt: "que 2", question_type: "multiple_choice", break_before: true) } @@ -103,7 +103,7 @@ describe 'associations' do # Test validates the association that a questionnaire comprises of several questions it 'has many questions' do - expect(questionnaire.questions).to include(question1, question2) + expect(questionnaire.items).to include(question1, question2) end end @@ -111,7 +111,7 @@ # Test ensures calls from the method copy_questionnaire_details it 'allowing calls from copy_questionnaire_details' do allow(Questionnaire).to receive(:find).with('1').and_return(questionnaire) - allow(Question).to receive(:where).with(questionnaire_id: '1').and_return([Question]) + allow(Item).to receive(:where).with(questionnaire_id: '1').and_return([Item]) end # Test ensures creation of a copy of given questionnaire @@ -133,9 +133,9 @@ question1.save! question2.save! copied_questionnaire = described_class.copy_questionnaire_details({ id: questionnaire.id }) - expect(copied_questionnaire.questions.count).to eq(2) - expect(copied_questionnaire.questions.first.txt).to eq(question1.txt) - expect(copied_questionnaire.questions.second.txt).to eq(question2.txt) + expect(copied_questionnaire.items.count).to eq(2) + expect(copied_questionnaire.items.first.txt).to eq(question1.txt) + expect(copied_questionnaire.items.second.txt).to eq(question2.txt) end end diff --git a/spec/models/response_spec.rb b/spec/models/response_spec.rb index 96ba61f5a..5f5b3ee07 100644 --- a/spec/models/response_spec.rb +++ b/spec/models/response_spec.rb @@ -7,8 +7,8 @@ let(:participant) { Participant.new(id: 1, user: user) } let(:assignment) { Assignment.new(id: 1, name: 'Test Assignment') } let(:answer) { Answer.new(answer: 1, comments: 'Answer text', question_id: 1) } - let(:question) { ScoredQuestion.new(id: 1, weight: 2) } - let(:questionnaire) { Questionnaire.new(id: 1, questions: [question], max_question_score: 5) } + let(:item) { ScoredItem.new(id: 1, weight: 2) } + let(:questionnaire) { Questionnaire.new(id: 1, items: [item], max_question_score: 5) } let(:review_response_map) { ReviewResponseMap.new(assignment: assignment, reviewee: team) } let(:response_map) { ResponseMap.new(assignment: assignment, reviewee: participant, reviewer: participant) } let(:response) { Response.new(map_id: 1, response_map: review_response_map, scores: [answer]) } @@ -43,9 +43,9 @@ # Calculate the total score of a review describe '#calculate_total_score' do it 'computes the total score of a review' do - question2 = double('ScoredQuestion', weight: 2) + question2 = double('ScoredItem', weight: 2) arr_question2 = [question2] - allow(Question).to receive(:find_with_order).with([1]).and_return(arr_question2) + allow(Item).to receive(:find_with_order).with([1]).and_return(arr_question2) allow(question2).to receive(:scorable?).and_return(true) allow(question2).to receive(:answer).and_return(answer) expect(response.calculate_total_score).to eq(2) @@ -74,9 +74,9 @@ # answer for scorable questions, and they will not be counted towards the total score) describe '#maximum_score' do it 'returns the maximum possible score for current response' do - question2 = double('ScoredQuestion', weight: 2) + question2 = double('ScoredItem', weight: 2) arr_question2 = [question2] - allow(Question).to receive(:find_with_order).with([1]).and_return(arr_question2) + allow(Item).to receive(:find_with_order).with([1]).and_return(arr_question2) allow(question2).to receive(:scorable?).and_return(true) allow(response).to receive(:questionnaire_by_answer).with(answer).and_return(questionnaire) allow(questionnaire).to receive(:max_question_score).and_return(5) @@ -100,9 +100,9 @@ # Expects to return ResponseMap's assignment it 'returns the appropriate assignment for ReviewResponseMap' do - question2 = double('ScoredQuestion', weight: 2) + question2 = double('ScoredItem', weight: 2) arr_question2 = [question2] - allow(Question).to receive(:find_with_order).with([1]).and_return(arr_question2) + allow(Item).to receive(:find_with_order).with([1]).and_return(arr_question2) allow(question2).to receive(:scorable?).and_return(true) allow(questionnaire).to receive(:max_question_score).and_return(5) allow(review_response_map).to receive(:assignment).and_return(assignment) diff --git a/spec/models/scale_spec.rb b/spec/models/scale_spec.rb new file mode 100644 index 000000000..b3a44b95d --- /dev/null +++ b/spec/models/scale_spec.rb @@ -0,0 +1,81 @@ +require 'rails_helper' + +RSpec.describe Scale, type: :model do + + subject { Scale.new } + + before do + subject.txt = "Rate your experience" + subject.type = "Scale" + subject.weight = 1 + subject.min_label = "Poor" + subject.max_label = "Excellent" + subject.min_question_score = 1 + subject.max_question_score = 5 + subject.answer = 3 + end + + describe "#edit" do + + it 'returns a JSON object with item text, type, weight, and score range' do + scale = Scale.new(txt: 'Scale Item', type: 'scale', weight: 2, min_question_score: 0, max_question_score: 10) + + json_result = scale.edit + + expected_result = { + form: true, + label: "Item:", + input_type: "text", + input_name: "item", + input_value: "Scale Item", + min_question_score: 0, + max_question_score: 10, + weight: 2, + type: 'scale' + }.to_json + expect(json_result).to eq(expected_result) + end + end + + describe "#view_item_text" do + it "returns JSON containing the item text" do + expected_json = { + text: "Rate your experience", + type: "Scale", + weight: 1, + score_range: "1..5" + }.to_json + expect(subject.view_item_text).to eq(expected_json) + end + end + + describe "#complete" do + it "returns JSON with scale options" do + expected_json = { scale_options: [ + { value: 1, selected: false }, + { value: 2, selected: false }, + { value: 3, selected: true }, + { value: 4, selected: false }, + { value: 5, selected: false } + ] }.to_json + expect(subject.complete).to eq(expected_json) + end + end + + describe "#view_completed_item" do + context "when the item has been answered" do + it "returns JSON with the count, answer, and questionnaire_max" do + options = { count: 10, answer: 3, questionnaire_max: 50 } + expected_json = options.to_json + expect(subject.view_completed_item(options)).to eq(expected_json) + end + end + + context "when the item has not been answered" do + it "returns a message indicating the item was not answered" do + expected_json = { message: "Item not answered." }.to_json + expect(subject.view_completed_item).to eq(expected_json) + end + end + end +end \ No newline at end of file diff --git a/spec/models/text_area_spec.rb b/spec/models/text_area_spec.rb new file mode 100644 index 000000000..90ac0ba9e --- /dev/null +++ b/spec/models/text_area_spec.rb @@ -0,0 +1,40 @@ +require 'rails_helper' + +RSpec.describe TextArea do + let(:text_area) { TextArea.create(size: '34,1') } + let!(:answer) { Answer.create(comments: 'test comment') } + + describe '#complete' do + context 'when count is provided' do + it 'generates JSON for a textarea input' do + result = JSON.parse(text_area.complete(1)) + expect(result['action']).to eq('complete') + expect(result['data']['count']).to eq(1) + expect(result['data']['size']).to eq('34,1') + end + + it 'includes any existing comments in the textarea input' do + result = JSON.parse(text_area.complete(1, answer)) + expect(result['data']['comment']).to eq('test comment') + end + end + + context 'when count is not provided' do + it 'generates JSON with default size for the textarea input' do + text_area = TextArea.create(size: nil) + result = JSON.parse(text_area.complete(nil)) + expect(result['data']['size']).to eq('70,1') + end + end + end + + describe 'view_completed_item' do + context 'when given a count and an answer' do + it 'returns the formatted JSON for the completed item' do + result = JSON.parse(text_area.view_completed_item(1, answer)) + expect(result['action']).to eq('view_completed_item') + expect(result['data']['comment']).to eq('test comment') + end + end + end +end \ No newline at end of file diff --git a/spec/models/text_field_spec.rb b/spec/models/text_field_spec.rb new file mode 100644 index 000000000..c39472031 --- /dev/null +++ b/spec/models/text_field_spec.rb @@ -0,0 +1,43 @@ +require 'rails_helper' + +RSpec.describe TextField, type: :model do + let(:item) { TextField.create(txt: 'Sample Text Item', question_type: 'TextField', size: 'medium', break_before: true) } + let(:answer) { Answer.new(comments: 'This is a sample answer.') } + + describe '#complete' do + context 'when count is provided' do + it 'returns JSON data for a paragraph with a label and input fields' do + result = JSON.parse(item.complete(1)) + expect(result['action']).to eq('complete') + expect(result['data']['type']).to eq('text') + expect(result['data']['name']).to eq("response[answers][#{item.id}]") + end + end + + context 'when count and answer are provided' do + it 'returns JSON data with pre-filled comment value' do + result = JSON.parse(item.complete(1, answer)) + expect(result['data']['value']).to eq(answer.comments) + end + end + end + + describe '#view_completed_item' do + context "when the item type is 'TextField' and break_before is true" do + it 'returns the formatted JSON for the completed item' do + result = JSON.parse(item.view_completed_item(1, [])) + expect(result['data']['type']).to eq('text') + expect(result['data']['break_before']).to be true + end + end + + context "when the item type is not 'TextField' or break_before is false" do + let(:non_text_field_question) { TextField.create(txt: 'Non-text item', question_type: 'NotTextField', size: 'small', break_before: false) } + + it 'returns the formatted JSON for the completed item' do + result = JSON.parse(non_text_field_question.view_completed_item(1, [])) + expect(result['data']['break_before']).to be false + end + end + end +end \ No newline at end of file diff --git a/spec/models/text_response_spec.rb b/spec/models/text_response_spec.rb new file mode 100644 index 000000000..41a79fcec --- /dev/null +++ b/spec/models/text_response_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' + +RSpec.describe TextResponse, type: :model do + let(:text_response) { TextResponse.create(seq: '001', txt: 'Sample item content', question_type: 'TextResponse', size: 'medium', weight: 10) } + + describe '#edit' do + let(:result) { JSON.parse(text_response.edit(1)) } + + it 'returns JSON for editing with correct action' do + expect(result["action"]).to eq('edit') + end + + it 'includes elements for editing item' do + expect(result["elements"].length).to be > 0 + end + end + + describe '#view_item_text' do + let(:result) { JSON.parse(text_response.view_item_text) } + + it 'returns JSON for viewing item text with correct action' do + expect(result["action"]).to eq('view_item_text') + end + + it 'includes the item text, question_type, and weight in elements' do + expect(result["elements"].any? { |e| e["value"] == 'Sample item content' }).to be true + expect(result["elements"].any? { |e| e["value"] == 'TextResponse' }).to be true + expect(result["elements"].any? { |e| e["value"].match?(/^\d+$/) }).to be true + end + end + +end \ No newline at end of file diff --git a/spec/requests/api/v1/questions_spec.rb b/spec/requests/api/v1/questions_spec.rb index f8fbfff33..66258b607 100644 --- a/spec/requests/api/v1/questions_spec.rb +++ b/spec/requests/api/v1/questions_spec.rb @@ -1,54 +1,23 @@ require 'swagger_helper' require 'json_web_token' # Rspec tests for questions controller -def setup_instructor - role = Role.find_or_create_by(name: 'Instructor', parent_id: nil) - expect(role).to be_present - - instructor = Instructor.create!( - name: 'testinstructor', - email: 'test@test.com', - full_name: 'Test Instructor', - password: '123456', - role: role - ) - expect(instructor).to be_valid - - instructor -end RSpec.describe 'api/v1/questions', type: :request do before(:all) do - # Create roles in hierarchy - - @super_admin = Role.find_or_create_by(name: 'Super Administrator') - @admin = Role.find_or_create_by(name: 'Administrator', parent_id: @super_admin.id) - @instructor = Role.find_or_create_by(name: 'Instructor', parent_id: @admin.id) - @ta = Role.find_or_create_by(name: 'Teaching Assistant', parent_id: @instructor.id) - @student = Role.find_or_create_by(name: 'Student', parent_id: @ta.id) + @roles = create_roles_hierarchy end - let(:instructor) { setup_instructor } - - let(:prof) { User.create( + let(:instructor) { User.create( name: "profa", password_digest: "password", - role_id: @instructor.id, + role_id: @roles[:instructor].id, full_name: "Prof A", email: "testuser@example.com", mru_directory_path: "/home/testuser", ) } - let(:token) { JsonWebToken.encode({id: prof.id}) } + let(:token) { JsonWebToken.encode({id: instructor.id}) } let(:Authorization) { "Bearer #{token}" } path '/api/v1/questions' do - # Creation of dummy objects for the test with the help of let statements - #let(:role) { Role.create(name: 'Instructor', parent_id: nil, default_page_id: nil) } - - #let(:instructor) do - # role - # Instructor.create(name: 'testinstructor', email: 'test@test.com', full_name: 'Test Instructor', password: '123456', role: role) - #end - let(:questionnaire) do instructor Questionnaire.create( @@ -63,9 +32,9 @@ def setup_instructor let(:question1) do questionnaire - Question.create( + Item.create( seq: 1, - txt: "test question 1", + txt: "test item 1", question_type: "multiple_choice", break_before: true, weight: 5, @@ -75,9 +44,9 @@ def setup_instructor let(:question2) do questionnaire - Question.create( + Item.create( seq: 2, - txt: "test question 2", + txt: "test item 2", question_type: "multiple_choice", break_before: false, weight: 10, @@ -96,7 +65,7 @@ def setup_instructor end end - post('create question') do + post('create item') do tags 'Questions' consumes 'application/json' produces 'application/json' @@ -104,7 +73,7 @@ def setup_instructor let(:valid_question_params) do { questionnaire_id: questionnaire.id, - txt: "test question", + txt: "test item", question_type: "multiple_choice", break_before: false, weight: 10 @@ -114,7 +83,7 @@ def setup_instructor let(:invalid_question_params1) do { questionnaire_id: nil , - txt: "test question", + txt: "test item", question_type: "multiple_choice", break_before: false, weight: 10 @@ -124,14 +93,14 @@ def setup_instructor let(:invalid_question_params2) do { questionnaire_id: questionnaire.id , - txt: "test question", + txt: "test item", question_type: nil, break_before: false, weight: 10 } end - parameter name: :question, in: :body, schema: { + parameter name: :item, in: :body, schema: { type: :object, properties: { weight: { type: :integer }, @@ -143,31 +112,31 @@ def setup_instructor required: %w[weight questionnaire_id break_before txt question_type] } - # post request on /api/v1/questions returns 201 created response and creates a question with given valid parameters + # post request on /api/v1/questions returns 201 created response and creates a item with given valid parameters response(201, 'created') do - let(:question) do + let(:item) do questionnaire - Question.create(valid_question_params) + Item.create(valid_question_params) end run_test! do expect(response.body).to include('"seq":1') end end - # post request on /api/v1/questions returns 404 not found when questionnaire id for the given question is not present in the database + # post request on /api/v1/questions returns 404 not found when questionnaire id for the given item is not present in the database response(404, 'questionnaire id not found') do - let(:question) do + let(:item) do instructor - Question.create(invalid_question_params1) + Item.create(invalid_question_params1) end run_test! end - # post request on /api/v1/questions returns 422 unprocessable entity when incorrect parameters are passed to create a question + # post request on /api/v1/questions returns 422 unprocessable entity when incorrect parameters are passed to create a item response(422, 'unprocessable entity') do - let(:question) do + let(:item) do instructor - Question.create(invalid_question_params2) + Item.create(invalid_question_params2) end run_test! end @@ -179,13 +148,6 @@ def setup_instructor path '/api/v1/questions/{id}' do parameter name: 'id', in: :path, type: :integer - # Creation of dummy objects for the test with the help of let statements - let(:role) { Role.create(name: 'Instructor', parent_id: nil, default_page_id: nil) } - - let(:instructor) do - role - Instructor.create(name: 'testinstructor', email: 'test@test.com', full_name: 'Test Instructor', password: '123456', role: role) - end let(:questionnaire) do instructor @@ -201,9 +163,9 @@ def setup_instructor let(:question1) do questionnaire - Question.create( + Item.create( seq: 1, - txt: "test question 1", + txt: "test item 1", question_type: "multiple_choice", break_before: true, weight: 5, @@ -213,9 +175,9 @@ def setup_instructor let(:question2) do questionnaire - Question.create( + Item.create( seq: 2, - txt: "test question 2", + txt: "test item 2", question_type: "multiple_choice", break_before: false, weight: 10, @@ -232,27 +194,27 @@ def setup_instructor - get('show question') do + get('show item') do tags 'Questions' produces 'application/json' - # get request on /api/v1/questions/{id} returns 200 successful response and returns question with given question id + # get request on /api/v1/questions/{id} returns 200 successful response and returns item with given item id response(200, 'successful') do run_test! do - expect(response.body).to include('"txt":"test question 1"') + expect(response.body).to include('"txt":"test item 1"') end end - # get request on /api/v1/questions/{id} returns 404 not found response when question id is not present in the database + # get request on /api/v1/questions/{id} returns 404 not found response when item id is not present in the database response(404, 'not_found') do let(:id) { 'invalid' } run_test! do - expect(response.body).to include("Couldn't find Question") + expect(response.body).to include("Couldn't find Item") end end end - put('update question') do + put('update item') do tags 'Questions' consumes 'application/json' produces 'application/json' @@ -265,7 +227,7 @@ def setup_instructor } } - # put request on /api/v1/questions/{id} returns 200 successful response and updates parameters of question with given question id + # put request on /api/v1/questions/{id} returns 200 successful response and updates parameters of item with given item id response(200, 'successful') do let(:body_params) do { @@ -277,7 +239,7 @@ def setup_instructor end end - # put request on /api/v1/questions/{id} returns 404 not found response when question with given id is not present in the database + # put request on /api/v1/questions/{id} returns 404 not found response when item with given id is not present in the database response(404, 'not found') do let(:id) { 0 } let(:body_params) do @@ -286,18 +248,18 @@ def setup_instructor } end run_test! do - expect(response.body).to include("Couldn't find Question") + expect(response.body).to include("Not Found") end end - # put request on /api/v1/questions/{id} returns 422 unprocessable entity when incorrect parameters are passed for question with given question id + # put request on /api/v1/questions/{id} returns 422 unprocessable entity when incorrect parameters are passed for item with given item id response(422, 'unprocessable entity') do let(:body_params) do { seq: "Dfsd" } end - schema type: :string + schema type: :object run_test! do expect(response.body).to_not include('"seq":"Dfsd"') end @@ -306,7 +268,7 @@ def setup_instructor end - patch('update question') do + patch('update item') do tags 'Questions' consumes 'application/json' produces 'application/json' @@ -319,7 +281,7 @@ def setup_instructor } } - # patch request on /api/v1/questions/{id} returns 200 successful response and updates parameters of question with given question id + # patch request on /api/v1/questions/{id} returns 200 successful response and updates parameters of item with given item id response(200, 'successful') do let(:body_params) do { @@ -331,7 +293,7 @@ def setup_instructor end end - # patch request on /api/v1/questions/{id} returns 404 not found response when question with given id is not present in the database + # patch request on /api/v1/questions/{id} returns 404 not found response when item with given id is not present in the database response(404, 'not found') do let(:id) { 0 } let(:body_params) do @@ -340,44 +302,42 @@ def setup_instructor } end run_test! do - expect(response.body).to include("Couldn't find Question") + expect(response.body).to include("Couldn't find Item") end end - # patch request on /api/v1/questions/{id} returns 422 unprocessable entity when incorrect parameters are passed for question with given question id + # patch request on /api/v1/questions/{id} returns 422 unprocessable entity when incorrect parameters are passed for item with given item id response(422, 'unprocessable entity') do let(:body_params) do { seq: "Dfsd" } end - schema type: :string + schema type: :object run_test! do expect(response.body).to_not include('"seq":"Dfsd"') end - end - - + end end - delete('delete question') do + delete('delete item') do tags 'Questions' produces 'application/json' - # delete request on /api/v1/questions/{id} returns 204 successful response when it deletes question with given question id present in the database + # delete request on /api/v1/questions/{id} returns 204 successful response when it deletes item with given item id present in the database response(204, 'successful') do run_test! do - expect(Question.exists?(id)).to eq(false) + expect(Item.exists?(id)).to eq(false) end end - # delete request on /api/v1/questions/{id} returns 404 not found response when question with given question id is not present in the database + # delete request on /api/v1/questions/{id} returns 404 not found response when item with given item id is not present in the database response(404, 'not found') do let(:id) { 0 } run_test! do - expect(response.body).to include("Couldn't find Question") + expect(response.body).to include("Couldn't find Item") end end end @@ -409,9 +369,9 @@ def setup_instructor let(:question1) do questionnaire - Question.create( + Item.create( seq: 1, - txt: "test question 1", + txt: "test item 1", question_type: "multiple_choice", break_before: true, weight: 5, @@ -421,9 +381,9 @@ def setup_instructor let(:question2) do questionnaire - Question.create( + Item.create( seq: 2, - txt: "test question 2", + txt: "test item 2", question_type: "multiple_choice", break_before: false, weight: 10, @@ -446,7 +406,7 @@ def setup_instructor # delete method on /api/v1/questions/delete_all/questionnaire/{id} returns 200 successful response when all questions with given questionnaire id are deleted response(200, 'successful') do run_test! do - expect(Question.where(questionnaire_id: id).count).to eq(0) + expect(Item.where(questionnaire_id: id).count).to eq(0) end end @@ -485,9 +445,9 @@ def setup_instructor let(:question1) do questionnaire - Question.create( + Item.create( seq: 1, - txt: "test question 1", + txt: "test item 1", question_type: "multiple_choice", break_before: true, weight: 5, @@ -509,9 +469,9 @@ def setup_instructor let(:question2) do questionnaire2 - Question.create( + Item.create( seq: 2, - txt: "test question 2", + txt: "test item 2", question_type: "multiple_choice", break_before: true, weight: 5, @@ -521,9 +481,9 @@ def setup_instructor let(:question3) do questionnaire2 - Question.create( + Item.create( seq: 3, - txt: "test question 3", + txt: "test item 3", question_type: "multiple_choice", break_before: false, weight: 10, @@ -548,7 +508,7 @@ def setup_instructor # get method on /api/v1/questions/show_all/questionnaire/{id} returns 200 successful response when all questions with given questionnaire id are shown response(200, 'successful') do run_test! do - expect(Question.where(questionnaire_id: id).count).to eq(1) + expect(Item.where(questionnaire_id: id).count).to eq(1) expect(response.body).to_not include('"questionnaire_id: "' + questionnaire2.id.to_s) end end @@ -587,9 +547,9 @@ def setup_instructor let(:question1) do questionnaire - Question.create( + Item.create( seq: 1, - txt: "test question 1", + txt: "test item 1", question_type: "multiple_choice", break_before: true, weight: 5, @@ -599,9 +559,9 @@ def setup_instructor let(:question2) do questionnaire - Question.create( + Item.create( seq: 2, - txt: "test question 2", + txt: "test item 2", question_type: "multiple_choice", break_before: false, weight: 10, @@ -609,7 +569,7 @@ def setup_instructor ) end - get('question types') do + get('item types') do tags 'Questions' produces 'application/json' # get request on /api/v1/questions/types returns types of questions present in the database diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index f23cb7b20..de8081625 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -786,7 +786,7 @@ paths: '200': description: successful post: - summary: create question + summary: create item tags: - Questions parameters: [] @@ -827,7 +827,7 @@ paths: schema: type: integer get: - summary: show question + summary: show item tags: - Questions responses: @@ -836,7 +836,7 @@ paths: '404': description: not_found put: - summary: update question + summary: update item tags: - Questions parameters: [] @@ -862,7 +862,7 @@ paths: seq: type: integer patch: - summary: update question + summary: update item tags: - Questions parameters: [] @@ -888,7 +888,7 @@ paths: seq: type: integer delete: - summary: delete question + summary: delete item tags: - Questions responses: @@ -930,7 +930,7 @@ paths: description: not found "/api/v1/questions/types": get: - summary: question types + summary: item types tags: - Questions responses: