diff --git a/app/models/course.rb b/app/models/course.rb index f33f1875f..8f6d6afb1 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -7,7 +7,7 @@ class Course < ApplicationRecord validates :name, presence: true validates :directory_path, presence: true has_many :participants, class_name: 'CourseParticipant', foreign_key: 'parent_id', dependent: :destroy, inverse_of: :course - has_many :users, through: :course_participants, inverse_of: :course + has_many :users, through: :participants has_many :ta_mappings, dependent: :destroy has_many :tas, through: :ta_mappings, source: :ta has_many :teams, class_name: 'CourseTeam', foreign_key: 'parent_id', dependent: :destroy, inverse_of: :course @@ -21,7 +21,7 @@ def path # Add a Teaching Assistant to the course def add_ta(user) if user.nil? - return { success: false, message: "The user with id #{user.id} does not exist" } + return { success: false, message: "User passed does not exist" } elsif TaMapping.exists?(user_id: user.id, course_id: id) return { success: false, message: "The user with id #{user.id} is already a TA for this course." } else @@ -38,7 +38,7 @@ def add_ta(user) # Removes Teaching Assistant from the course def remove_ta(user_id) - ta_mapping = ta_mappings.find_by(user_id: user_id, course_id: :id) + ta_mapping = ta_mappings.find_by(user_id: user_id, course_id: id) return { success: false, message: "No TA mapping found for the specified course and TA" } if ta_mapping.nil? ta = User.find(ta_mapping.user_id) ta_count = TaMapping.where(user_id: user_id).size - 1 diff --git a/app/models/question_advice.rb b/app/models/question_advice.rb index 76b54c56d..9b298893e 100644 --- a/app/models/question_advice.rb +++ b/app/models/question_advice.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class QuestionAdvice < ApplicationRecord - belongs_to :item + belongs_to :item, foreign_key: :question_id def self.export_fields(_options) QuestionAdvice.columns.map(&:name) end diff --git a/db/schema.rb b/db/schema.rb index cddbe12c6..6a198491c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -456,9 +456,9 @@ add_foreign_key "assignments_duties", "duties" add_foreign_key "courses", "institutions" add_foreign_key "courses", "users", column: "instructor_id" + add_foreign_key "duties", "users", column: "instructor_id" add_foreign_key "invitations", "participants", column: "from_id" add_foreign_key "invitations", "participants", column: "to_id" - add_foreign_key "duties", "users", column: "instructor_id" add_foreign_key "items", "questionnaires" add_foreign_key "participants", "duties" add_foreign_key "participants", "join_team_requests" diff --git a/spec/factories.rb b/spec/factories.rb index 1556b02b2..25ec2d36e 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -3,19 +3,19 @@ FactoryBot.define do factory :student_task do assignment { nil } - current_stage { "MyString" } + current_stage { 'MyString' } participant { nil } - stage_deadline { "2024-04-15 15:55:54" } - topic { "MyString" } + stage_deadline { '2024-04-15 15:55:54' } + topic { 'MyString' } end factory :join_team_request do end factory :bookmark do - url { "MyText" } - title { "MyText" } - description { "MyText" } + url { 'MyText' } + title { 'MyText' } + description { 'MyText' } user_id { 1 } topic_id { 1 } end diff --git a/spec/factories/courses.rb b/spec/factories/courses.rb index f51991f02..370b02f95 100644 --- a/spec/factories/courses.rb +++ b/spec/factories/courses.rb @@ -7,6 +7,6 @@ private { false } directory_path { Faker::File.dir } association :institution, factory: :institution - association :instructor, factory: :user + association :instructor, factory: :instructor end end diff --git a/spec/factories/question_advice.rb b/spec/factories/question_advice.rb new file mode 100644 index 000000000..f0d7ff952 --- /dev/null +++ b/spec/factories/question_advice.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# spec/factories/question_advice.rb +FactoryBot.define do + factory :question_advice do + score {5} + association :item + advice {'default advice'} + end + +end \ No newline at end of file diff --git a/spec/factories/questionnaires.rb b/spec/factories/questionnaires.rb index 71b54a50a..dea2ee05b 100644 --- a/spec/factories/questionnaires.rb +++ b/spec/factories/questionnaires.rb @@ -7,8 +7,7 @@ private { false } min_question_score { 0 } max_question_score { 10 } - association :instructor - association :assignment + association :instructor, factory: :instructor # Trait for questionnaire with questions trait :with_questions do diff --git a/spec/models/course_spec.rb b/spec/models/course_spec.rb index d80d8bd40..08f56d773 100644 --- a/spec/models/course_spec.rb +++ b/spec/models/course_spec.rb @@ -3,30 +3,47 @@ require 'rails_helper' describe Course, type: :model do - let(:role) {Role.create(name: 'Instructor', parent_id: nil, id: 2, default_page_id: nil)} - let(:instructor) { Instructor.create(name: 'testinstructor', email: 'test@test.com', full_name: 'Test Instructor', password: '123456', role_id: role.id) } - let(:institution) { create(:institution, id: 1) } - let(:course) { create(:course, id: 1, name: 'ECE517', instructor: instructor, institution: institution) } - let(:user1) { create(:user, name: 'abcdef', full_name:'abc bbc', email: 'abcbbc@gmail.com', password: '123456789', password_confirmation: '123456789', role: role) } + before do + allow_any_instance_of(User).to receive(:set_defaults) + end + + let(:instructor) { create(:instructor) } + let(:institution) { create(:institution) } + let(:course) { create(:course, name: 'ECE517', instructor: instructor, institution: institution) } + let(:user1) { create(:user, full_name: 'abc bbc', role: create(:role, :instructor)) } describe 'validations' do + # Ensures the course requires a name to be valid. it 'validates presence of name' do course.name = '' expect(course).not_to be_valid end + # Ensures the course requires a directory path to be valid. it 'validates presence of directory_path' do course.directory_path = ' ' expect(course).not_to be_valid end end + + describe 'associations' do + # Ensures users are reachable through course participants. + it 'returns users through participants' do + student = create(:user, :student) + create(:course_participant, course: course, user: student) + + expect(course.users).to include(student) + end + end describe '#path' do context 'when there is no associated instructor' do + # Raises an error if path is requested without an instructor. it 'an error is raised' do allow(course).to receive(:instructor_id).and_return(nil) expect { course.path }.to raise_error('Path can not be created as the course must be associated with an instructor.') end end context 'when there is an associated instructor' do + # Returns a directory path when instructor and institution are present. it 'returns a directory' do allow(course).to receive(:instructor_id).and_return(6) allow(User).to receive(:find).with(6).and_return(user1) @@ -37,4 +54,86 @@ end end + describe '#add_ta' do + let(:ta_role) { create(:role, name: 'Teaching Assistant') } + let(:ta_user) { create(:user, :ta) } + + # Adds a TA mapping and updates the user role to Teaching Assistant. + it 'adds a TA and updates their role' do + ta_role + result = course.add_ta(ta_user) + + expect(result[:success]).to be(true) + expect(result[:data]).to include('course_id' => course.id, 'user_id' => ta_user.id) + expect(ta_user.reload.role.name).to eq('Teaching Assistant') + end + + # Prevents adding a user who is already a TA for the course. + it 'returns an error when the user is already a TA for the course' do + TaMapping.create!(user_id: ta_user.id, course_id: course.id) + result = course.add_ta(ta_user) + + expect(result[:success]).to be(false) + expect(result[:message]).to eq("The user with id #{ta_user.id} is already a TA for this course.") + end + + # Returns a failure response when the user does not exist. + it 'returns an error when the user is nil' do + result = course.add_ta(nil) + + expect(result[:success]).to be(false) + expect(result[:message]).to eq('The user with id does not exist') + end + + # Returns validation errors when the TA mapping cannot be saved. + it 'returns mapping errors when save fails' do + ta_role + ta_mapping = instance_double(TaMapping, save: false, errors: 'invalid mapping') + allow(TaMapping).to receive(:create).and_return(ta_mapping) + + result = course.add_ta(ta_user) + + expect(result[:success]).to be(false) + expect(result[:message]).to eq('invalid mapping') + end + end + + describe '#remove_ta' do + let(:student_role) { create(:role, name: 'Student') } + let(:ta_user) { create(:user, :ta) } + + # Returns an error when no TA mapping exists for the user. + it 'returns an error when no mapping exists' do + result = course.remove_ta(ta_user.id) + expect(result[:success]).to be(false) + expect(result[:message]).to eq('No TA mapping found for the specified course and TA') + end + + # Removes the TA mapping and downgrades the role when it is the last assignment. + it 'removes the mapping and downgrades role when it is the last mapping' do + ta_mapping = TaMapping.create!(user_id: ta_user.id, course_id: course.id) + allow(course.ta_mappings).to receive(:find_by).and_return(ta_mapping) + allow(TaMapping).to receive(:where).with(user_id: ta_user.id).and_return([ta_mapping]) + stub_const('Role::STUDENT', student_role) + + result = course.remove_ta(ta_user.id) + + expect(result[:success]).to be(true) + expect(result[:ta_name]).to eq(ta_user.name) + expect(ta_user.reload.role).to eq(student_role) + end + end + + describe '#copy_course' do + # Creates a duplicate course with updated name and directory path. + it 'creates a copied course with updated name and directory_path' do + result = course.copy_course + + expect(result).to be(true) + copied_course = Course.find_by(name: "#{course.name}_copy") + expect(copied_course).not_to be_nil + expect(copied_course.directory_path).to eq("#{course.directory_path}_copy") + end + end + end diff --git a/spec/models/question_advice_spec.rb b/spec/models/question_advice_spec.rb new file mode 100644 index 000000000..adcf5af40 --- /dev/null +++ b/spec/models/question_advice_spec.rb @@ -0,0 +1,118 @@ + +require 'rails_helper' +describe QuestionAdvice, type: :model do + + before do + allow_any_instance_of(User).to receive(:set_defaults) + end + let(:instructor) { create(:instructor) } + let(:questionnaire) do + create(:questionnaire, + name: 'abc', + private: false, + min_question_score: 0, + max_question_score: 10, + instructor: instructor) + end + + let(:question1) do + create(:item, questionnaire: questionnaire, weight: 1, seq: 1, txt: 'que 1', question_type: 'scale') + end + let(:question2) do + create(:item, questionnaire: questionnaire, weight: 10, seq: 2, txt: 'que 2', question_type: 'multiple_choice') + end + + let(:question_advice1) do + create(:question_advice, question_id: question1.id) + end + + let(:question_advice2) do + create(:question_advice, question_id: question2.id, advice: 'advice for question 2', score: 6) + end + + let(:question_advice3) do + create(:question_advice, question_id: question1.id, advice: 'advice for question 3', score: 1) + end + + describe '#question association' do + it 'returns the associated question of QuestionAdvice' do + expect(question_advice1.question_id).to eq(question1.id) + expect(question_advice2.question_id).to eq(question2.id) + expect(question_advice3.question_id).to eq(question1.id) + end + + end + + describe '#score' do + it 'returns the score of QuestionAdvice' do + expect(question_advice1.score).to eq(5) + expect(question_advice2.score).to eq(6) + expect(question_advice3.score).to eq(1) + end + + end + + describe '#advice' do + it 'returns the advice of QuestionAdvice' do + expect(question_advice1.advice).to eq('default advice') + expect(question_advice2.advice).to eq('advice for question 2') + expect(question_advice3.advice).to eq('advice for question 3') + end + + end + + describe '#test export_fields' do + it 'make sure that the columns of QuestionAdvice are properly being mapped' do + output = QuestionAdvice.export_fields(options = {}) + expect(output).to eq(["id", "question_id", "score", "advice", "created_at", "updated_at"]) + end + end + + describe '#test export' do + it 'test the export method to see if values are being properly stored ' do + csv = [] + + expect(question1.questionnaire).to eq(questionnaire) + expect(question2.questionnaire).to eq(questionnaire) + + expect(question_advice1.advice).to eq('default advice') + expect(question_advice2.advice).to eq('advice for question 2') + expect(question_advice3.advice).to eq('advice for question 3') + + + QuestionAdvice.export(csv, questionnaire.id, nil) + + expect(csv.length).to eq(3) + end + end + + describe '#test to_json_by_question_id' do + it 'verify json output with QuestionAdvice entries associated with question 1' do + + #instantiate the question_advice table + expect(question_advice1.question_id).to eq(question1.id) + expect(question_advice3.question_id).to eq(question1.id) + + output = QuestionAdvice.where(question_id: question1.id).first + + + expect(output.question_id).to eq(question1.id) + + output1 = QuestionAdvice.to_json_by_question_id(question1.id) + + expect(output1).to eq([ + { score: 5, advice: 'default advice' }, + { score: 1, advice: 'advice for question 3' } + ]) + end + + it 'verify json output with QuestionAdvice entries associated with question 2' do + expect(question_advice2.question_id).to eq(question2.id) + output = QuestionAdvice.to_json_by_question_id(question2.id) + expect(output).to eq([ + { score: 6, advice: 'advice for question 2'} + ]) + end + + end +end \ No newline at end of file diff --git a/spec/models/questionnaire_spec.rb b/spec/models/questionnaire_spec.rb index 7c308902b..2dfd24446 100644 --- a/spec/models/questionnaire_spec.rb +++ b/spec/models/questionnaire_spec.rb @@ -2,26 +2,31 @@ require 'rails_helper' describe Questionnaire, 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) { Instructor.create(name: 'testinstructor', email: 'test@test.com', full_name: 'Test Instructor', password: '123456', role_id: role.id) } + before do + allow_any_instance_of(User).to receive(:set_defaults) + end + let(:instructor) { create(:instructor) } let(:questionnaire) do - Questionnaire.create!( - id: 1, - name: 'abc', - private: false, - min_question_score: 0, - max_question_score: 10, - instructor: instructor, - ) + create(:questionnaire, + name: 'abc', + private: false, + min_question_score: 0, + max_question_score: 10, + instructor: instructor) + end + let(:questionnaire1) do + build(:questionnaire, name: 'xyz', private: 0, max_question_score: 20, instructor: instructor) + end + let(:questionnaire2) do + build(:questionnaire, name: 'pqr', private: 0, max_question_score: 10, instructor: instructor) + end + let(:question1) do + create(:item, questionnaire: questionnaire, weight: 1, seq: 1, txt: 'que 1', question_type: 'scale') + end + let(:question2) do + create(:item, questionnaire: questionnaire, weight: 10, seq: 2, txt: 'que 2', question_type: 'multiple_choice') 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.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) } - - describe '#name' do # Test validates the name of the questionnaire @@ -98,14 +103,14 @@ questionnaire.min_question_score = 'a' expect(questionnaire).not_to be_valid end - end - describe 'associations' do # Test validates the association that a questionnaire comprises of several questions it 'has many questions' do - expect(questionnaire.items).to include(question1, question2) + question1 + question2 + expect(questionnaire.items.reload).to include(question1, question2) end end @@ -113,16 +118,15 @@ # 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(Item).to receive(:where).with(questionnaire_id: '1').and_return([Item]) + allow(Item).to receive(:where).with(questionnaire_id: '1').and_return([question1, question2]) end - + # Test ensures creation of a copy of given questionnaire it 'creates a copy of the questionnaire' do - instructor.save! - questionnaire.save! - question1.save! - question2.save! - copied_questionnaire = Questionnaire.copy_questionnaire_details( { id: questionnaire.id, instructor_id: instructor.id}) + question1 + question2 + copied_questionnaire = Questionnaire.copy_questionnaire_details({ id: questionnaire.id, + instructor_id: instructor.id }) expect(copied_questionnaire.instructor_id).to eq(questionnaire.instructor_id) expect(copied_questionnaire.name).to eq("Copy of #{questionnaire.name}") expect(copied_questionnaire.created_at).to be_within(1.second).of(Time.zone.now) @@ -130,15 +134,141 @@ # Test ensures creation of copy of all the present questionnaire in the database it 'creates a copy of all questions belonging to the original questionnaire' do - instructor.save! - questionnaire.save! - question1.save! - question2.save! - copied_questionnaire = described_class.copy_questionnaire_details({ id: questionnaire.id, instructor_id: instructor.id }) + question1 + question2 + copied_questionnaire = described_class.copy_questionnaire_details({ id: questionnaire.id, + instructor_id: instructor.id }) 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 + + # Ensures advice rows are duplicated along with copied questions. + it 'copies advice rows when original question has advice' do + question1 + QuestionAdvice.insert( + { + question_id: question1.id, + score: 3, + advice: 'Good rationale', + created_at: Time.zone.now, + updated_at: Time.zone.now + } + ) + + copied_questionnaire = described_class.copy_questionnaire_details({ id: questionnaire.id, + instructor_id: instructor.id }) + copied_question = copied_questionnaire.items.first + + copied_advice = QuestionAdvice.where(question_id: copied_question.id) + expect(copied_advice.count).to eq(1) + expect(copied_advice.first.score).to eq(3) + expect(copied_advice.first.advice).to eq('Good rationale') + end + end + + describe '#symbol' do + # Confirms the questionnaire symbol reflects the review type. + it 'returns review symbol' do + expect(questionnaire.symbol).to eq(:review) + end + end + + describe '#get_assessments_for' do + # Verifies assessments are proxied from the participant reviews collection. + it 'returns participant reviews' do + reviews = [double('review1'), double('review2')] + participant = double('participant', reviews: reviews) + expect(questionnaire.get_assessments_for(participant)).to eq(reviews) + end end -end \ No newline at end of file + describe '#check_for_question_associations' do + # Prevents deletion when the questionnaire still has items. + it 'raises delete restriction when items exist' do + question1 + questionnaire.items.reload + expect { questionnaire.check_for_question_associations }.to raise_error(ActiveRecord::DeleteRestrictionError) + end + + # Allows deletion when there are no associated items. + it 'does not raise when no items exist' do + questionnaire + expect { questionnaire.check_for_question_associations }.not_to raise_error + end + end + + describe '#as_json' do + # Ensures JSON serialization includes the instructor field. + it 'returns serialized hash with instructor key' do + json = questionnaire.as_json + expect(json).to include('id', 'name', 'instructor') + end + end + + describe '#get_weighted_score' do + let(:assignment) { double('assignment', id: 42) } + + # Uses the base symbol when no round-specific questionnaire is set. + it 'uses base symbol when used_in_round is nil' do + aq = double('assignment_questionnaire', used_in_round: nil) + allow(AssignmentQuestionnaire).to receive(:find_by).with(assignment_id: 42, + questionnaire_id: questionnaire.id).and_return(aq) + expect(questionnaire).to receive(:compute_weighted_score).with(:review, assignment, {}) + questionnaire.get_weighted_score(assignment, {}) + end + + # Uses a round-specific symbol when used_in_round is provided. + it 'uses round specific symbol when used_in_round is present' do + aq = double('assignment_questionnaire', used_in_round: 2) + allow(AssignmentQuestionnaire).to receive(:find_by).with(assignment_id: 42, + questionnaire_id: questionnaire.id).and_return(aq) + expect(questionnaire).to receive(:compute_weighted_score).with(:review2, assignment, {}) + questionnaire.get_weighted_score(assignment, {}) + end + end + + describe '#compute_weighted_score' do + let(:assignment) { double('assignment', id: 77) } + + # Handles missing averages by returning zero. + it 'returns 0 when average score is nil' do + allow(AssignmentQuestionnaire).to receive(:find_by).with(assignment_id: 77).and_return(double('aq', + questionnaire_weight: 25)) + scores = { review: { scores: { avg: nil } } } + expect(questionnaire.compute_weighted_score(:review, assignment, scores)).to eq(0) + end + + # Computes weighted average when an average score is present. + it 'returns weighted average when avg exists' do + allow(AssignmentQuestionnaire).to receive(:find_by).with(assignment_id: 77).and_return(double('aq', + questionnaire_weight: 25)) + scores = { review: { scores: { avg: 8 } } } + expect(questionnaire.compute_weighted_score(:review, assignment, scores)).to eq(2.0) + end + end + + describe '#true_false_items?' do + # Detects checkbox items as true/false questions. + it 'returns true when a checkbox item is present' do + allow(questionnaire).to receive(:items).and_return([double('item', type: 'Checkbox')]) + expect(questionnaire.true_false_items?).to be(true) + end + + # Returns false when no checkbox items exist. + it 'returns false when no checkbox item is present' do + allow(questionnaire).to receive(:items).and_return([double('item', type: 'Criterion')]) + expect(questionnaire.true_false_items?).to be(false) + end + end + + describe '#max_possible_score' do + # Calculates max possible score from weights and max score. + it 'returns sum of item weights multiplied by max_question_score' do + question1 + question2 + + expect(questionnaire.max_possible_score.to_i).to eq(110) + end + end +end