From 79f58d0ae9726ee56b4d2ccbfafadc8ca9a55bd8 Mon Sep 17 00:00:00 2001 From: dzhuang Date: Thu, 10 Aug 2017 18:49:17 +0800 Subject: [PATCH] Refactor unittests. --- requirements.txt | 3 + run-tests-for-ci.sh | 1 + test/base_test_mixins.py | 257 +++++++++++++ test/test_grade_instructor.py | 41 --- test/test_grade_ta.py | 52 --- test/{base_grade_tests.py => test_grades.py} | 365 +++++++++---------- test/{test_course.py => test_pages.py} | 79 ++-- test/{test_other.py => test_sandbox.py} | 68 ++-- 8 files changed, 481 insertions(+), 385 deletions(-) create mode 100644 test/base_test_mixins.py delete mode 100644 test/test_grade_instructor.py delete mode 100644 test/test_grade_ta.py rename test/{base_grade_tests.py => test_grades.py} (57%) rename test/{test_course.py => test_pages.py} (76%) rename test/{test_other.py => test_sandbox.py} (53%) diff --git a/requirements.txt b/requirements.txt index 036f50f2..1f8d9435 100644 --- a/requirements.txt +++ b/requirements.txt @@ -109,4 +109,7 @@ pytools # For mypy (static type checking) support typing +# For unittest by mock (only required for Py2) +# mock + # vim: foldmethod=marker diff --git a/run-tests-for-ci.sh b/run-tests-for-ci.sh index 898a0bba..0d36dd9b 100644 --- a/run-tests-for-ci.sh +++ b/run-tests-for-ci.sh @@ -59,6 +59,7 @@ PIP="${PY_EXE} $(which pip)" grep -v dnspython requirements.txt > req.txt if [[ "$PY_EXE" = python2* ]]; then $PIP install dnspython + $PIP install mock else $PIP install dnspython3 fi diff --git a/test/base_test_mixins.py b/test/base_test_mixins.py new file mode 100644 index 00000000..6925d7e9 --- /dev/null +++ b/test/base_test_mixins.py @@ -0,0 +1,257 @@ +from __future__ import division + +__copyright__ = "Copyright (C) 2017 Dong Zhuang, Andreas Kloeckner, Zesheng Wang" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +import os +from django.conf import settings +from django.test import Client +from django.urls import reverse +from django.contrib.auth import get_user_model +from course.models import Course, Participation, ParticipationRole +from course.constants import participation_status + +CREATE_SUPERUSER_KWARGS = { + "username": "test_admin", + "password": "test_admin", + "email": "test_admin@example.com", + "first_name": "Test", + "last_name": "Admin"} + +SINGLE_COURSE_SETUP_LIST = [ + { + "course": { + "identifier": "test-course", + "name": "Test Course", + "number": "CS123", + "time_period": "Fall 2016", + "hidden": False, + "listed": True, + "accepts_enrollment": True, + "git_source": "git://github.com/inducer/relate-sample", + "course_file": "course.yml", + "events_file": "events.yml", + "enrollment_approval_required": False, + "enrollment_required_email_suffix": None, + "from_email": "inform@tiker.net", + "notify_email": "inform@tiker.net"}, + "participations": [ + { + "role_identifier": "instructor", + "user": { + "username": "test_instructor", + "password": "test_instructor", + "email": "test_instructor@example.com", + "first_name": "Test", + "last_name": "Instructor"}, + "status": participation_status.active + }, + { + "role_identifier": "ta", + "user": { + "username": "test_ta", + "password": "test", + "email": "test_ta@example.com", + "first_name": "Test", + "last_name": "TA"}, + "status": participation_status.active + }, + { + "role_identifier": "student", + "user": { + "username": "test_student", + "password": "test", + "email": "test_student@example.com", + "first_name": "Test", + "last_name": "Student"}, + "status": participation_status.active + } + ] + } +] + + +def force_remove_path(path): + # shutil.rmtree won't work when delete course repo folder, on Windows, + # so it cause all testcases failed. + # Though this work around (copied from http://bit.ly/2usqGxr) still fails + # for some tests, this enables **some other** tests on Windows. + import stat + def remove_readonly(func, path, _): # noqa + os.chmod(path, stat.S_IWRITE) + func(path) + + import shutil + shutil.rmtree(path, onerror=remove_readonly) + + +class SuperuserCreateMixin(object): + create_superuser_kwargs = CREATE_SUPERUSER_KWARGS + + @classmethod + def setUpTestData(cls): # noqa + # Create superuser, without this, we cannot + # create user, course and participation. + cls.superuser = cls.create_superuser() + cls.c = Client() + super(SuperuserCreateMixin, cls).setUpTestData() + + @classmethod + def tearDownClass(cls): # noqa + super(SuperuserCreateMixin, cls).tearDownClass() + + @classmethod + def create_superuser(cls): + return get_user_model().objects.create_superuser( + **cls.create_superuser_kwargs) + + +class CoursesTestMixinBase(SuperuserCreateMixin): + + # A list of Dicts, each of which contain a course dict and a list of + # participations. See SINGLE_COURSE_SETUP_LIST for the setup for one course. + courses_setup_list = [] + + @classmethod + def setUpTestData(cls): # noqa + super(CoursesTestMixinBase, cls).setUpTestData() + cls.n_courses = 0 + for course_setup in cls.courses_setup_list: + if "course" not in course_setup: + continue + + cls.n_courses += 1 + course_identifier = course_setup["course"]["identifier"] + cls.remove_exceptionally_undelete_course_repos(course_identifier) + cls.create_course(**course_setup["course"]) + course = Course.objects.get(identifier=course_identifier) + if "participations" in course_setup: + for participation in course_setup["participations"]: + create_user_kwargs = participation.get("user") + if not create_user_kwargs: + continue + role_identifier = participation.get("role_identifier") + if not role_identifier: + continue + cls.create_participation( + course=course, + create_user_kwargs=create_user_kwargs, + role_identifier=role_identifier, + status=participation.get("status", + participation_status.active) + ) + + # Remove superuser from participation for further test + # such as impersonate in auth module + if role_identifier == "instructor": + try: + superuser_participation = ( + Participation.objects.get(user=cls.superuser)) + Participation.delete(superuser_participation) + except Participation.DoesNotExist: + pass + cls.course_qset = Course.objects.all() + + @classmethod + def remove_exceptionally_undelete_course_repos(cls, course_identifier): + # Remove undelete course repo folders coursed by + # unexpected exceptions in previous tests. + try: + force_remove_path(os.path.join(settings.GIT_ROOT, course_identifier)) + except OSError: + pass + + @classmethod + def remove_course_repo(cls, course): + from course.content import get_course_repo_path + repo_path = get_course_repo_path(course) + force_remove_path(repo_path) + + @classmethod + def tearDownClass(cls): + cls.c.logout() + # Remove repo folder for all courses + for course in Course.objects.all(): + cls.remove_course_repo(course) + super(CoursesTestMixinBase, cls).tearDownClass() + + @classmethod + def create_participation( + cls, course, create_user_kwargs, role_identifier, status): + try: + # TODO: why pop failed here? + password = create_user_kwargs["password"] + except: + raise + user, created = get_user_model().objects.get_or_create(**create_user_kwargs) + if created: + user.set_password(password) + user.save() + participation, p_created = Participation.objects.get_or_create( + user=user, + course=course, + status=status + ) + if p_created: + role = ParticipationRole.objects.filter( + course=course, identifier=role_identifier) + participation.roles.set(role) + return participation + + @classmethod + def create_course(cls, **create_course_kwargs): + cls.c.force_login(cls.superuser) + cls.c.post(reverse("relate-set_up_new_course"), create_course_kwargs) + + +class SingleCourseTestMixin(CoursesTestMixinBase): + courses_setup_list = SINGLE_COURSE_SETUP_LIST + + @classmethod + def setUpTestData(cls): # noqa + super(SingleCourseTestMixin, cls).setUpTestData() + cls.course = cls.course_qset.first() + cls.instructor_participation = Participation.objects.filter( + course=cls.course, + roles__identifier="instructor", + status=participation_status.active + ).first() + assert cls.instructor_participation + + cls.student_participation = Participation.objects.filter( + course=cls.course, + roles__identifier="student", + status=participation_status.active + ).first() + assert cls.student_participation + + cls.ta_participation = Participation.objects.filter( + course=cls.course, + roles__identifier="ta", + status=participation_status.active + ).first() + assert cls.ta_participation + cls.c.logout() + + @classmethod + def tearDownClass(cls): + super(SingleCourseTestMixin, cls).tearDownClass() diff --git a/test/test_grade_instructor.py b/test/test_grade_instructor.py deleted file mode 100644 index cd8ad7bd..00000000 --- a/test/test_grade_instructor.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import division - -__copyright__ = "Copyright (C) 2014 Andreas Kloeckner" - -__license__ = """ -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -""" - -import shutil -from base_grade_tests import BaseGradeTest -from django.test import TestCase - - -class InstructorGradeTest(BaseGradeTest, TestCase): - @classmethod - def setUpTestData(cls): # noqa - super(InstructorGradeTest, cls).setUpTestData() - cls.do_quiz(cls.admin) - cls.datas["accounts"] = 2 - - @classmethod - def tearDownClass(cls): - # Remove created folder - shutil.rmtree('../' + cls.datas["course_identifier"]) - super(InstructorGradeTest, cls).tearDownClass() diff --git a/test/test_grade_ta.py b/test/test_grade_ta.py deleted file mode 100644 index 901868fb..00000000 --- a/test/test_grade_ta.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import division - -__copyright__ = "Copyright (C) 2017 Zesheng Wang" - -__license__ = """ -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -""" - -import shutil -from base_grade_tests import BaseGradeTest -from django.test import TestCase -from accounts.models import User - - -class TAGradeTest(BaseGradeTest, TestCase): - @classmethod - def setUpTestData(cls): # noqa - super(TAGradeTest, cls).setUpTestData() - # TA account - cls.ta = User.objects.create_user( - username="ta1", - password="test", - email="ta1@example.com", - first_name="TA", - last_name="Tester") - cls.ta.save() - - cls.do_quiz(cls.ta, "ta") - cls.do_quiz(cls.admin) - cls.datas["accounts"] = 3 - - @classmethod - def tearDownClass(cls): - # Remove created folder - shutil.rmtree('../' + cls.datas["course_identifier"]) - super(TAGradeTest, cls).tearDownClass() diff --git a/test/base_grade_tests.py b/test/test_grades.py similarity index 57% rename from test/base_grade_tests.py rename to test/test_grades.py index 0504e4b8..90f7bd06 100644 --- a/test/base_grade_tests.py +++ b/test/test_grades.py @@ -1,6 +1,6 @@ from __future__ import division -__copyright__ = "Copyright (C) 2017 Zesheng Wang" +__copyright__ = "Copyright (C) 2017 Zesheng Wang, Andreas Kloeckner" __license__ = """ Permission is hereby granted, free of charge, to any person obtaining a copy @@ -22,184 +22,121 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -# For compatibility between Python 2 and Python 3 -try: - import cStringIO -except ImportError: - import io as cStringIO +from django.urls import reverse, resolve +from base_test_mixins import SingleCourseTestMixin -import csv -from django.test import Client -from django.urls import resolve, reverse -from accounts.models import User -from course.models import FlowSession, Course, GradingOpportunity, \ - Participation, FlowRuleException, ParticipationRole, \ - GradeChange +from django.test import TestCase +from course.models import ( + Participation, GradingOpportunity, FlowSession, + FlowRuleException, GradeChange +) -# This serve as a base test cases for other grade tests to subclass -# Nice little tricks :) -class BaseGradeTest(object): +class GradeTestMixin(SingleCourseTestMixin): + # This serve as a base test cases for other grade tests to subclass + # Nice little tricks :) @classmethod def setUpTestData(cls): # noqa - # Set up data for the whole TestCase - # Admin account - cls.admin = User.objects.create_superuser( - username="testadmin", - password="test", - email="testadmin@example.com", - first_name="Test", - last_name="Admin") - cls.admin.save() - - # Student account - cls.student = User.objects.create_user( - username="tester1", - password="test", - email="tester1@example.com", - first_name="Student", - last_name="Tester") - cls.student.save() - - # Create the course here and check later to - # avoid exceptions raised here - cls.c = Client() - cls.c.login( - username="testadmin", - password="test") - cls.c.post("/new-course/", dict( - identifier="test-course", - name="Test Course", - number="CS123", - time_period="Fall 2016", - hidden=False, - listed=True, - accepts_enrollment=True, - git_source="git://github.com/inducer/relate-sample", - course_file="course.yml", - events_file="events.yml", - enrollment_approval_required=False, - enrollment_required_email_suffix=None, - from_email="inform@tiker.net", - notify_email="inform@tiker.net")) - - cls.course = Course.objects.all()[0] + super(GradeTestMixin, cls).setUpTestData() + # Some classwise sharing data - cls.datas = {"course_identifier": cls.course.identifier, - "flow_id": "quiz-test"} - cls.datas["flow_session_id"] = [] + cls.data = {"course_identifier": cls.course.identifier, + "flow_id": "quiz-test"} + cls.data["flow_session_id"] = [] + + cls.do_quiz(cls.student_participation) - # Make sure admin is logged in after all this in all sub classes - # Student takes quiz anyway - cls.do_quiz(cls.student, "student") + @classmethod + def tearDownClass(cls): + super(GradeTestMixin, cls).tearDownClass() # Use specified user to take a quiz @classmethod - def do_quiz(cls, user, assign_role=None): + def do_quiz(cls, participation): # Login user first - cls.c.logout() - cls.c.login( - username=user.username, - password="test") - - # Enroll if not admin - # Little hacky for not using enrollment view - if assign_role: - participation = Participation() - participation.user = user - participation.course = Course.objects.filter( - identifier=cls.datas["course_identifier"])[0] - participation.status = "active" - participation.save() - - if assign_role == "student": - role = ParticipationRole.objects.filter(id=3)[0] - elif assign_role == "ta": - role = ParticipationRole.objects.filter(id=2)[0] - participation.roles.add(role) - - params = cls.datas.copy() + cls.c.force_login(participation.user) + + params = cls.data.copy() del params["flow_session_id"] resp = cls.c.post(reverse("relate-view_start_flow", kwargs=params)) - # Yep, no regax! + # Yep, no regex! _, _, kwargs = resolve(resp.url) # Store flow_session_id - cls.datas["flow_session_id"].append(int(kwargs["flow_session_id"])) + cls.data["flow_session_id"].append(int(kwargs["flow_session_id"])) # Let it raise error # Use pop() will not del kwargs["ordinal"] - resp = cls.c.post(reverse("relate-finish_flow_session_view", + cls.c.post(reverse("relate-finish_flow_session_view", kwargs=kwargs), {'submit': ['']}) # Seperate the test here def test_grading_opportunity(self): # Should only have one grading opportunity object - self.assertEqual(len(GradingOpportunity.objects.all()), 1) + self.assertEqual(GradingOpportunity.objects.all().count(), 1) def test_view_my_grade(self): resp = self.c.get(reverse("relate-view_participant_grades", - args=[self.datas["course_identifier"]])) + args=[self.course.identifier])) self.assertEqual(resp.status_code, 200) def test_view_participant_grades(self): - params = {"course_identifier": self.datas["course_identifier"], - "participation_id": self.admin.id} + params = {"course_identifier": self.course.identifier, + "participation_id": self.instructor_participation.user.id} resp = self.c.get(reverse("relate-view_participant_grades", kwargs=params)) self.assertEqual(resp.status_code, 200) def test_view_participant_list(self): resp = self.c.get(reverse("relate-view_participant_list", - args=[self.datas["course_identifier"]])) + args=[self.course.identifier])) self.assertEqual(resp.status_code, 200) def test_view_grading_opportunity_list(self): resp = self.c.get(reverse("relate-view_grading_opportunity_list", - args=[self.datas["course_identifier"]])) + args=[self.course.identifier])) self.assertEqual(resp.status_code, 200) def test_view_gradebook(self): resp = self.c.get(reverse("relate-view_gradebook", - args=[self.datas["course_identifier"]])) + args=[self.course.identifier])) self.assertEqual(resp.status_code, 200) def test_view_export_gradebook_csv(self): resp = self.c.get(reverse("relate-export_gradebook_csv", - args=[self.datas["course_identifier"]])) + args=[self.course.identifier])) self.assertEqual(resp.status_code, 200) self.assertEqual(resp["Content-Disposition"], - 'attachment; filename="grades-test-course.csv"') + 'attachment; filename="grades-test-course.csv"') def test_view_grades_by_opportunity(self): # Check attributes - self.assertEqual(len(GradingOpportunity.objects.all()), 1) - opportunity = GradingOpportunity.objects.all()[0] + self.assertEqual(GradingOpportunity.objects.all().count(), 1) + opportunity = GradingOpportunity.objects.first() self.assertEqual(self.course, opportunity.course) - self.assertEqual(self.datas["flow_id"], opportunity.flow_id) + self.assertEqual(self.data["flow_id"], opportunity.flow_id) # Check page - params = {"course_identifier": self.datas["course_identifier"], - "opp_id": opportunity.id} + params = {"course_identifier": self.course.identifier, + "opp_id": opportunity.id} resp = self.c.get(reverse("relate-view_grades_by_opportunity", - kwargs=params)) + kwargs=params)) self.assertEqual(resp.status_code, 200) def test_view_participant_grade_by_opportunity(self): # Check attributes - self.assertEqual(len(GradingOpportunity.objects.all()), 1) - opportunity = GradingOpportunity.objects.all()[0] + self.assertEqual(GradingOpportunity.objects.all().count(), 1) + opportunity = GradingOpportunity.objects.first() self.assertEqual(self.course, opportunity.course) - self.assertEqual(self.datas["flow_id"], opportunity.flow_id) + self.assertEqual(self.data["flow_id"], opportunity.flow_id) # Check page - params = {"course_identifier": self.datas["course_identifier"], - "opportunity_id": opportunity.id, - "participation_id": self.admin.id} - resp = self.c.get(reverse("relate-view_single_grade", - kwargs=params)) + params = {"course_identifier": self.course.identifier, + "opportunity_id": opportunity.id, + "participation_id": self.student_participation.id} + resp = self.c.get(reverse("relate-view_single_grade", kwargs=params)) self.assertEqual(resp.status_code, 200) def test_view_reopen_session(self): @@ -207,36 +144,39 @@ class BaseGradeTest(object): self.assertEqual(len(GradingOpportunity.objects.all()), 1) opportunity = GradingOpportunity.objects.all()[0] self.assertEqual(self.course, opportunity.course) - self.assertEqual(self.datas["flow_id"], opportunity.flow_id) + self.assertEqual(self.data["flow_id"], opportunity.flow_id) all_session = FlowSession.objects.all() # Check flow numbers - self.assertEqual(len(all_session), - len(self.datas["flow_session_id"])) + self.assertEqual(len(all_session), len(self.data["flow_session_id"])) # Check each flow session for session in all_session: self.check_reopen_session(session.id, opportunity.id) # Check flow numbers again - self.assertEqual(len(FlowSession.objects.all()), - len(self.datas["flow_session_id"])) + self.assertEqual(FlowSession.objects.all().count(), + len(self.data["flow_session_id"])) def test_view_import_grades_without_header(self): - csv_datas = [("testadmin", 99, "Almost!"), - ("tester1", 50, "I hate this course :(")] - self.check_import_grade(csv_datas) + csv_data = [(self.instructor_participation.user.username, + 99, "Almost!"), + (self.student_participation.user.username, + 50, "I hate this course :(")] + self.check_import_grade(csv_data) def test_view_import_grades_with_header(self): - csv_datas = [("username", "grade", "feedback"), - ("testadmin", 99, "Almost!"), - ("tester1", 50, "I hate this course :(")] - self.check_import_grade(csv_datas, True) + csv_data = [("username", "grade", "feedback"), + (self.instructor_participation.user.username, + 99, "Almost!"), + (self.student_participation.user.username, + 50, "I hate this course :(")] + self.check_import_grade(csv_data, True) # Seems just show the answer def test_view_grade_flow_page(self): - params = {"course_identifier": self.datas["course_identifier"], - "flow_session_id": self.datas["flow_session_id"][0]} + params = {"course_identifier": self.course.identifier, + "flow_session_id": self.data["flow_session_id"][0]} for i in range(18): params["page_ordinal"] = str(i) resp = self.c.get(reverse("relate-grade_flow_page", @@ -244,15 +184,15 @@ class BaseGradeTest(object): self.assertEqual(resp.status_code, 200) def test_view_grader_statistics(self): - params = {"course_identifier": self.datas["course_identifier"], - "flow_id": self.datas["flow_id"]} + params = {"course_identifier": self.course.identifier, + "flow_id": self.data["flow_id"]} resp = self.c.get(reverse("relate-show_grader_statistics", kwargs=params)) self.assertEqual(resp.status_code, 200) def test_view_download_submissions(self): - params = {"course_identifier": self.datas["course_identifier"], - "flow_id": self.datas["flow_id"]} + params = {"course_identifier": self.course.identifier, + "flow_id": self.data["flow_id"]} # Check download form first resp = self.c.get(reverse("relate-download_all_submissions", @@ -261,18 +201,20 @@ class BaseGradeTest(object): # Check download here, only test intro page # Maybe we should include an "all" option in the future? - datas = {'restrict_to_rules_tag': ['<<>>'], 'which_attempt': ['last'], + data = {'restrict_to_rules_tag': ['<<>>'], + 'which_attempt': ['last'], 'extra_file': [''], 'download': ['Download'], - 'page_id': ['intro/welcome'], 'non_in_progress_only': ['on']} + 'page_id': ['intro/welcome'], + 'non_in_progress_only': ['on']} resp = self.c.post(reverse("relate-download_all_submissions", - kwargs=params), datas) + kwargs=params), data) self.assertEqual(resp.status_code, 200) prefix, zip_file = resp["Content-Disposition"].split('=') self.assertEqual(prefix, "attachment; filename") zip_file_name = zip_file.replace('"', '').split('_') self.assertEqual(zip_file_name[0], "submissions") - self.assertEqual(zip_file_name[1], self.datas["course_identifier"]) - self.assertEqual(zip_file_name[2], self.datas["flow_id"]) + self.assertEqual(zip_file_name[1], self.course.identifier) + self.assertEqual(zip_file_name[2], self.data["flow_id"]) self.assertEqual(zip_file_name[3], "intro") self.assertEqual(zip_file_name[4], "welcome") self.assertTrue(zip_file_name[5].endswith(".zip")) @@ -282,9 +224,9 @@ class BaseGradeTest(object): self.assertEqual(len(GradingOpportunity.objects.all()), 1) opportunity = GradingOpportunity.objects.all()[0] self.assertEqual(self.course, opportunity.course) - self.assertEqual(self.datas["flow_id"], opportunity.flow_id) + self.assertEqual(self.data["flow_id"], opportunity.flow_id) - params = {"course_identifier": self.datas["course_identifier"], + params = {"course_identifier": self.course.identifier, "opportunity_id": opportunity.id} # Check page resp = self.c.get(reverse("relate-edit_grading_opportunity", @@ -292,7 +234,7 @@ class BaseGradeTest(object): self.assertEqual(resp.status_code, 200) # Try making a change self.assertEqual(opportunity.page_scores_in_participant_gradebook, False) - datas = {'page_scores_in_participant_gradebook': ['on'], + data = {'page_scores_in_participant_gradebook': ['on'], 'name': ['Flow: RELATE Test Quiz'], 'hide_superseded_grade_history_before': [''], 'submit': ['Update'], @@ -301,7 +243,7 @@ class BaseGradeTest(object): 'shown_in_grade_book': ['on'], 'result_shown_in_participant_grade_book': ['on']} resp = self.c.post(reverse("relate-edit_grading_opportunity", - kwargs=params), datas) + kwargs=params), data) self.assertEqual(resp.status_code, 302) self.assertEqual(resp.url, reverse("relate-edit_grading_opportunity", kwargs=params)) @@ -311,18 +253,18 @@ class BaseGradeTest(object): self.assertEqual(len(GradingOpportunity.objects.all()), 1) opportunity = GradingOpportunity.objects.all()[0] self.assertEqual(self.course, opportunity.course) - self.assertEqual(self.datas["flow_id"], opportunity.flow_id) + self.assertEqual(self.data["flow_id"], opportunity.flow_id) # Check changes self.assertEqual(opportunity.page_scores_in_participant_gradebook, True) def test_view_flow_list_analytics(self): resp = self.c.get(reverse("relate-flow_list", - args=[self.datas["course_identifier"]])) + args=[self.course.identifier])) self.assertEqual(resp.status_code, 200) def test_view_flow_analytics(self): - params = {"course_identifier": self.datas["course_identifier"], - "flow_id": self.datas["flow_id"]} + params = {"course_identifier": self.course.identifier, + "flow_id": self.data["flow_id"]} resp = self.c.get(reverse("relate-flow_analytics", kwargs=params)) self.assertEqual(resp.status_code, 200) @@ -330,26 +272,25 @@ class BaseGradeTest(object): # Only check page for now def test_view_regrade_flow(self): resp = self.c.get(reverse("relate-regrade_flows_view", - args=[self.datas["course_identifier"]])) + args=[self.course.identifier])) self.assertEqual(resp.status_code, 200) def test_view_grant_exception_new_session(self): all_session = FlowSession.objects.all() # Check number of flow sessions and ids - self.assertEqual(len(all_session), - len(self.datas["flow_session_id"])) + self.assertEqual(all_session.count(), len(self.data["flow_session_id"])) for session in all_session: # Perform all checking before moving to stage three params = self.check_stage_one_and_two(session.participation) - self.assertTrue(session.id in self.datas["flow_session_id"]) + self.assertTrue(session.id in self.data["flow_session_id"]) self.check_grant_new_exception(params) - # Should have two flow sessions now - self.assertEqual(len(FlowSession.objects.all()), 2 * self.datas["accounts"]) + self.assertEqual(FlowSession.objects.all().count(), + 2 * self.n_quiz_takers) def test_view_grant_exception_exist_session(self): # Store numbers to reuse - session_nums = len(self.datas["flow_session_id"]) + session_nums = len(self.data["flow_session_id"]) all_session = FlowSession.objects.all() # Check session numbers @@ -366,38 +307,45 @@ class BaseGradeTest(object): self.assertEqual(len(FlowRuleException.objects.all()), 2 * session_nums) # Helper method for creating in memory csv files to test import grades - def creat_grading_csv(self, datas): + def creat_grading_csv(self, data): + try: + import cStringIO # PY2 + except ImportError: + import io as cStringIO # PY3 + csvfile = cStringIO.StringIO() + + import csv csvwriter = csv.writer(csvfile) - for data in datas: + for d in data: # (username, grades, feedback) - csvwriter.writerow([data[0], data[1], data[2]]) + csvwriter.writerow([d[0], d[1], d[2]]) # Reset back to the start of file to avoid invalid form error # Otherwise it will consider the file as empty csvfile.seek(0) return csvfile # Helper method for testing import grades - def check_import_grade(self, csv_datas, headers=False): + def check_import_grade(self, csv_data, headers=False): # Check import form works well resp = self.c.get(reverse("relate-import_grades", - args=[self.datas["course_identifier"]])) + args=[self.course.identifier])) self.assertEqual(resp.status_code, 200) # Check number of GradeChange - self.assertEqual(len(GradeChange.objects.all()), self.datas["accounts"]) + self.assertEqual(GradeChange.objects.all().count(), self.n_quiz_takers) # Check attributes - self.assertEqual(len(GradingOpportunity.objects.all()), 1) - opportunity = GradingOpportunity.objects.all()[0] + self.assertEqual(GradingOpportunity.objects.all().count(), 1) + opportunity = GradingOpportunity.objects.all().first() self.assertEqual(self.course, opportunity.course) - self.assertEqual(self.datas["flow_id"], opportunity.flow_id) + self.assertEqual(self.data["flow_id"], opportunity.flow_id) - # Prepare datas + # Prepare data # Prepare csv - csv_file = self.creat_grading_csv(csv_datas) - # Prepare form datas - datas = {'points_column': ['2'], 'attr_column': ['1'], + csv_file = self.creat_grading_csv(csv_data) + # Prepare form data + data = {'points_column': ['2'], 'attr_column': ['1'], 'feedback_column': ['3'], 'grading_opportunity': [str(opportunity.id)], 'format': ['csv' + ('head' if headers else '')], @@ -407,37 +355,37 @@ class BaseGradeTest(object): # Check importing resp = self.c.post(reverse("relate-import_grades", - args=[self.datas["course_identifier"]]), datas) + args=[self.course.identifier]), data) self.assertEqual(resp.status_code, 200) # Check number of GradeChange - num_diff = len(csv_datas) - 1 if headers else len(csv_datas) - self.assertEqual(len(GradeChange.objects.all()), - self.datas["accounts"] + num_diff) + num_diff = len(csv_data) - 1 if headers else len(csv_data) + self.assertEqual(GradeChange.objects.all().count(), + self.n_quiz_takers + num_diff) # Helper method for testing grant exceptions for new session def check_grant_new_exception(self, params): # Grant a new one - datas = {'access_rules_tag_for_new_session': ['<<>>'], + data = {'access_rules_tag_for_new_session': ['<<>>'], 'create_session': ['Create session']} resp = self.c.post(reverse("relate-grant_exception_stage_2", - kwargs=params), datas) + kwargs=params), data) self.assertEqual(resp.status_code, 200) # Helper method for testing grant exceptions for existing one def check_grant_exist_exception(self, session_id, parameters): params = parameters.copy() flow_session = FlowSession.objects.filter(id=session_id)[0] - self.assertTrue(flow_session.id in self.datas["flow_session_id"]) + self.assertTrue(flow_session.id in self.data["flow_session_id"]) # Grant an existing one - datas = {'session': [str(flow_session.id)], 'next': ['Next \xbb']} + data = {'session': [str(flow_session.id)], 'next': ['Next \xbb']} resp = self.c.post(reverse("relate-grant_exception_stage_2", - kwargs=params), datas) + kwargs=params), data) self.assertEqual(resp.status_code, 302) # Prepare parameters - params["session_id"] = datas["session"][0] + params["session_id"] = data["session"][0] # Check redirect self.assertEqual(resp.url, reverse("relate-grant_exception_stage_3", kwargs=params)) @@ -448,7 +396,7 @@ class BaseGradeTest(object): self.assertEqual(resp.status_code, 200) # Create a new exception rule - datas = {'comment': ['test-rule'], 'save': ['Save'], 'view': ['on'], + data = {'comment': ['test-rule'], 'save': ['Save'], 'view': ['on'], 'see_answer_after_submission': ['on'], 'create_grading_exception': ['on'], 'create_access_exception': ['on'], @@ -457,12 +405,12 @@ class BaseGradeTest(object): 'credit_percent': ['100.0'], 'max_points_enforced_cap': [''], 'generates_grade': ['on'], 'see_correctness': ['on']} resp = self.c.post(reverse("relate-grant_exception_stage_3", - kwargs=params), datas) + kwargs=params), data) self.assertEqual(resp.status_code, 302) # Check redirect self.assertEqual(resp.url, reverse("relate-grant_exception", - args=[self.datas["course_identifier"]])) + args=[self.course.identifier])) # Helper method for testing reopen session def check_reopen_session(self, session_id, opportunity_id): @@ -470,7 +418,7 @@ class BaseGradeTest(object): self.assertEqual(flow_session.in_progress, False) # Check reopen session form - params = {"course_identifier": self.datas["course_identifier"], + params = {"course_identifier": self.course.identifier, "opportunity_id": opportunity_id, "flow_session_id": session_id} resp = self.c.get(reverse("relate-view_reopen_session", @@ -478,10 +426,12 @@ class BaseGradeTest(object): self.assertEqual(resp.status_code, 200) # Reopen session - datas = {'set_access_rules_tag': ['<<>>'], 'comment': ['test-reopen'], - 'unsubmit_pages': ['on'], 'reopen': ['Reopen']} - resp = self.c.post(reverse("relate-view_reopen_session", - kwargs=params), datas) + data = {'set_access_rules_tag': ['<<>>'], + 'comment': ['test-reopen'], + 'unsubmit_pages': ['on'], + 'reopen': ['Reopen']} + resp = self.c.post( + reverse("relate-view_reopen_session", kwargs=params), data) flow_session = FlowSession.objects.filter(id=session_id)[0] self.assertEqual(flow_session.in_progress, True) @@ -489,35 +439,60 @@ class BaseGradeTest(object): # Helper method for testing grant exception view def check_stage_one_and_two(self, participation): # Check stage one page - resp = self.c.get(reverse("relate-grant_exception", - args=[self.datas["course_identifier"]])) + resp = self.c.get( + reverse("relate-grant_exception", args=[self.course.identifier])) self.assertEqual(resp.status_code, 200) # Move to stage two - # Shoud be only one participation record - self.assertEqual(len(Participation.objects.all()), self.datas["accounts"]) + self.assertEqual(Participation.objects.all().count(), self.n_participations) - datas = {"next": ["Next \xbb"], "participation": [str(participation.id)], - "flow_id": [self.datas["flow_id"]]} + data = {"next": ["Next \xbb"], + "participation": [str(participation.id)], + "flow_id": [self.data["flow_id"]]} resp = self.c.post(reverse("relate-grant_exception", - args=[self.datas["course_identifier"]]), datas) + args=[self.course.identifier]), data) self.assertEqual(resp.status_code, 302) # Prepare parameters - params = datas.copy() + params = data.copy() params["participation_id"] = params["participation"][0] - params["course_identifier"] = self.datas["course_identifier"] + params["course_identifier"] = self.course.identifier params["flow_id"] = params["flow_id"][0] del params["next"] del params["participation"] # Check redirect - self.assertEqual(resp.url, reverse("relate-grant_exception_stage_2", - kwargs=params)) + self.assertEqual(resp.url, + reverse( + "relate-grant_exception_stage_2", kwargs=params)) # Check stage two page - resp = self.c.get(reverse("relate-grant_exception_stage_2", - kwargs=params)) + resp = self.c.get( + reverse("relate-grant_exception_stage_2", kwargs=params)) self.assertEqual(resp.status_code, 200) # Return params to reuse return params + + +class GradeTwoQuizTakerTest(GradeTestMixin, TestCase): + @classmethod + def setUpTestData(cls): # noqa + super(GradeTwoQuizTakerTest, cls).setUpTestData() + cls.do_quiz(cls.instructor_participation) + cls.n_quiz_takers = 2 + cls.n_participations = 3 + + # Make sure the instructor is logged in after all quizes finished + cls.c.force_login(cls.instructor_participation.user) + + +class GradeThreeQuizTakerTest(GradeTestMixin, TestCase): + @classmethod + def setUpTestData(cls): # noqa + super(GradeThreeQuizTakerTest, cls).setUpTestData() + cls.do_quiz(cls.ta_participation) + cls.do_quiz(cls.instructor_participation) + cls.n_quiz_takers = 3 + cls.n_participations = 3 + + cls.c.force_login(cls.instructor_participation.user) diff --git a/test/test_course.py b/test/test_pages.py similarity index 76% rename from test/test_course.py rename to test/test_pages.py index cc4ee960..04bdf7db 100644 --- a/test/test_course.py +++ b/test/test_pages.py @@ -1,6 +1,6 @@ from __future__ import division -__copyright__ = "Copyright (C) 2014 Andreas Kloeckner" +__copyright__ = "Copyright (C) 2014 Andreas Kloeckner, Zesheng Wang, Dong Zhuang" __license__ = """ Permission is hereby granted, free of charge, to any person obtaining a copy @@ -22,64 +22,40 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -import shutil -from django.test import TestCase, Client +from django.test import TestCase from django.urls import resolve, reverse -from accounts.models import User +from django.contrib.auth import get_user_model from course.models import FlowSession, FlowPageVisit, Course from decimal import Decimal +from base_test_mixins import SingleCourseTestMixin -class CourseTest(TestCase): +class SingleCoursePageTest(SingleCourseTestMixin, TestCase): @classmethod def setUpTestData(cls): # noqa - # Set up data for the whole TestCase - cls.admin = User.objects.create_superuser( - username="testadmin", - password="test", - email="test@example.com", - first_name="Test", - last_name="Admin") - cls.admin.save() - # Create the course here and check later to - # avoid exceptions raised here - cls.c = Client() - cls.c.login( - username="testadmin", - password="test") - cls.c.post("/new-course/", dict( - identifier="test-course", - name="Test Course", - number="CS123", - time_period="Fall 2016", - hidden=True, - listed=True, - accepts_enrollment=True, - git_source="git://github.com/inducer/relate-sample", - course_file="course.yml", - events_file="events.yml", - enrollment_approval_required=True, - enrollment_required_email_suffix=None, - from_email="inform@tiker.net", - notify_email="inform@tiker.net")) - - @classmethod - def tearDownClass(cls): - # Remove created folder - shutil.rmtree('../test-course') - super(CourseTest, cls).tearDownClass() + super(SingleCoursePageTest, cls).setUpTestData() + cls.c.force_login(cls.student_participation.user) + # TODO: This should be moved to tests for auth module def test_user_creation(self): - # Should only have one user - self.assertEqual(len(User.objects.all()), 1) - self.assertTrue(self.c.login( - username="testadmin", - password="test")) - + # Should have 4 users + self.assertEqual(get_user_model().objects.all().count(), 4) + self.c.logout() + + self.assertTrue( + self.c.login( + username=self.instructor_participation.user.username, + password=( + self.courses_setup_list[0] + ["participations"][0] + ["user"]["password"]))) + + # TODO: This should move to tests for course.view module def test_course_creation(self): # Should only have one course - self.assertEqual(len(Course.objects.all()), 1) - resp = self.c.get(reverse("relate-course_page", args=["test-course"])) + self.assertEqual(Course.objects.all().count(), 1) + resp = self.c.get(reverse("relate-course_page", + args=[self.course.identifier])) # 200 != 302 is better than False is not True self.assertEqual(resp.status_code, 200) @@ -179,15 +155,16 @@ class CourseTest(TestCase): # Decorator won't work here :( def start_quiz(self): self.assertEqual(len(FlowSession.objects.all()), 0) - params = {"course_identifier": "test-course", "flow_id": "quiz-test"} + params = {"course_identifier": self.course.identifier, + "flow_id": "quiz-test"} resp = self.c.post(reverse("relate-view_start_flow", kwargs=params)) self.assertEqual(resp.status_code, 302) - self.assertEqual(len(FlowSession.objects.all()), 1) + self.assertEqual(FlowSession.objects.all().count(), 1) # Yep, no regax! _, _, kwargs = resolve(resp.url) # Should be in correct course - self.assertEqual(kwargs["course_identifier"], "test-course") + self.assertEqual(kwargs["course_identifier"], self.course.identifier) # Should redirect us to welcome page self.assertEqual(kwargs["ordinal"], '0') diff --git a/test/test_other.py b/test/test_sandbox.py similarity index 53% rename from test/test_other.py rename to test/test_sandbox.py index 64666501..a0884c52 100644 --- a/test/test_other.py +++ b/test/test_sandbox.py @@ -22,56 +22,32 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -import shutil -from django.test import TestCase, Client +from django.test import TestCase from django.urls import reverse -from accounts.models import User +from base_test_mixins import SingleCourseTestMixin +from course.models import Participation +from course.constants import participation_permission as pperm -class CourseTest(TestCase): +class SingleCoursePageSandboxTest(SingleCourseTestMixin, TestCase): @classmethod def setUpTestData(cls): # noqa - # Set up data for the whole TestCase - cls.admin = User.objects.create_superuser( - username="testadmin", - password="test", - email="test@example.com", - first_name="Test", - last_name="Admin") - cls.admin.save() - # Create the course here and check later to - # avoid exceptions raised here - cls.c = Client() - cls.c.login( - username="testadmin", - password="test") - cls.c.post("/new-course/", dict( - identifier="test-course", - name="Test Course", - number="CS123", - time_period="Fall 2016", - hidden=True, - listed=True, - accepts_enrollment=True, - git_source="git://github.com/inducer/relate-sample", - course_file="course.yml", - events_file="events.yml", - enrollment_approval_required=True, - enrollment_required_email_suffix=None, - from_email="inform@tiker.net", - notify_email="inform@tiker.net")) + super(SingleCoursePageSandboxTest, cls).setUpTestData() + participation = ( + Participation.objects.filter( + course=cls.course, + roles__permissions__permission=pperm.use_page_sandbox + ).first() + ) + assert participation + cls.c.force_login(participation.user) - @classmethod - def tearDownClass(cls): - # Remove created folder - shutil.rmtree('../test-course') - super(CourseTest, cls).tearDownClass() - - def test_page_sandbox(self): - # Check if page is there - resp = self.c.get(reverse("relate-view_page_sandbox", args=["test-course"])) + def test_page_sandbox_get(self): + resp = self.c.get(reverse("relate-view_page_sandbox", + args=[self.course.identifier])) self.assertEqual(resp.status_code, 200) + def test_page_sandbox_post(self): # Check one of the quiz questions question_markup = ("type: TextQuestion\r\n" "id: half\r\nvalue: 5\r\n" @@ -83,13 +59,13 @@ class CourseTest(TestCase): " rtol: 1e-4\r\n" " - half\r\n" " - a half") - datas = {'content': [question_markup], 'preview': ['Preview']} + data = {'content': [question_markup], 'preview': ['Preview']} resp = self.c.post(reverse("relate-view_page_sandbox", - args=["test-course"]), datas) + args=[self.course.identifier]), data) self.assertEqual(resp.status_code, 200) # Try to answer the rendered question - datas = {'answer': ['0.5'], 'submit': ['Submit answer']} + data = {'answer': ['0.5'], 'submit': ['Submit answer']} resp = self.c.post(reverse("relate-view_page_sandbox", - args=["test-course"]), datas) + args=[self.course.identifier]), data) self.assertEqual(resp.status_code, 200) -- GitLab