Files
penracourses/rad/tests/test_helpers.py
T

686 lines
26 KiB
Python

from http import HTTPStatus
import json
from typing import Callable
from bs4 import BeautifulSoup
import pytest
from generic.models import CidUser, CidUserGroup, UserUserGroup, ExamBase
from generic.views import ExamViews
from django.contrib.auth.models import User, Group
from django.urls import reverse
from rich.pretty import pprint
def AssertNotFound(client, url):
"""Helper to quickly test for urls that should return a 404 (NOT FOUND) error"""
response = client.get(url)
assert (
response.status_code == HTTPStatus.NOT_FOUND
), f"Response content: {response.content}"
def create_cid_user_and_groups(db, marker=None):
group1 = CidUserGroup.objects.create(name="Group1")
group2 = CidUserGroup.objects.create(name="Group2")
cid_user_1000 = CidUser.objects.create(cid=1000, passcode="ABCD", group=group1)
cid_user_1001 = CidUser.objects.create(cid=1001, passcode="EFGH", group=group1)
cid_user_2001 = CidUser.objects.create(cid=2001, passcode="IJKL", group=group2)
assert cid_user_1000.cid == 1000
assert cid_user_1001.cid == 1001
# Create UserUsers
user1 = User.objects.create_user(
username="user1", email="test@email.com", password="test1234"
)
user2 = User.objects.create_user(
username="user2", email="test2@email.com", password="test1234"
)
usergroup1 = UserUserGroup.objects.create(name="Group1")
usergroup1.users.add(user1)
usergroup2 = UserUserGroup.objects.create(name="Group2")
usergroup2.users.add(user2)
#if marker is not None:
# marker_group = Group.objects.create(name=f"{marker}_marker")
# #marker_group = Group.objects.create(name=f"{marker}_marker")
marker = User.objects.create_user(
username="marker", email="test@marker.com", password="test1234"
)
def create_exam(db, ExamViews: ExamViews):
exam: ExamBase = ExamViews.Exam.objects.create(
name="Cid Exam", exam_mode=True, active=True
)
cidgroup1: CidUserGroup = CidUserGroup.objects.get(name="Group1")
usergroup1: UserUserGroup = UserUserGroup.objects.get(name="Group1")
exam.cid_user_groups.add(cidgroup1)
exam.valid_cid_users.add(*cidgroup1.ciduser_set.all())
# exam.valid_user_users.add(user1)
exam.valid_user_users.add(*usergroup1.users.all())
assert exam
return exam
class ExamTester:
def __init__(self, db, client, app_name, ExamViews) -> None:
self.db = db
self.client = client
self.app_name = app_name
create_cid_user_and_groups(db)
self.exam = exam = create_exam(db, ExamViews)
cidgroup1: CidUserGroup = CidUserGroup.objects.get(name="Group1")
self.answers_per_question = 1
match app_name:
case "physics":
assert exam in cidgroup1.physics_cid_user_groups.all()
self.answers_per_question = 5
case "sbas":
assert exam in cidgroup1.sba_cid_user_groups.all()
cid_user_1001 = CidUser.objects.get(cid=1001)
cid_user_1000 = CidUser.objects.get(cid=1000)
self.cid_user_2001 = cid_user_2001 = CidUser.objects.get(cid=2001)
user1 = User.objects.get(username="user1")
user2 = User.objects.get(username="user2")
self.all_users = [cid_user_1000, cid_user_1001, cid_user_2001, user1, user2]
self.cid_users = [cid_user_1000, cid_user_1001, cid_user_2001]
self.invalid_users = [cid_user_2001, user2]
self.current_user = None
self.generated_questions = []
def load_user(self, user):
self.client.logout()
self.current_user = user
if isinstance(self.current_user, CidUser):
self.testing_cid_user = True
else:
self.testing_cid_user = False
self.client.force_login(user)
def create_questions(self, create_question_func: Callable):
self.create_question_func = create_question_func
self.generated_questions = [
create_question_func(self.exam),
create_question_func(self.exam),
create_question_func(self.exam),
]
def add_question(self):
self.generated_questions.append(self.create_question_func(self.exam))
def check_active_exams(self, n):
active_exams = self.client.get(reverse(f"{self.app_name}:active_exams"))
assert active_exams.status_code == 200
assert (
len(json.loads(active_exams.content)["exams"]) == n
), f"Current user: {self.current_user}"
def check_onsite_start_page(self):
start_page_res = self.client.get(
reverse(f"{self.app_name}:exam_start", kwargs={"pk": self.exam.pk})
)
# Check user access to the start page
assert start_page_res.status_code == HTTPStatus.OK
start_page_soup = BeautifulSoup(start_page_res.content, "html.parser")
if (
self.current_user in self.cid_users
or self.current_user in self.invalid_users
):
assert "Enter your CID and passcode" in str(start_page_soup)
else:
assert "Start Exam" in str(start_page_soup)
def check_take_page(self):
for n, question in enumerate(self.generated_questions):
if self.testing_cid_user:
take_url = reverse(
f"{self.app_name}:exam_take",
kwargs={
"pk": self.exam.pk,
"sk": n,
"cid": self.current_user.cid,
"passcode": self.current_user.passcode,
},
)
else:
take_url = reverse(
f"{self.app_name}:exam_take_user",
kwargs={
"pk": self.exam.pk,
"sk": n,
},
)
cid_take_res = self.client.get(take_url)
# Check that users that have not been added cannot access the take pages
if self.current_user in self.invalid_users:
assert cid_take_res.status_code == HTTPStatus.NOT_FOUND
continue
else:
assert (
cid_take_res.status_code == HTTPStatus.OK
), f"Current user: {self.current_user}"
cid_take_soup = BeautifulSoup(cid_take_res.content, "html.parser")
# Check correct user is displayed
if self.testing_cid_user:
display_user = str(self.current_user.cid)
else:
display_user = self.current_user.username
assert display_user in cid_take_soup.find("span", {"id": "user-id"}).string
# Check review mode alert not visible
assert cid_take_soup.find("div", {"class": "review-mode-alert"}) is None
# The main take page loads the question controls via HTMX/JS into
# the fragment URL. Request that fragment and run button/answer
# checks against it (server-rendered), not the main page which is
# just a container.
if self.testing_cid_user:
frag_url = reverse(
f"{self.app_name}:exam_take_fragment",
kwargs={
"pk": self.exam.pk,
"sk": n,
"cid": self.current_user.cid,
"passcode": self.current_user.passcode,
},
)
else:
frag_url = reverse(
f"{self.app_name}:exam_take_fragment_user",
kwargs={
"pk": self.exam.pk,
"sk": n,
},
)
frag_res = self.client.get(frag_url)
# If the user is invalid they may not be able to load the fragment
if self.current_user in self.invalid_users:
assert frag_res.status_code == HTTPStatus.NOT_FOUND
continue
else:
assert frag_res.status_code == HTTPStatus.OK
frag_soup = BeautifulSoup(frag_res.content, "html.parser")
# Check that relevant buttons are being displayed in the fragment
assert frag_soup.find("button", {"name": "finish"}) is not None
if n < len(self.generated_questions) - 1:
assert frag_soup.find("button", {"name": "next"}) is not None
if n > 0:
assert frag_soup.find("button", {"name": "previous"}) is not None
# Check that stem text is displayed (main page header is also kept in
# sync by fragment JS, but verify here that fragment contains stem)
assert (
frag_soup.find("div", {"class": "question-stem-fragment"}).string.strip()
== question.stem
)
match self.app_name:
case "physics":
assert (
frag_soup.find("label", {"for": "id_a"}).string
== question.a
)
assert (
frag_soup.find("label", {"for": "id_b"}).string
== question.b
)
assert (
frag_soup.find("label", {"for": "id_c"}).string
== question.c
)
assert (
frag_soup.find("label", {"for": "id_d"}).string
== question.d
)
assert (
frag_soup.find("label", {"for": "id_e"}).string
== question.e
)
case "sbas":
assert (
frag_soup.find("ul", {"class": "sba-answer-list"})
is not None
)
assert (
len(
frag_soup.find(
"ul", {"class": "sba-answer-list"}
).find_all("li")
)
== 5
)
# As we haven't submitted any answers they should all be blank
assert (
frag_soup.find("input", {"type": "checkbox", "checked": True})
is None
)
def check_take_overview(self, answered, total):
if self.testing_cid_user:
take_overview_url = reverse(
f"{self.app_name}:exam_take_overview",
kwargs={
"pk": self.exam.pk,
"cid": self.current_user.cid,
"passcode": self.current_user.passcode,
},
)
else:
take_overview_url = reverse(
f"{self.app_name}:exam_take_overview_user",
kwargs={
"pk": self.exam.pk,
},
)
cid_take_overview_res = self.client.get(take_overview_url)
# Check that users that have not been added cannot access the take pages
if self.current_user in self.invalid_users:
assert cid_take_overview_res.status_code == HTTPStatus.NOT_FOUND
else:
assert cid_take_overview_res.status_code == HTTPStatus.OK
cid_take_overview_soup = BeautifulSoup(
cid_take_overview_res.content, "html.parser"
)
assert (
f"{answered} out of 3 questions answered"
in cid_take_overview_soup.find(
"div", attrs={"class": "overview-text"}
).text
)
# Different apps render unanswered markers differently:
# - `sbas` uses <button class="unanswered"> nodes
# - `physics` uses anchor tiles with a title attribute for unanswered
if self.app_name == "sbas":
count_unanswered = len(
cid_take_overview_soup.find_all(
"button", attrs={"class": "unanswered"}
)
)
else:
# Count overview tiles that include the "not answered" title
overview_tiles = cid_take_overview_soup.find_all(
class_="overview-question-btn"
)
count_unanswered = sum(1 for t in overview_tiles if t.has_attr("title"))
assert count_unanswered == total - answered
def check_cid_scores_page(self):
if self.testing_cid_user:
# Get overview scores page (invalid passcode)
AssertNotFound(
self.client,
reverse(
f"cid_scores",
kwargs={"cid": self.current_user.cid, "passcode": "AAAA"},
),
)
cid_scores_url = reverse(
f"cid_scores",
kwargs={
"cid": self.current_user.cid,
"passcode": self.current_user.passcode,
},
)
else:
cid_scores_url = reverse(f"user_scores")
# Get overview scores page
cid_scores_res = self.client.get(cid_scores_url)
assert cid_scores_res.status_code == HTTPStatus.OK
cid_scores_soup = BeautifulSoup(cid_scores_res.content, "html.parser")
if self.current_user in self.invalid_users:
# page should load but we shouldn't be able to find the exam
with pytest.raises(AttributeError):
search_exam = (
cid_scores_soup.find("div", {"id": "exam-assigned"})
.find("ul", {"class": self.exam.app_name.lower()})
.find_all("li", attrs={"data-exam-id": self.exam.pk})
)
else:
search_exam = (
cid_scores_soup.find("div", {"id": "exam-assigned"})
.find("ul", {"class": self.exam.app_name.lower()})
.find("li", attrs={"data-exam-id": self.exam.pk})
)
assert search_exam
assert str(self.exam) in str(search_exam)
assert len(search_exam.find("button", {"class": "start-button"})) > 0
# assert "Active" in assigned_exams # check it is active
invalid_exam = cid_scores_soup.find_all(
"li", attrs={"data-exam-id": self.exam.pk + 5}
)
assert not invalid_exam # Check we can't find an invalid exam
# Check that we have a results link
results_exam = (
cid_scores_soup.find("div", {"id": "exam-results"})
.find("ul", {"class": self.exam.app_name})
.find_all("li", attrs={"data-exam-id": self.exam.pk})
)
assert results_exam
assert str(self.exam) in str(results_exam)
assert str(self.exam) + " test" not in str(results_exam)
assert "Results Published" not in str(
results_exam
) # It should not be published yet
def check_cid_scores_user_page(self):
if self.testing_cid_user:
AssertNotFound(
self.client,
reverse(
f"{self.exam.app_name}:exam_scores_cid_user",
kwargs={
"pk": self.exam.pk,
"cid": self.current_user.cid,
"passcode": "AAAA",
},
),
)
AssertNotFound(
self.client,
reverse(
f"{self.exam.app_name}:exam_scores_cid_user",
kwargs={
"pk": self.exam.pk,
"cid": self.cid_user_2001.cid,
"passcode": self.cid_user_2001.passcode,
},
),
)
exam_scores_url = reverse(
f"{self.exam.app_name}:exam_scores_cid_user",
kwargs={
"pk": self.exam.pk,
"cid": self.current_user.cid,
"passcode": self.current_user.passcode,
},
)
else:
exam_scores_url = reverse(
f"{self.exam.app_name}:exam_scores_user",
kwargs={"pk": self.exam.pk},
)
self.exam_scores_url = exam_scores_url
# Load the exam results page
cid_exam_scores_res = self.client.get(exam_scores_url)
if self.current_user in self.invalid_users:
assert cid_exam_scores_res.status_code == HTTPStatus.NOT_FOUND
return
else:
assert cid_exam_scores_res.status_code == HTTPStatus.OK
cid_exam_scores_soup = BeautifulSoup(cid_exam_scores_res.content, "html.parser")
# Check that we have an alert that exams are not published
alert = cid_exam_scores_soup.find("div", {"class": "alert"})
assert "Results are not currently published." in str(alert)
# Find any element with the submitted-user-answer class (div or span)
submitted_answers = cid_exam_scores_soup.find_all(
class_="submitted-user-answer"
)
# Check that we are showing enough questions / answers
match self.app_name:
case "physics":
# Physics renders per-part answers inside an ordered list
# within each question block. Count li elements inside those
# lists to get the total number of parts shown.
ols = cid_exam_scores_soup.find_all("ol", class_="mb-0")
answer_li_count = sum(len(ol.find_all("li")) for ol in ols)
assert answer_li_count == 5 * len(self.generated_questions)
assert len(submitted_answers) == 5 * len(self.generated_questions)
for ans in submitted_answers:
assert ans.text.strip() == "True"
case "sbas":
answer_lis = cid_exam_scores_soup.find_all(
"li", {"class": "user-answer-li"}
)
assert len(answer_lis) == len(self.generated_questions)
assert len(submitted_answers) == len(self.generated_questions)
for ans in submitted_answers:
assert ans.text.strip() == "a"
# Check no correct answers are shown
assert not cid_exam_scores_soup.find_all("span", {"class": "correct-answer"})
# Add another question to the exam
self.add_question()
# Load the exam results page (again)
cid_exam_scores_res = self.client.get(exam_scores_url)
cid_exam_scores_soup = BeautifulSoup(cid_exam_scores_res.content, "html.parser")
submitted_answers = cid_exam_scores_soup.find_all(class_="submitted-user-answer")
match self.app_name:
case "physics":
ols = cid_exam_scores_soup.find_all("ol", class_="mb-0")
answer_li_count = sum(len(ol.find_all("li")) for ol in ols)
assert answer_li_count == 5 * len(self.generated_questions)
assert len(submitted_answers) == 5 * len(self.generated_questions)
for ans in submitted_answers[:-5]:
assert ans.text.strip() == "True"
# The new question will not have any answers
for ans in submitted_answers[-5:]:
assert ans.text.strip() == "Not answered"
case "sbas":
answer_lis = cid_exam_scores_soup.find_all(
"li", {"class": "user-answer-li"}
)
assert len(answer_lis) == len(self.generated_questions)
assert len(submitted_answers) == len(self.generated_questions)
print(submitted_answers)
for ans in submitted_answers[:3]:
print("test1", ans)
assert ans.text.strip() == "a"
# The new question will not have any answers
for ans in submitted_answers[3:]:
print("test2", ans)
assert ans.text.strip() == "Not answered"
# Publish the results!
self.exam.publish_results = True
self.exam.save()
# Load the exam results page (again)
cid_exam_scores_res = self.client.get(exam_scores_url)
cid_exam_scores_soup = BeautifulSoup(cid_exam_scores_res.content, "html.parser")
assert (
cid_exam_scores_soup.find("div", {"class": "alert"}) is None
) # Check our warning banner is gone
# Check that we are displaying the correct answers (correctly)
# Different apps render correct answers differently. For physics the
# template uses a div containing the text "Correct answer: ...".
if self.app_name == "physics":
correct_answers = [
t for t in cid_exam_scores_soup.find_all(string=True) if "Correct answer:" in t
]
else:
correct_answers = cid_exam_scores_soup.find_all(
"span", {"class": "correct-answer"}
)
assert len(correct_answers) == self.answers_per_question * len(
self.generated_questions
)
n = 0
for i, question in enumerate(self.generated_questions):
match self.app_name:
case "physics":
for ans in question.get_answers():
assert correct_answers[n].text.strip() == f"Correct answer: {ans}"
n = n + 1
case "sbas":
ans = question.get_correct_answer()
assert correct_answers[i].text.strip() == f"Correct answer: {ans}"
submitted_answers = cid_exam_scores_soup.find_all(class_="submitted-user-answer")
assert len(submitted_answers) == self.answers_per_question * len(
self.generated_questions
)
print(submitted_answers)
for ans in submitted_answers[:-self.answers_per_question]:
print("------", ans.text.strip())
match self.app_name:
case "physics":
assert ans.text.strip() == "True"
case "sbas":
assert ans.text.strip() == "a"
# The new question will not have any answers
for ans in submitted_answers[-self.answers_per_question:]:
assert ans.text.strip() == "Not answered"
match self.app_name:
case "physics":
assert (
cid_exam_scores_soup.find("div", {"class": "score-overview"})
.find("span", {"id": "total-score"})
.string
== "9"
)
assert (
cid_exam_scores_soup.find("div", {"class": "score-overview"})
.find("span", {"id": "max-score"})
.string
== "20"
)
case "sbas":
assert (
cid_exam_scores_soup.find("div", {"class": "score-overview"})
.find("span", {"id": "total-score"})
.string
== "3"
)
assert (
cid_exam_scores_soup.find("div", {"class": "score-overview"})
.find("span", {"id": "max-score"})
.string
== "4"
)
def check_cid_scores_user_page2(self):
cid_exam_scores_res = self.client.get(self.exam_scores_url)
cid_exam_scores_soup = BeautifulSoup(cid_exam_scores_res.content, "html.parser")
# As we have answered correctly the new score should be increased by 1 * answer_count
match self.app_name:
case "physics":
assert (
cid_exam_scores_soup.find("div", {"class": "score-overview"})
.find("span", {"id": "total-score"})
.string
== "14"
), f"Invalid answer count, Current user: {self.current_user}"
case "sbas":
assert (
cid_exam_scores_soup.find("div", {"class": "score-overview"})
.find("span", {"id": "total-score"})
.string
== "4"
), f"Invalid answer count, Current user: {self.current_user}"
# Check what happens when exam is not active
self.exam.active = False
self.exam.save()
start_page_res = self.client.get(
reverse(f"{self.app_name}:exam_start", kwargs={"pk": self.exam.pk})
)
start_page_soup = BeautifulSoup(start_page_res.content, "html.parser")
assert "Exam is currently inactive" in start_page_soup.text
if self.testing_cid_user:
cid_take_url = reverse(
f"{self.app_name}:exam_take",
kwargs={
"pk": self.exam.pk,
"sk": 0,
"cid": self.current_user.cid,
"passcode": self.current_user.passcode,
},
)
else:
cid_take_url = reverse(
f"{self.app_name}:exam_take_user",
kwargs={
"pk": self.exam.pk,
"sk": 0,
},
)
cid_take_res = self.client.get(cid_take_url)
cid_take_soup = BeautifulSoup(cid_take_res.content, "html.parser")
print(cid_take_soup.text)
assert "Exam is currently inactive" in cid_take_soup.text
# Delete the last question, from both the list
q = self.generated_questions.pop()
# and the database
q.delete()