diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 91db302f32..88019e882b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -87,6 +87,34 @@ jobs: - name: Check that "evaluation" section appears in help string run: python -m evap --help | grep --fixed-strings "[evaluation]" + test_frontend: + name: Test Frontend + + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - uses: ./.github/setup_evap + with: + shell: .#evap-frontend-dev + start-db: true + + - name: Compile assets + run: | + python manage.py ts compile + python manage.py scss + + - name: Run tests (shuffled) + run: coverage run manage.py test --shuffle --tag selenium + - name: Upload coverage + uses: codecov/codecov-action@v5 + with: + flags: frontend-tests + token: ${{ secrets.CODECOV_TOKEN }} + mypy: runs-on: ubuntu-22.04 @@ -98,6 +126,21 @@ jobs: - name: Run MyPy run: mypy + typescript: + runs-on: ubuntu-22.04 + name: Test Typescript + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: ./.github/setup_evap + with: + npm-ci: true + + - name: Run tests + run: python manage.py ts test + linter: runs-on: ubuntu-22.04 @@ -171,83 +214,6 @@ jobs: - name: Reload backup run: echo "yy" | deployment/load_production_backup.sh backup.json - compile_scss: - runs-on: ubuntu-22.04 - - name: Compile Scss - - steps: - - uses: actions/checkout@v4 - with: - submodules: true - - uses: ./.github/setup_evap - with: - npm-ci: true - - - name: Compile Scss - run: npx sass evap/static/scss/evap.scss evap/static/css/evap.css - - name: Store Css - uses: actions/upload-artifact@v4 - with: - name: css - path: evap/static/css/evap.css - - render_pages: - runs-on: ubuntu-22.04 - - name: Render Html pages - - steps: - - uses: actions/checkout@v4 - - uses: ./.github/setup_evap - with: - start-db: true - - - name: Render pages - run: coverage run manage.py ts render_pages - - name: Upload coverage - uses: codecov/codecov-action@v5 - with: - flags: render-pages - token: ${{ secrets.CODECOV_TOKEN }} - - name: Store rendered pages - uses: actions/upload-artifact@v4 - with: - name: rendered-pages - path: evap/static/ts/rendered - - typescript: - runs-on: ubuntu-22.04 - - needs: [ compile_scss, render_pages ] - - name: Test Typescript - - steps: - - uses: actions/checkout@v4 - with: - submodules: true - - uses: ./.github/setup_evap - with: - npm-ci: true - - - run: npx puppeteer browsers install chrome - - - name: Compile Typescript - run: npx tsc --project evap/static/ts/tsconfig.compile.json - - name: Load rendered pages - uses: actions/download-artifact@v4 - with: - name: rendered-pages - path: evap/static/ts/rendered - - name: Load Css - uses: actions/download-artifact@v4 - with: - name: css - path: evap/static/css - - name: Run tests - run: xvfb-run --auto-servernum npx jest - macos-nix-build: runs-on: macos-14 name: Build nix environment on MacOS diff --git a/deployment/manage_autocompletion.sh b/deployment/manage_autocompletion.sh index 29e52d6908..30571721ae 100755 --- a/deployment/manage_autocompletion.sh +++ b/deployment/manage_autocompletion.sh @@ -3,7 +3,7 @@ # generated using # ./manage.py | grep -v -E "^\[|^$" | tail -n +3 | sort | xargs COMMANDS="admin_generator anonymize changepassword check clean_pyc clear_cache clearsessions collectstatic compile_pyc compilemessages create_command create_jobs create_template_tags createcachetable createsuperuser dbshell delete_squashed_migrations describe_form diffsettings drop_test_database dump_testdata dumpdata dumpscript export_emails find_template findstatic flush format generate_password generate_secret_key graph_models inspectdb lint list_model_info list_signals loaddata loaddata_unlogged mail_debug makemessages makemigrations managestate merge_model_instances migrate notes optimizemigration pipchecker precommit print_settings print_user_for_session raise_test_exception refresh_results_cache reload_testdata remove_stale_contenttypes reset_db reset_schema run runjob runjobs runprofileserver runscript runserver runserver_plus scss send_reminders sendtestemail set_default_site set_fake_emails set_fake_passwords shell shell_plus show_template_tags show_urls showmigrations sqlcreate sqldiff sqldsn sqlflush sqlmigrate sqlsequencereset squashmigrations startapp startproject sync_s3 syncdata test testserver tools translate ts typecheck unreferenced_files update_evaluation_states update_permissions validate_templates" -TS_COMMANDS="compile test render_pages" +TS_COMMANDS="compile test" _managepy_complete() { diff --git a/evap/contributor/tests/test_views.py b/evap/contributor/tests/test_views.py index 7eeb1ae243..29e772db1f 100644 --- a/evap/contributor/tests/test_views.py +++ b/evap/contributor/tests/test_views.py @@ -8,7 +8,6 @@ WebTest, WebTestWith200Check, create_evaluation_with_responsible_and_editor, - render_pages, submit_with_modal, ) @@ -136,8 +135,6 @@ def test_without_questionnaires_assigned(self): class TestContributorEvaluationEditView(WebTest): - render_pages_url = "/contributor/evaluation/PK/edit" - @classmethod def setUpTestData(cls): result = create_evaluation_with_responsible_and_editor() @@ -146,23 +143,6 @@ def setUpTestData(cls): cls.evaluation = result["evaluation"] cls.url = f"/contributor/evaluation/{cls.evaluation.pk}/edit" - @render_pages - def render_pages(self): - self.evaluation.allow_editors_to_edit = False - self.evaluation.save() - - content_without_allow_editors_to_edit = self.app.get(self.url, user=self.editor).content - - self.evaluation.allow_editors_to_edit = True - self.evaluation.save() - - content_with_allow_editors_to_edit = self.app.get(self.url, user=self.editor).content - - return { - "normal": content_without_allow_editors_to_edit, - "allow_editors_to_edit": content_with_allow_editors_to_edit, - } - def test_not_authenticated(self): """ Asserts that an unauthorized user gets redirected to the login page. diff --git a/evap/evaluation/management/commands/ts.py b/evap/evaluation/management/commands/ts.py index 7a4725e027..ab0da07def 100644 --- a/evap/evaluation/management/commands/ts.py +++ b/evap/evaluation/management/commands/ts.py @@ -1,22 +1,9 @@ import argparse import subprocess # nosec -import unittest from django.conf import settings from django.core.management import call_command from django.core.management.base import BaseCommand, CommandError -from django.test.runner import DiscoverRunner - - -class RenderPagesRunner(DiscoverRunner): - """Test runner which only includes `render_pages.*` methods. - The actual logic of the page rendering is implemented in the `@render_pages` decorator.""" - - test_loader = unittest.TestLoader() - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.test_loader.testMethodPrefix = "render_pages" class Command(BaseCommand): @@ -31,7 +18,6 @@ def add_arguments(self, parser: argparse.ArgumentParser): self.add_fresh_argument(compile_parser) test_parser = subparsers.add_parser("test") self.add_fresh_argument(test_parser) - subparsers.add_parser("render_pages") @staticmethod def add_fresh_argument(parser: argparse.ArgumentParser): @@ -47,8 +33,6 @@ def handle(self, *args, **options): self.compile(**options) elif options["command"] == "test": self.test(**options) - elif options["command"] == "render_pages": - self.render_pages(**options) def run_command(self, command): try: @@ -80,14 +64,4 @@ def compile(self, watch=False, fresh=False, **_options): def test(self, **options): call_command("scss") self.compile(**options) - self.render_pages() self.run_command(["npx", "jest"]) - - @staticmethod - def render_pages(**_options): - # Enable debug mode as otherwise a collectstatic beforehand would be necessary, - # as missing static files would result into an error. - test_runner = RenderPagesRunner(debug_mode=True) - failed_tests = test_runner.run_tests([]) - if failed_tests > 0: - raise CommandError("Failures during render_pages") diff --git a/evap/evaluation/tests/test_commands.py b/evap/evaluation/tests/test_commands.py index 20cc6f03e6..da6ab0928f 100644 --- a/evap/evaluation/tests/test_commands.py +++ b/evap/evaluation/tests/test_commands.py @@ -269,12 +269,10 @@ def test_ts_compile_with_watch(self, mock_subprocess_run): @patch("subprocess.run") @patch("evap.evaluation.management.commands.ts.call_command") - @patch("evap.evaluation.management.commands.ts.Command.render_pages") - def test_ts_test(self, mock_render_pages, mock_call_command, mock_subprocess_run): + def test_ts_test(self, mock_call_command, mock_subprocess_run): management.call_command("ts", "test") # Mock render pages to prevent a second call into the test framework - mock_render_pages.assert_called_once() mock_call_command.assert_called_once_with("scss") mock_subprocess_run.assert_has_calls( [ diff --git a/evap/evaluation/tests/test_live.py b/evap/evaluation/tests/test_live.py new file mode 100644 index 0000000000..107d2915e6 --- /dev/null +++ b/evap/evaluation/tests/test_live.py @@ -0,0 +1,25 @@ +from django.core import mail +from django.urls import reverse +from selenium.webdriver.common.by import By +from selenium.webdriver.support.expected_conditions import text_to_be_present_in_element, visibility_of_element_located + +from evap.evaluation.tests.tools import LiveServerTest + + +class ContactModalTests(LiveServerTest): + def test_contact_modal(self) -> None: + self.selenium.get(self.live_server_url + reverse("evaluation:index")) + self.selenium.find_element(By.ID, "feedbackModalShowButton").click() + self.wait.until(visibility_of_element_located((By.ID, "feedbackModalMessageText"))) + self.selenium.find_element(By.ID, "feedbackModalMessageText").send_keys("Test message") + self.selenium.find_element(By.ID, "feedbackModalActionButton").click() + + self.wait.until( + text_to_be_present_in_element( + (By.CSS_SELECTOR, "#successMessageModal_feedbackModal .modal-body"), + "Your message was successfully sent.", + ) + ) + self.assertEqual(len(mail.outbox), 1) + + self.assertEqual(mail.outbox[0].subject, f"[EvaP] Message from {self.manager.email}") diff --git a/evap/evaluation/tests/test_views.py b/evap/evaluation/tests/test_views.py index a44266c11b..238a0c7709 100644 --- a/evap/evaluation/tests/test_views.py +++ b/evap/evaluation/tests/test_views.py @@ -11,20 +11,10 @@ WebTestWith200Check, create_evaluation_with_responsible_and_editor, make_manager, - store_ts_test_asset, ) from evap.staff.tests.utils import WebTestStaffMode -class RenderJsTranslationCatalog(WebTest): - url = reverse("javascript-catalog") - - def render_pages(self): - # Not using render_pages decorator to manually create a single (special) javascript file - content = self.app.get(self.url).content - store_ts_test_asset("catalog.js", content) - - @override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) class TestIndexView(WebTest): url = "/" diff --git a/evap/evaluation/tests/tools.py b/evap/evaluation/tests/tools.py index 495e81a05b..b77a84dea2 100644 --- a/evap/evaluation/tests/tools.py +++ b/evap/evaluation/tests/tools.py @@ -1,19 +1,26 @@ -import functools -import os -from collections.abc import Sequence +import time +from collections.abc import Iterator, Sequence from contextlib import contextmanager from datetime import timedelta +from importlib import import_module +from typing import Any import django.test import django_webtest import webtest from django.conf import settings +from django.contrib.auth import login from django.contrib.auth.models import Group +from django.contrib.staticfiles.handlers import StaticFilesHandler from django.db import DEFAULT_DB_ALIAS, connections -from django.http.request import QueryDict +from django.http.request import HttpRequest, QueryDict +from django.test.runner import DiscoverRunner +from django.test.selenium import SeleniumTestCase from django.test.utils import CaptureQueriesContext from django.utils import timezone, translation from model_bakery import baker +from selenium.webdriver.firefox.webdriver import WebDriver +from selenium.webdriver.support.wait import WebDriverWait from evap.evaluation.models import ( CHOICES, @@ -29,6 +36,15 @@ ) +class EvapTestRunner(DiscoverRunner): + """Skips selenium tests by default, if no other tags are specified.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + if not self.tags and not self.exclude_tags: + self.exclude_tags = {"selenium"} + + class ResetLanguageOnTearDownMixin: def tearDown(self): translation.activate("en") # Django by default does not "reset" this, causing test interdependency @@ -102,36 +118,6 @@ def let_user_vote_for_evaluation(user, evaluation, create_answers=False): RatingAnswerCounter.objects.bulk_update(rac_by_contribution_question.values(), ["count"]) -def store_ts_test_asset(relative_path: str, content) -> None: - absolute_path = settings.STATICFILES_DIRS[0] / "ts" / "rendered" / relative_path - - absolute_path.parent.mkdir(parents=True, exist_ok=True) - - with open(absolute_path, "wb") as file: - file.write(content) - - -def render_pages(test_item): - """Decorator which annotates test methods which render pages. - The containing class is expected to include a `url` attribute which matches a valid path. - Unlike normal test methods, it should not assert anything and is expected to return a dictionary. - The key denotes the variant of the page to reflect multiple states, cases or views. - The value is a byte string of the page content.""" - - @functools.wraps(test_item) - def decorator(self) -> None: - pages = test_item(self) - - url = getattr(self, "render_pages_url", self.url) - - for name, content in pages.items(): - # Remove the leading slash from the url to prevent that an absolute path is created - path = os.path.join(url[1:], f"{name}.html") - store_ts_test_asset(path, content) - - return decorator - - class WebTestWith200Check(WebTest): url = "/" test_users: list[UserProfile | str] = [] @@ -273,3 +259,59 @@ def assert_no_database_modifications(*args, **kwargs): lower_sql = query["sql"].lower() if not any(lower_sql.startswith(prefix) for prefix in allowed_prefixes): raise AssertionError("Unexpected modifying query found: " + query["sql"]) + + +class LiveServerTest(SeleniumTestCase): + browser = "firefox" + selenium: WebDriver + headless = True + window_size = (1920, 4096) # large height to workaround scrolling + serialized_rollback = True # SeleniumTestCase is a TransactionTestCase, which drops migration data. This keeps fixture data but may slow down tests, see https://docs.djangoproject.com/en/5.0/topics/testing/overview/#test-case-serialized-rollback + static_handler = StaticFilesHandler # see StaticLiveServerTestCase + + def setUp(self) -> None: + super().setUp() + self.request = self.make_request() + self.manager = make_manager() + self.selenium.get(self.live_server_url) + self.login(self.manager) + + @classmethod + def make_request(cls) -> HttpRequest: + request = HttpRequest() + engine = import_module(settings.SESSION_ENGINE) + request.session = engine.SessionStore() + return request + + def update_session(self) -> None: + self.request.session.save() + self.selenium.add_cookie( + { + "name": settings.SESSION_COOKIE_NAME, + "value": self.request.session.session_key, + "path": "/", + "secure": settings.SESSION_COOKIE_SECURE or False, + } + ) + + def login(self, user) -> None: + """Login a test user by setting the session cookie.""" + login(self.request, user, "evap.evaluation.auth.RequestAuthUserBackend") + self.update_session() + + @contextmanager + def enter_staff_mode(self) -> Iterator[None]: + self.request.session["staff_mode_start_time"] = time.time() + self.update_session() + yield + del self.request.session["staff_mode_start_time"] + self.update_session() + + @property + def wait(self) -> WebDriverWait: + return WebDriverWait(self.selenium, 10) + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.selenium.set_window_size(*cls.window_size) diff --git a/evap/results/tests/test_live.py b/evap/results/tests/test_live.py new file mode 100644 index 0000000000..d0a8279a82 --- /dev/null +++ b/evap/results/tests/test_live.py @@ -0,0 +1,181 @@ +from typing import Any + +from django.urls import reverse +from model_bakery import baker +from selenium.webdriver.common.by import By +from selenium.webdriver.support.expected_conditions import ( + invisibility_of_element_located, + visibility_of_element_located, +) + +from evap.evaluation.models import Course, CourseType, Evaluation, Program, Semester, UserProfile +from evap.evaluation.tests.tools import LiveServerTest + + +class ResultsIndexLiveTests(LiveServerTest): + def setUp(self): + super().setUp() + + def make_winter_semester(year: int) -> Semester: + return baker.make( + Semester, + name_de=f"Wintersemester {year}/{year + 1}", + name_en=f"Winter term {year}/{year + 1}", + short_name_de=f"WS {year % 1000}/{year % 1000 + 1}", + short_name_en=f"WT {year % 1000}/{year % 1000 + 1}", + ) + + def make_summer_semester(year: int) -> Semester: + return baker.make( + Semester, + name_de=f"Sommersemester {year}", + name_en=f"Summer term {year}", + short_name_de=f"SS {year % 1000}", + short_name_en=f"ST {year % 1000}", + ) + + semesters = [ + make_summer_semester(2014), + make_winter_semester(2013), + make_summer_semester(2013), + ] + programs = { + "ba-a": baker.make(Program, name_de="Bachelor A", name_en="Bachelor A"), + "ma-a": baker.make(Program, name_de="Master A", name_en="Master A"), + "ma-b": baker.make(Program, name_de="Master B", name_en="Master B"), + } + course_types = { + "l": baker.make(CourseType, name_de="Vorlesung", name_en="Lecture"), + "s": baker.make(CourseType, name_de="Seminar", name_en="Seminar"), + } + + def make_responsible(title: str, first_name: str, last_name: str) -> UserProfile: + return baker.make( + UserProfile, + title=title, + first_name_given=first_name, + last_name=last_name, + ) + + responsibles = { + "responsible": make_responsible("Prof. Dr.", "", "responsible"), + "goldwasser": make_responsible("Dr.", "Clara", "Goldwasser"), + "kuchenbuch": make_responsible("Dr.", "Tony", "Kuchenbuch"), + } + + def make_course(name, semester, course_type_name, program_names, responsible_names): + return baker.make( + Course, + semester=semesters[semester], + name_de=f"Veranstaltung {name}", + name_en=f"Course {name}", + type=course_types[course_type_name], + programs={programs[program_name] for program_name in program_names}, + responsibles={responsibles[responsible_name] for responsible_name in responsible_names}, + ) + + courses = { + "a-0": make_course("A", 0, "l", {"ba-a"}, {"responsible"}), + "a-1": make_course("A", 1, "l", {"ba-a"}, {"responsible"}), + "a-2": make_course("A", 2, "l", {"ba-a"}, {"responsible"}), + "c": make_course("C", 0, "s", {"ba-a", "ma-a"}, {"goldwasser"}), + "d": make_course("D", 0, "l", {"ma-a", "ma-b"}, {"kuchenbuch", "goldwasser"}), + "e": make_course("E", 2, "s", {"ma-a"}, {"kuchenbuch"}), + } + + def make_evaluation(course_name: str, participant_count: int, voter_count: int, **attrs: Any) -> None: + baker.make( + Evaluation, + state=Evaluation.State.PUBLISHED, + course=courses[course_name], + _participant_count=participant_count, + _voter_count=voter_count, + **attrs, + ) + + make_evaluation("a-0", 100, 80) + make_evaluation("a-1", 100, 85) + make_evaluation("a-2", 100, 80) + make_evaluation("a-2", 100, 75, name_de="Klausur", name_en="Exam") + make_evaluation("c", 20, 15) + make_evaluation("d", 50, 45) + make_evaluation("e", 5, 5) + + self.url = self.live_server_url + reverse("results:index") + + def assertRowsVisible(self, *rows: tuple[str, str]): + items = tuple( + ( + row.find_element(By.CLASS_NAME, "evaluation-name").text, + row.find_element(By.CLASS_NAME, "semester-short-name").text, + ) + for row in self.selenium.find_elements(By.CLASS_NAME, "heading-row") + ) + self.assertEqual(items, rows) + + def test_results_initially_sorted_by_evaluation_and_semester(self): + self.selenium.get(self.url) + self.wait.until(visibility_of_element_located((By.CLASS_NAME, "reset-button"))).click() + + self.assertRowsVisible( + ("Course A", "ST 14"), + ("Course A", "WT 13/14"), + ("Course A", "ST 13"), + ("Course C", "ST 14"), + ("Course D", "ST 14"), + ("Course E", "ST 13"), + ) + + def test_results_filter_with_search_input(self): + self.selenium.get(self.url) + self.wait.until(visibility_of_element_located((By.CLASS_NAME, "reset-button"))).click() + + self.selenium.find_element(By.CSS_SELECTOR, "input[name=search]").send_keys("Exam") + self.wait.until(invisibility_of_element_located((By.XPATH, "//span[contains(text(),'Course C')]"))) + + self.assertRowsVisible(("Course A", "ST 13")) + + def test_results_filter_with_program_checkbox(self): + self.selenium.get(self.url) + self.wait.until(visibility_of_element_located((By.CLASS_NAME, "reset-button"))).click() + + self.selenium.find_element(By.CSS_SELECTOR, "input[name=program][data-filter='Bachelor A']").click() + self.assertRowsVisible( + ("Course A", "ST 14"), ("Course A", "WT 13/14"), ("Course A", "ST 13"), ("Course C", "ST 14") + ) + + def test_results_filter_with_course_type_checkbox(self): + self.selenium.get(self.url) + self.wait.until(visibility_of_element_located((By.CLASS_NAME, "reset-button"))).click() + + self.selenium.find_element(By.CSS_SELECTOR, "input[name=courseType][data-filter='Seminar']").click() + self.assertRowsVisible(("Course C", "ST 14"), ("Course E", "ST 13")) + + def test_results_filter_with_semester_checkbox(self): + self.selenium.get(self.url) + self.wait.until(visibility_of_element_located((By.CLASS_NAME, "reset-button"))).click() + + self.selenium.find_element(By.CSS_SELECTOR, "input[name=semester][data-filter='ST 13']").click() + self.assertRowsVisible(("Course A", "ST 13"), ("Course E", "ST 13")) + + def test_results_clear_filter(self): + self.selenium.get(self.url) + reset_button = self.wait.until(visibility_of_element_located((By.CLASS_NAME, "reset-button"))) + reset_button.click() + + search_input = self.selenium.find_element(By.CSS_SELECTOR, "input[name=search]") + program_checkbox = self.selenium.find_element(By.CSS_SELECTOR, "input[name=program][data-filter='Bachelor A']") + course_type_checkbox = self.selenium.find_element( + By.CSS_SELECTOR, "input[name=courseType][data-filter='Lecture']" + ) + semester_checkbox = self.selenium.find_element(By.CSS_SELECTOR, "input[name=semester][data-filter='ST 14']") + search_input.send_keys("Some search text") + program_checkbox.click() + course_type_checkbox.click() + semester_checkbox.click() + reset_button.click() + + self.assertEqual(search_input.get_attribute("value"), "") + self.assertFalse(program_checkbox.is_selected()) + self.assertFalse(course_type_checkbox.is_selected()) + self.assertFalse(semester_checkbox.is_selected()) diff --git a/evap/results/tests/test_views.py b/evap/results/tests/test_views.py index 0abf6590cb..09f82171f6 100644 --- a/evap/results/tests/test_views.py +++ b/evap/results/tests/test_views.py @@ -12,7 +12,6 @@ from evap.evaluation.models import ( Contribution, Course, - CourseType, Evaluation, Program, Question, @@ -28,7 +27,6 @@ let_user_vote_for_evaluation, make_manager, make_rating_answer_counters, - render_pages, ) from evap.results.exporters import TextAnswerExporter from evap.results.tools import cache_results @@ -39,97 +37,6 @@ class TestResultsView(WebTest): url = "/results/" - @render_pages - def render_pages(self): - def make_winter_semester(year): - return baker.make( - Semester, - name_de=f"Wintersemester {year}/{year + 1}", - name_en=f"Winter term {year}/{year + 1}", - short_name_de=f"WS {year % 1000}/{year % 1000 + 1}", - short_name_en=f"WT {year % 1000}/{year % 1000 + 1}", - ) - - def make_summer_semester(year): - return baker.make( - Semester, - name_de=f"Sommersemester {year}", - name_en=f"Summer term {year}", - short_name_de=f"SS {year % 1000}", - short_name_en=f"ST {year % 1000}", - ) - - semesters = [ - make_summer_semester(2014), - make_winter_semester(2013), - make_summer_semester(2013), - ] - programs = { - "ba-a": baker.make(Program, name_de="Bachelor A", name_en="Bachelor A"), - "ma-a": baker.make(Program, name_de="Master A", name_en="Master A"), - "ma-b": baker.make(Program, name_de="Master B", name_en="Master B"), - } - course_types = { - "l": baker.make(CourseType, name_de="Vorlesung", name_en="Lecture"), - "s": baker.make(CourseType, name_de="Seminar", name_en="Seminar"), - } - - def make_responsible(title, first_name, last_name): - return baker.make( - UserProfile, - title=title, - first_name_given=first_name, - last_name=last_name, - ) - - responsibles = { - "responsible": make_responsible("Prof. Dr.", "", "responsible"), - "goldwasser": make_responsible("Dr.", "Clara", "Goldwasser"), - "kuchenbuch": make_responsible("Dr.", "Tony", "Kuchenbuch"), - } - - def make_course(name, semester, course_type_name, program_names, responsible_names): - return baker.make( - Course, - semester=semesters[semester], - name_de=f"Veranstaltung {name}", - name_en=f"Course {name}", - type=course_types[course_type_name], - programs={programs[program_name] for program_name in program_names}, - responsibles={responsibles[responsible_name] for responsible_name in responsible_names}, - ) - - courses = { - "a-0": make_course("A", 0, "l", {"ba-a"}, {"responsible"}), - "a-1": make_course("A", 1, "l", {"ba-a"}, {"responsible"}), - "a-2": make_course("A", 2, "l", {"ba-a"}, {"responsible"}), - "c": make_course("C", 0, "s", {"ba-a", "ma-a"}, {"goldwasser"}), - "d": make_course("D", 0, "l", {"ma-a", "ma-b"}, {"kuchenbuch", "goldwasser"}), - "e": make_course("E", 2, "s", {"ma-a"}, {"kuchenbuch"}), - } - - def make_evaluation(course_name, participant_count, voter_count, **attrs): - baker.make( - Evaluation, - state=Evaluation.State.PUBLISHED, - course=courses[course_name], - _participant_count=participant_count, - _voter_count=voter_count, - **attrs, - ) - - make_evaluation("a-0", 100, 80) - make_evaluation("a-1", 100, 85) - make_evaluation("a-2", 100, 80) - make_evaluation("a-2", 100, 75, name_de="Klausur", name_en="Exam") - make_evaluation("c", 20, 15) - make_evaluation("d", 50, 45) - make_evaluation("e", 5, 5) - - return { - "student": self.app.get(self.url, user="student@institution.example.com").content, - } - @patch("evap.evaluation.models.Evaluation.can_be_seen_by", new=(lambda self, user: True)) def test_multiple_evaluations_per_course(self): student = baker.make(UserProfile, email="student@institution.example.com") diff --git a/evap/settings.py b/evap/settings.py index 8677b7fbf8..3a447f1c97 100644 --- a/evap/settings.py +++ b/evap/settings.py @@ -439,6 +439,7 @@ def CHARACTER_ALLOWED_IN_NAME(character): # pylint: disable=invalid-name except ImportError: pass +TEST_RUNNER = "evap.evaluation.tests.tools.EvapTestRunner" TESTING = "test" in sys.argv or "pytest" in sys.modules # speed up tests and activate typeguard introspection diff --git a/evap/staff/templates/staff_evaluation_person_management.html b/evap/staff/templates/staff_evaluation_person_management.html index 8bc3e5afea..fce46be853 100644 --- a/evap/staff/templates/staff_evaluation_person_management.html +++ b/evap/staff/templates/staff_evaluation_person_management.html @@ -220,7 +220,7 @@
{% translate 'To CSV file' %}
{% endblock %} diff --git a/evap/staff/templates/staff_semester_import.html b/evap/staff/templates/staff_semester_import.html index 407490b8bb..7356865e46 100644 --- a/evap/staff/templates/staff_semester_import.html +++ b/evap/staff/templates/staff_semester_import.html @@ -50,7 +50,7 @@

{% translate 'Import semester data' %}

{% include 'bootstrap_datetimepicker.html' %} {% endblock %} diff --git a/evap/staff/templates/staff_user_import.html b/evap/staff/templates/staff_user_import.html index 7225d7077c..3d3ebf19df 100644 --- a/evap/staff/templates/staff_user_import.html +++ b/evap/staff/templates/staff_user_import.html @@ -53,6 +53,6 @@

{% translate 'Import users' %}

{% block additional_javascript %} {% endblock %} diff --git a/evap/staff/tests/test_live.py b/evap/staff/tests/test_live.py new file mode 100644 index 0000000000..e2a208aabd --- /dev/null +++ b/evap/staff/tests/test_live.py @@ -0,0 +1,73 @@ +from datetime import date, datetime + +from django.urls import reverse +from model_bakery import baker +from selenium.webdriver.common.by import By +from selenium.webdriver.support.expected_conditions import element_to_be_clickable, visibility_of_element_located + +from evap.evaluation.models import Contribution, Course, Evaluation, Program, Question, Questionnaire, UserProfile +from evap.evaluation.tests.tools import LiveServerTest + + +class EvaluationEditLiveTest(LiveServerTest): + def test_submit_changes_form_data(self): + """Regression test for #1769""" + + responsible = baker.make(UserProfile) + evaluation = baker.make( + Evaluation, + course=baker.make(Course, programs=[baker.make(Program)], responsibles=[responsible]), + vote_start_datetime=datetime(2099, 1, 1, 0, 0), + vote_end_date=date(2099, 12, 31), + ) + + general_questionnaire = baker.make(Questionnaire, questions=[baker.make(Question)]) + evaluation.general_contribution.questionnaires.set([general_questionnaire]) + + contribution1 = baker.make( + Contribution, + evaluation=evaluation, + contributor=responsible, + order=0, + role=Contribution.Role.CONTRIBUTOR, + textanswer_visibility=Contribution.TextAnswerVisibility.OWN_TEXTANSWERS, + ) + baker.make( + Contribution, + evaluation=evaluation, + contributor=baker.make(UserProfile), + order=1, + role=Contribution.Role.EDITOR, + ) + + with self.enter_staff_mode(): + self.selenium.get(self.live_server_url + reverse("staff:evaluation_edit", args=[evaluation.pk])) + + row = self.wait.until(visibility_of_element_located((By.CSS_SELECTOR, "#id_contributions-0-contributor"))) + tomselect_options = row.get_property("tomselect")["options"] + manager_text = "manager (manager@institution.example.com)" + manager_options = [key for key, value in tomselect_options.items() if value["text"] == manager_text] + self.assertEqual(len(manager_options), 1) + self.selenium.execute_script( + f"""let tomselect = document.querySelector("#id_contributions-0-contributor").tomselect; + tomselect.setValue("{manager_options[0]}");""" + ) + + submit_btn = self.wait.until( + element_to_be_clickable((By.XPATH, "//button[@name='operation' and @value='save']")) + ) + + editor_labels = self.selenium.find_elements(By.XPATH, "//label[contains(text(), 'Editor')]") + own_and_general_labels = self.selenium.find_elements(By.XPATH, "//label[contains(text(), 'Own and general')]") + editor_labels[0].click() + own_and_general_labels[0].click() + + with self.enter_staff_mode(): + submit_btn.click() + + contribution1.refresh_from_db() + + self.assertEqual(contribution1.contributor_id, self.manager.id) + self.assertEqual(contribution1.order, 0) + self.assertEqual(contribution1.role, Contribution.Role.EDITOR) + self.assertEqual(contribution1.textanswer_visibility, Contribution.TextAnswerVisibility.GENERAL_TEXTANSWERS) diff --git a/evap/staff/tests/test_views.py b/evap/staff/tests/test_views.py index c0fa4520c8..2247a65b7c 100644 --- a/evap/staff/tests/test_views.py +++ b/evap/staff/tests/test_views.py @@ -45,7 +45,6 @@ let_user_vote_for_evaluation, make_manager, make_rating_answer_counters, - render_pages, submit_with_modal, ) from evap.grades.models import GradeDocument @@ -610,12 +609,6 @@ def setUpTestData(cls): cls.manager = make_manager() - @render_pages - def render_pages(self): - return { - "normal": self.app.get(self.url, user=self.manager).content, - } - def test_success_handling(self): """ Tests whether a correct excel file is correctly tested and imported and whether the success messages are displayed @@ -2159,8 +2152,6 @@ def get_post_params(cls): ] ) class TestEvaluationEditView(WebTestStaffMode): - render_pages_url = "/staff/semester/PK/evaluation/PK/edit" - @classmethod def setUpTestData(cls): cls.manager = make_manager() @@ -2202,12 +2193,6 @@ def setUpTestData(cls): cls.contribution1.questionnaires.set([cls.contributor_questionnaire]) cls.contribution2.questionnaires.set([cls.contributor_questionnaire]) - @render_pages - def render_pages(self): - return { - "normal": self.app.get(self.url, user=self.manager).content, - } - def test_edit_evaluation(self): page = self.app.get(self.url, user=self.manager) diff --git a/evap/static/ts/src/copy-to-clipboard.ts b/evap/static/ts/src/copy-to-clipboard.ts index 8d22e756d6..24eead05de 100644 --- a/evap/static/ts/src/copy-to-clipboard.ts +++ b/evap/static/ts/src/copy-to-clipboard.ts @@ -1,19 +1,3 @@ -function copyToClipboard(text: string) { - const selection = document.getSelection()!; - const el = document.createElement("textarea"); - el.value = text; - document.body.appendChild(el); - const selected = selection.rangeCount > 0 ? selection.getRangeAt(0) : false; - el.select(); - // eslint-disable-next-line @typescript-eslint/no-deprecated - document.execCommand("copy"); // required by puppeteer tests - el.remove(); - if (selected) { - selection.removeAllRanges(); - selection.addRange(selected); - } -} - -function copyHeaders(headers: string[]) { - copyToClipboard(headers.join("\t")); +async function copyHeaders(headers: string[]): Promise { + await navigator.clipboard.writeText(headers.join("\t")); } diff --git a/evap/static/ts/tests/frontend/results-index.ts b/evap/static/ts/tests/frontend/results-index.ts deleted file mode 100644 index 72d4dab0dd..0000000000 --- a/evap/static/ts/tests/frontend/results-index.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Page } from "puppeteer"; - -import { pageHandler } from "../utils/page"; -import "../utils/matchers"; - -async function fetchVisibleRows(page: Page): Promise { - return await page.$$eval(".heading-row", rows => - rows.map(row => { - const evaluationName = row.querySelector(".evaluation-name")!.textContent!.trim(); - const semester = row.querySelector(".semester-short-name")!.textContent!.trim(); - return [evaluationName, semester]; - }), - ); -} - -test( - "initially sort by evaluation and semester", - pageHandler("results/student.html", async page => { - expect(await fetchVisibleRows(page)).toEqual([ - ["Course A", "ST 14"], - ["Course A", "WT 13/14"], - ["Course A", "ST 13"], - ["Course C", "ST 14"], - ["Course D", "ST 14"], - ["Course E", "ST 13"], - ]); - }), -); - -test( - "filter with search input", - pageHandler("results/student.html", async page => { - await page.type("input[name=search]", "Exam"); - await new Promise(resolve => setTimeout(resolve, 200)); // wait for input to debounce - - expect(await fetchVisibleRows(page)).toEqual([["Course A", "ST 13"]]); - }), -); - -test( - "filter with program checkbox", - pageHandler("results/student.html", async page => { - await page.click("input[name=program][data-filter='Bachelor A']"); - - expect(await fetchVisibleRows(page)).toEqual([ - ["Course A", "ST 14"], - ["Course A", "WT 13/14"], - ["Course A", "ST 13"], - ["Course C", "ST 14"], - ]); - }), -); - -test( - "filter with course type checkbox", - pageHandler("results/student.html", async page => { - await page.click("input[name=courseType][data-filter=Seminar]"); - - expect(await fetchVisibleRows(page)).toEqual([ - ["Course C", "ST 14"], - ["Course E", "ST 13"], - ]); - }), -); - -test( - "filter with semester checkbox", - pageHandler("results/student.html", async page => { - await page.click("input[name=semester][data-filter='ST 13']"); - - expect(await fetchVisibleRows(page)).toEqual([ - ["Course A", "ST 13"], - ["Course E", "ST 13"], - ]); - }), -); - -test( - "clear filters", - pageHandler("results/student.html", async page => { - const searchInput = (await page.$("input[name=search]"))!; - const programCheckbox = (await page.$("input[name=program][data-filter='Bachelor A']"))!; - const courseTypeCheckbox = (await page.$("input[name=courseType][data-filter=Lecture]"))!; - const semesterCheckbox = (await page.$("input[name=semester][data-filter='ST 14']"))!; - - await searchInput.type("Some search text"); - await programCheckbox.click(); - await courseTypeCheckbox.click(); - await semesterCheckbox.click(); - - await page.click("[data-reset=filter]"); - - expect(await searchInput.evaluate(searchInput => searchInput.value)).toBe(""); - await expect(programCheckbox).not.toBeChecked(); - await expect(courseTypeCheckbox).not.toBeChecked(); - await expect(semesterCheckbox).not.toBeChecked(); - }), -); diff --git a/evap/static/ts/tests/frontend/staff-evaluation-edit.ts b/evap/static/ts/tests/frontend/staff-evaluation-edit.ts deleted file mode 100644 index 53de76d003..0000000000 --- a/evap/static/ts/tests/frontend/staff-evaluation-edit.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { test, expect } from "@jest/globals"; -import { ElementHandle } from "puppeteer"; - -import { pageHandler } from "../utils/page"; -import { assertDefined } from "../../src/utils"; - -// regression test for #1769 -test( - "changes form data", - pageHandler("/staff/semester/PK/evaluation/PK/edit/normal.html", async page => { - const managerId = await page.evaluate(() => { - const tomselect = (document.getElementById("id_contributions-0-contributor") as any).tomselect; - const options = tomselect.options; - const managerOption = Object.keys(options).find( - key => options[key].text == "manager (manager@institution.example.com)", - ); - tomselect.setValue(managerOption); - return managerOption; - }); - assertDefined(managerId); - - const editorLabels = await page.$$("xpath/.//label[contains(text(), 'Editor')]"); - const ownAndGeneralLabels = await page.$$("xpath/.//label[contains(text(), 'Own and general')]"); - if (editorLabels.length < 1 || ownAndGeneralLabels.length < 1) { - throw new Error("Button group buttons not found."); - } - - await editorLabels[0].click(); - await ownAndGeneralLabels[0].click(); - - const formData = await page.evaluate(() => - Object.fromEntries(new FormData(document.getElementById("evaluation-form") as HTMLFormElement)), - ); - - expect(formData["contributions-0-contributor"]).toBe(managerId); - expect(formData["contributions-0-order"]).toBe("0"); - expect(formData["contributions-0-role"]).toBe("1"); - expect(formData["contributions-0-textanswer_visibility"]).toBe("GENERAL"); - }), -); diff --git a/evap/static/ts/tests/frontend/staff-user-import.ts b/evap/static/ts/tests/frontend/staff-user-import.ts deleted file mode 100644 index 95f9eafb78..0000000000 --- a/evap/static/ts/tests/frontend/staff-user-import.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { test, expect } from "@jest/globals"; - -import { pageHandler } from "../utils/page"; - -test( - "copies header", - pageHandler("staff/user/import/normal.html", async page => { - await page.click(".btn-link"); - const copiedText = await page.evaluate(() => navigator.clipboard.readText()); - expect(copiedText).toBe("Title\tFirst name\tLast name\tEmail"); - }), -); diff --git a/evap/static/ts/tests/frontend/student-vote.ts b/evap/static/ts/tests/frontend/student-vote.ts deleted file mode 100644 index 6def8a1601..0000000000 --- a/evap/static/ts/tests/frontend/student-vote.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Page, ElementHandle } from "puppeteer"; - -import { pageHandler } from "../utils/page"; -import "../utils/matchers"; - -interface TextResultsPublishConfirmationElements { - top: ElementHandle; - bottom: ElementHandle; - bottomCard: ElementHandle; -} - -async function query(page: Page): Promise { - return { - top: (await page.$("#text_results_publish_confirmation_top"))!, - bottom: (await page.$("#text_results_publish_confirmation_bottom"))!, - bottomCard: (await page.$("#bottom_text_results_publish_confirmation_card"))!, - }; -} - -async function queryClosest(element: ElementHandle, selector: string): Promise> { - return element - .evaluateHandle((element, selector) => element.closest(selector), selector) - .then(handle => handle.asElement()!); -} - -test( - "checking top confirm checkbox checks and hides bottom", - pageHandler("student/vote/PK/with_textanswer_publish_confirmation.html", async page => { - const elements = await query(page); - await elements.top.click(); - - await expect(elements.bottom).toBeChecked(); - await expect(elements.bottomCard).toHaveClass("d-none"); - }), -); - -test( - "checking bottom confirm checkbox check top but keeps bottom visible", - pageHandler("student/vote/PK/with_textanswer_publish_confirmation.html", async page => { - const elements = await query(page); - await elements.bottom.click(); - - await expect(elements.top).toBeChecked(); - await expect(elements.bottomCard).not.toHaveClass("d-none"); - }), -); - -test( - "resolving submit errors clears warning", - pageHandler("student/vote/PK/submit_errors.html", async page => { - const checkbox = (await page.$(".choice-error + input[type=radio][value='3']"))!; - await checkbox.click(); - const row = await queryClosest(checkbox, ".row"); - expect(await row.$$(".choice-error")).toHaveLength(0); - }), -); - -test( - "skip contributor", - pageHandler("student/vote/PK/normal.html", async page => { - const button = (await page.$("[data-mark-no-answers-for]"))!; - const voteArea = (await queryClosest(button, ".card").then(card => card.$(".collapse")))!; - await button.click(); - for (const checkbox of await voteArea.$$("input[type=radio]:not([value='6'])")) { - await expect(checkbox).not.toBeChecked(); - } - for (const checkbox of await voteArea.$$("input[type=radio][value='6']")) { - await expect(checkbox).toBeChecked(); - } - await expect(voteArea).toHaveClass("collapsing"); - }), -); - -test( - "skipping contributor clears warning", - pageHandler("student/vote/PK/submit_errors.html", async page => { - const button = (await page.$("[data-mark-no-answers-for]"))!; - const voteArea = (await queryClosest(button, ".card").then(card => card.$(".collapse")))!; - await button.click(); - expect(await voteArea.$$(".choice-error")).toHaveLength(0); - }), -); diff --git a/evap/static/ts/tests/utils/matchers.ts b/evap/static/ts/tests/utils/matchers.ts deleted file mode 100644 index 6f442ca7de..0000000000 --- a/evap/static/ts/tests/utils/matchers.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { ElementHandle } from "puppeteer"; -import MatcherUtils = jest.MatcherUtils; - -declare global { - namespace jest { - interface Matchers { - toBeChecked(): Promise; - - toHaveClass(className: string): Promise; - } - } -} - -function createTagDescription(element: ElementHandle): Promise { - return element.evaluate(element => { - let tagDescription = element.tagName.toLowerCase(); - if (element.id) { - tagDescription += ` id="${element.id}"`; - } - if (element.className) { - tagDescription += ` class="${element.className}"`; - } - return `<${tagDescription}>`; - }); -} - -async function createElementMessage( - this: MatcherUtils, - matcherName: string, - expectation: string, - element: ElementHandle, - value?: any, -): Promise<() => string> { - const tagDescription = await createTagDescription(element); - return () => { - const optionallyNot = this.isNot ? "not " : ""; - const receivedLine = value ? `\nReceived: ${this.utils.printReceived(value)}` : ""; - return ( - this.utils.matcherHint(matcherName, undefined, undefined, { isNot: this.isNot }) + - "\n\n" + - `Expected ${this.utils.RECEIVED_COLOR(tagDescription)} to ${optionallyNot}${expectation}` + - receivedLine - ); - }; -} - -expect.extend({ - async toBeChecked(received: ElementHandle): Promise { - const pass = await received.evaluate(element => (element as HTMLInputElement).checked); - const message = await createElementMessage.call(this, "toBeChecked", "be checked", received); - return { message, pass }; - }, - - async toHaveClass(received: ElementHandle, className: string): Promise { - const classList = await received.evaluate(element => [...element.classList]); - const pass = classList.includes(className); - const message = await createElementMessage.call( - this, - "toHaveClass", - `have the class ${this.utils.printExpected(className)}`, - received, - classList, - ); - - return { message, pass }; - }, -}); diff --git a/evap/static/ts/tests/utils/page.ts b/evap/static/ts/tests/utils/page.ts deleted file mode 100644 index 8431f0caa7..0000000000 --- a/evap/static/ts/tests/utils/page.ts +++ /dev/null @@ -1,84 +0,0 @@ -import * as fs from "fs"; -import * as path from "path"; -import { Browser, Page } from "puppeteer"; -import { Global } from "@jest/types/"; -import DoneFn = Global.DoneFn; - -const contentTypeByExtension = new Map([ - [".css", "text/css"], - [".js", "application/javascript"], - [".png", "image/png"], - [".svg", "image/svg+xml"], -]); - -async function createPage(browser: Browser): Promise { - const staticPrefix = "/static/"; - - const page = await browser.newPage(); - await page.setRequestInterception(true); - page.on("request", async request => { - const extension = path.extname(request.url()); - const pathname = new URL(request.url()).pathname; - if (extension === ".html") { - // requests like /evap/evap/static/ts/rendered/results/student.html - await request.continue(); - } else if (pathname.startsWith(staticPrefix)) { - // requests like /static/css/tom-select.bootstrap5.min.css - const asset = pathname.substring(staticPrefix.length); - const body = fs.readFileSync(path.join(__dirname, "..", "..", "..", asset)); - await request.respond({ - contentType: contentTypeByExtension.get(extension), - body, - }); - } else if (pathname.endsWith("catalog.js")) { - // request for /catalog.js - // some pages will error out if translation functions are not available - // rendered in RenderJsTranslationCatalog - const absolute_fs_path = path.join(__dirname, "..", "..", "..", "ts", "rendered", "catalog.js"); - const body = fs.readFileSync(absolute_fs_path); - await request.respond({ - contentType: contentTypeByExtension.get(extension), - body, - }); - } else { - await request.abort(); - } - }); - return page; -} - -export function pageHandler(fileName: string, fn: (page: Page) => Promise): (done?: DoneFn) => Promise { - return async done => { - let finished = false; - // This wrapper ensures that done() is only called once - async function finish(reason?: Error) { - if (!finished) { - finished = true; - await page.evaluate(() => { - localStorage.clear(); - }); - await page.close(); - done!(reason); - } - } - - const context = browser.defaultBrowserContext(); - await context.overridePermissions("file:", ["clipboard-read"]); - - const page = await createPage(browser); - page.on("pageerror", async error => { - await finish(new Error(error.message)); - }); - - const filePath = path.join(__dirname, "..", "..", "rendered", fileName); - await page.goto(`file:${filePath}`, { waitUntil: "networkidle0" }); - - try { - await fn(page); - await finish(); - } catch (error) { - if (error instanceof Error) await finish(error); - else throw error; - } - }; -} diff --git a/evap/student/tests/test_live.py b/evap/student/tests/test_live.py new file mode 100644 index 0000000000..aa40d0d16d --- /dev/null +++ b/evap/student/tests/test_live.py @@ -0,0 +1,120 @@ +from django.test import override_settings +from django.urls import reverse +from model_bakery import baker +from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webelement import WebElement +from selenium.webdriver.support.expected_conditions import presence_of_element_located, visibility_of_element_located + +from evap.evaluation.models import Contribution, Evaluation, Question, Questionnaire, QuestionType, UserProfile +from evap.evaluation.tests.tools import LiveServerTest + + +class StudentVoteLiveTest(LiveServerTest): + + def setUp(self) -> None: + super().setUp() + voting_user1 = baker.make(UserProfile, email="voting_user1@institution.example.com") + voting_user2 = baker.make(UserProfile, email="voting_user2@institution.example.com") + contributor1 = baker.make(UserProfile, email="contributor1@institution.example.com") + contributor2 = baker.make(UserProfile, email="contributor2@institution.example.com") + + evaluation = baker.make( + Evaluation, + participants=[voting_user1, voting_user2, contributor1], + state=Evaluation.State.IN_EVALUATION, + ) + + top_general_questionnaire = baker.make(Questionnaire, type=Questionnaire.Type.TOP) + bottom_general_questionnaire = baker.make(Questionnaire, type=Questionnaire.Type.BOTTOM) + contributor_questionnaire = baker.make(Questionnaire, type=Questionnaire.Type.CONTRIBUTOR) + + baker.make(Question, questionnaire=contributor_questionnaire, order=0, type=QuestionType.HEADING) + baker.make(Question, questionnaire=contributor_questionnaire, order=1, type=QuestionType.TEXT) + baker.make(Question, questionnaire=contributor_questionnaire, order=2, type=QuestionType.POSITIVE_LIKERT) + + baker.make(Question, questionnaire=top_general_questionnaire, order=0, type=QuestionType.HEADING) + baker.make(Question, questionnaire=top_general_questionnaire, order=1, type=QuestionType.TEXT) + baker.make(Question, questionnaire=top_general_questionnaire, order=2, type=QuestionType.POSITIVE_LIKERT) + baker.make(Question, questionnaire=top_general_questionnaire, order=3, type=QuestionType.GRADE) + + baker.make(Question, questionnaire=bottom_general_questionnaire, order=0, type=QuestionType.HEADING) + baker.make(Question, questionnaire=bottom_general_questionnaire, order=1, type=QuestionType.TEXT) + baker.make(Question, questionnaire=bottom_general_questionnaire, order=2, type=QuestionType.POSITIVE_LIKERT) + baker.make(Question, questionnaire=bottom_general_questionnaire, order=3, type=QuestionType.GRADE) + + baker.make( + Contribution, + contributor=contributor1, + questionnaires=[contributor_questionnaire], + evaluation=evaluation, + ) + baker.make( + Contribution, + contributor=contributor2, + questionnaires=[contributor_questionnaire], + evaluation=evaluation, + ) + + evaluation.general_contribution.questionnaires.set([top_general_questionnaire, bottom_general_questionnaire]) + self.url = self.live_server_url + reverse("student:vote", args=[evaluation.pk]) + self.login(voting_user1) + + def _get_publish_confirmation(self) -> dict[str, WebElement]: + return { + "top": self.wait.until(visibility_of_element_located((By.ID, "text_results_publish_confirmation_top"))), + "bottom": self.selenium.find_element(By.ID, "text_results_publish_confirmation_bottom"), + "bottom_card": self.selenium.find_element(By.ID, "bottom_text_results_publish_confirmation_card"), + } + + def test_checking_top_confirm_checkbox_checks_and_hides_bottom(self): + self.selenium.get(self.url) + confirmation = self._get_publish_confirmation() + + confirmation["top"].click() + + self.assertTrue(confirmation["bottom"].is_selected()) + self.assertIn("d-none", confirmation["bottom_card"].get_attribute("class")) + + def test_checking_bottom_confirm_checkbox_check_top_but_keeps_bottom_visible(self): + self.selenium.get(self.url) + confirmation = self._get_publish_confirmation() + + confirmation["bottom"].click() + + self.assertTrue(confirmation["top"].is_selected()) + self.assertNotIn("d-none", confirmation["bottom_card"].get_attribute("class")) + + def test_resolving_submit_errors_clears_warning(self) -> None: + self.selenium.get(self.url) + self.wait.until(presence_of_element_located((By.ID, "vote-submit-btn"))).click() + + row = self.selenium.find_element(By.CSS_SELECTOR, "#student-vote-form .row:has(.btn-check)") + checkbox = row.find_element(By.CSS_SELECTOR, "input[type=radio][value='2'] + label.choice-error") + checkbox.click() + self.assertEqual(row.find_elements(By.CSS_SELECTOR, ".choice-error"), []) + + @override_settings(SMALL_COURSE_SIZE=2) + def test_skip_contributor(self) -> None: + self.selenium.get(self.url) + + button = self.wait.until(presence_of_element_located((By.CSS_SELECTOR, "[data-mark-no-answers-for]"))) + button.click() + id_ = button.get_attribute("data-mark-no-answers-for") + vote_area = self.selenium.find_element(By.ID, f"vote-area-{id_}") + + for checkbox in vote_area.find_elements(By.CSS_SELECTOR, "input[type=radio]:not([value='6'])"): + self.assertFalse(checkbox.is_selected()) + + for checkbox in vote_area.find_elements(By.CSS_SELECTOR, "input[type=radio][value='6']"): + self.assertTrue(checkbox.is_selected()) + + self.assertIn(vote_area.get_attribute("class"), ("collapsing", "collapse")) + + @override_settings(SMALL_COURSE_SIZE=2) + def test_skip_contributor_clears_warning(self) -> None: + self.selenium.get(self.url) + button = self.wait.until(presence_of_element_located((By.CSS_SELECTOR, "[data-mark-no-answers-for]"))) + button.click() + + id_ = button.get_attribute("data-mark-no-answers-for") + self.assertEqual(len(self.selenium.find_elements(By.CSS_SELECTOR, f"#vote-area-{id_} .choice-error")), 0) diff --git a/evap/student/tests/test_views.py b/evap/student/tests/test_views.py index 455c1c6762..0aaa22ae14 100644 --- a/evap/student/tests/test_views.py +++ b/evap/student/tests/test_views.py @@ -18,7 +18,7 @@ UserProfile, VoteTimestamp, ) -from evap.evaluation.tests.tools import FuzzyInt, WebTest, WebTestWith200Check, render_pages +from evap.evaluation.tests.tools import FuzzyInt, WebTest, WebTestWith200Check from evap.student.tools import answer_field_id from evap.student.views import SUCCESS_MAGIC_STRING @@ -145,8 +145,6 @@ def test_global_reward_progress_hidden(self): @override_settings(INSTITUTION_EMAIL_DOMAINS=["example.com"]) class TestVoteView(WebTest): - render_pages_url = "/student/vote/PK" - @classmethod def setUpTestData(cls): cls.voting_user1 = baker.make(UserProfile, email="voting_user1@institution.example.com") @@ -218,20 +216,6 @@ def setUpTestData(cls): [cls.top_general_questionnaire, cls.bottom_general_questionnaire] ) - @render_pages - def render_pages(self): - with_confirmation = self.app.get(self.url, user=self.voting_user1) - submit_errors = with_confirmation.forms["student-vote-form"].submit() - - with override_settings(SMALL_COURSE_SIZE=2): - normal = self.app.get(self.url, user=self.voting_user1) - - return { - "with_textanswer_publish_confirmation": with_confirmation.content, - "submit_errors": submit_errors.content, - "normal": normal.content, - } - def test_question_ordering(self): page = self.app.get(self.url, user=self.voting_user1, status=200) diff --git a/flake.nix b/flake.nix index 5cf247f535..29686fe1dd 100644 --- a/flake.nix +++ b/flake.nix @@ -45,6 +45,7 @@ workspaceRoot = ./.; }; evap-dev = evap.override (prev: { dependency-groups = (prev.dependency-groups or [ ]) ++ [ "dev" ]; }); + evap-frontend-dev = evap-dev.overrideAttrs (prev: { nativeBuildInputs = (prev.nativeBuildInputs or [ ]) ++ (with pkgs; [ firefox geckodriver ]); }); default = evap-dev; impure = pkgs.mkShell { diff --git a/package.json b/package.json index c5b9eda606..4e0b538dae 100644 --- a/package.json +++ b/package.json @@ -2,17 +2,14 @@ "devDependencies": { "@types/bootstrap": "^5.2.10", "@types/jest": "^29.5.12", - "@types/jest-environment-puppeteer": "^5.0.3", "@types/jquery": "^3.5.16", "@types/sortablejs": "^1.3.32", "@eslint/js": "^9.20.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "jest-environment-puppeteer": "^11.0.0", "jest-jasmine2": "^29.7.0", "jest-ts-webcompat-resolver": "^1.0.0", "prettier": "^3.5.1", - "puppeteer": "^24.0.0", "sass": "1.77.1", "ts-jest": "^29.2.0", "typescript": "^5.7.2", @@ -32,9 +29,6 @@ "transform": { "^.+\\.ts$": "ts-jest" }, - "globalSetup": "jest-environment-puppeteer/setup", - "globalTeardown": "jest-environment-puppeteer/teardown", - "testEnvironment": "jest-environment-puppeteer", "resolver": "jest-ts-webcompat-resolver" }, "type": "module" diff --git a/pyproject.toml b/pyproject.toml index 1004e6cf20..a6f9c6c7bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dev = [ "tblib~=3.0.0", "xlrd~=2.0.1", "typeguard~=4.4.0", + "selenium~=4.27.0", ] [tool.uv] diff --git a/uv.lock b/uv.lock index 01f675ccd7..7ea3312a80 100644 --- a/uv.lock +++ b/uv.lock @@ -34,6 +34,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233 }, ] +[[package]] +name = "attrs" +version = "24.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/0f/aafca9af9315aee06a89ffde799a10a582fe8de76c563ee80bbcdc08b3fb/attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", size = 792678 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/21/5b6702a7f963e95456c0de2d495f67bf5fd62840ac655dc451586d23d39a/attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2", size = 63001 }, +] + [[package]] name = "beautifulsoup4" version = "4.12.3" @@ -348,6 +357,7 @@ dev = [ { name = "pylint" }, { name = "pylint-django" }, { name = "ruff" }, + { name = "selenium" }, { name = "tblib" }, { name = "typeguard" }, { name = "xlrd" }, @@ -382,11 +392,30 @@ dev = [ { name = "pylint", specifier = "~=3.3.0" }, { name = "pylint-django", specifier = "~=2.6.1" }, { name = "ruff", specifier = "~=0.8.0" }, + { name = "selenium", specifier = "~=4.27.0" }, { name = "tblib", specifier = "~=3.0.0" }, { name = "typeguard", specifier = "~=4.4.0" }, { name = "xlrd", specifier = "~=2.0.1" }, ] +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + [[package]] name = "idna" version = "3.10" @@ -508,6 +537,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/04/5847e2e75c39f80e65c388f8e292719b46c00cda2c987f2d5c53ed617d11/openpyxl_stubs-0.1.25-py3-none-any.whl", hash = "sha256:db29f7804993b4a46b155fc4be45314c14538cb475b00591d8096e5af486abf1", size = 26598 }, ] +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692 }, +] + [[package]] name = "packaging" version = "24.2" @@ -645,6 +686,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/dd/e0aa7ebef5168c75b772eda64978c597a9129b46be17779054652a7999e4/pyOpenSSL-24.2.1-py3-none-any.whl", hash = "sha256:967d5719b12b243588573f39b0c677637145c7a1ffedcd495a487e58177fbb8d", size = 58390 }, ] +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725 }, +] + [[package]] name = "redis" version = "5.2.0" @@ -697,6 +747,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/bc/cc8a6a5ca4960b226dc15dd8fb511dd11f2014ff89d325c0b9b9faa9871f/ruff-0.8.0-py3-none-win_arm64.whl", hash = "sha256:ba93e6294e9a737cd726b74b09a6972e36bb511f9a102f1d9a7e1ce94dd206a6", size = 8939733 }, ] +[[package]] +name = "selenium" +version = "4.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "trio" }, + { name = "trio-websocket" }, + { name = "typing-extensions" }, + { name = "urllib3", extra = ["socks"] }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/8c/62c47c91072aa03af1c3b7d7f1c59b987db41c9fec0f158fb03a0da51aa6/selenium-4.27.1.tar.gz", hash = "sha256:5296c425a75ff1b44d0d5199042b36a6d1ef76c04fb775b97b40be739a9caae2", size = 973526 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/1e/5f1a5dd2a28528c4b3ec6e076b58e4c035810c805328f9936123283ca14e/selenium-4.27.1-py3-none-any.whl", hash = "sha256:b89b1f62b5cfe8025868556fe82360d6b649d464f75d2655cb966c8f8447ea18", size = 9707007 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, +] + [[package]] name = "soupsieve" version = "2.6" @@ -742,6 +827,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 }, ] +[[package]] +name = "trio" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "exceptiongroup" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/d1/a83dee5be404da7afe5a71783a33b8907bacb935a6dc8c69ab785e4a3eed/trio-0.27.0.tar.gz", hash = "sha256:1dcc95ab1726b2da054afea8fd761af74bad79bd52381b84eae408e983c76831", size = 568064 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/83/ec3196c360afffbc5b342ead48d1eb7393dd74fa70bca75d33905a86f211/trio-0.27.0-py3-none-any.whl", hash = "sha256:68eabbcf8f457d925df62da780eff15ff5dc68fd6b367e2dde59f7aaf2a0b884", size = 481734 }, +] + +[[package]] +name = "trio-websocket" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup" }, + { name = "trio" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/36/abad2385853077424a11b818d9fd8350d249d9e31d583cb9c11cd4c85eda/trio-websocket-0.11.1.tar.gz", hash = "sha256:18c11793647703c158b1f6e62de638acada927344d534e3c7628eedcb746839f", size = 26511 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/be/a9ae5f50cad5b6f85bd2574c2c923730098530096e170c1ce7452394d7aa/trio_websocket-0.11.1-py3-none-any.whl", hash = "sha256:520d046b0d030cf970b8b2b2e00c4c2245b3807853ecd44214acd33d74581638", size = 17408 }, +] + [[package]] name = "typeguard" version = "4.4.1" @@ -790,6 +907,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, ] +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, +] + [[package]] name = "waitress" version = "3.0.2" @@ -808,6 +930,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/bd/c336448be43d40be28e71f2e0f3caf7ccb28e2755c58f4c02c065bfe3e8e/WebOb-1.8.9-py2.py3-none-any.whl", hash = "sha256:45e34c58ed0c7e2ecd238ffd34432487ff13d9ad459ddfd77895e67abba7c1f9", size = 115364 }, ] +[[package]] +name = "websocket-client" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826 }, +] + [[package]] name = "webtest" version = "3.0.1" @@ -822,6 +953,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2b/6d/075023456a2ff8e01ef07afa069563f0d1e1a2fd359d7dbd7672a5bf218a/WebTest-3.0.1-py3-none-any.whl", hash = "sha256:b3bc75d020d0576ee93a5f149666045e58fe2400ea5f0c214d7430d7d213d0d0", size = 32154 }, ] +[[package]] +name = "wsproto" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", size = 53425 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226 }, +] + [[package]] name = "xlrd" version = "2.0.1"