diff --git a/Makefile b/Makefile index 3b60ef1fcc6ae7a03155ff09074a4957936e26ee..b20a9c5dab125909f43161fafcd82f4552c4640b 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,10 @@ test: teste2e: cd frontend && yarn build && cp dist/index.html ../core/templates && cd .. && python util/format_index.py && python manage.py collectstatic --no-input && HEADLESS_TESTS=$(headless) pytest --ds=grady.settings $(path); git checkout core/templates/index.html +teste2e-nc: + cp frontend/dist/index.html ./core/templates && python util/format_index.py && python manage.py collectstatic --no-input && HEADLESS_TESTS=$(headless) pytest --ds=grady.settings $(path); git checkout core/templates/index.html + + coverage: DJANGO_SETTINGS_MODULE=grady.settings pytest --cov coverage html diff --git a/frontend/src/components/SubmissionTests.vue b/frontend/src/components/SubmissionTests.vue index d782693f54b81eb141b0c4dc9d480c4ec049e103..0012808ed1e6a179152032f59cb18a92c6c17521 100644 --- a/frontend/src/components/SubmissionTests.vue +++ b/frontend/src/components/SubmissionTests.vue @@ -1,5 +1,5 @@ <template> - <v-card> + <v-card id="submission-tests"> <v-card-title class="title py-0" v-if="tests.length > 0"> Tests <v-spacer/> @@ -12,15 +12,15 @@ No Tests available </v-card-title> <v-expansion-panel v-if="expanded"> - <v-expansion-panel-content v-for="item in tests" :key="item.pk"> - <div slot="header"> - <v-layout row class="pr-4"> + <v-expansion-panel-content v-for="item in tests" :key="item.pk" > + <div slot="header" name="test-name-label"> + <v-layout row class="pr-4" > {{item.name}} <v-spacer/> {{item.label}} </v-layout> </div> - <v-card> + <v-card name="test-output"> <v-card-text class="test-output">{{item.annotation}}</v-card-text> </v-card> </v-expansion-panel-content> diff --git a/frontend/src/components/SubmissionType.vue b/frontend/src/components/SubmissionType.vue index 279ba35580c70ecf5f360eadc9ecdaba803e9be5..3554e20de4eee5ef5b19dba92fc67e4605d2a338 100644 --- a/frontend/src/components/SubmissionType.vue +++ b/frontend/src/components/SubmissionType.vue @@ -1,6 +1,6 @@ <template> <v-layout column> - <v-card> + <v-card id="submission-type"> <v-card-title class="title mb-2">{{ name }} - Full score: {{ fullScore }}</v-card-title> <v-expansion-panel expand v-model="expanded"> <v-expansion-panel-content diff --git a/frontend/src/components/submission_notes/SubmissionCorrection.vue b/frontend/src/components/submission_notes/SubmissionCorrection.vue index 88811af851fd3a7fbf9682dad3ee60b6972ee84e..29d973b964d0b802c3551ce8af4b9c25accce76c 100644 --- a/frontend/src/components/submission_notes/SubmissionCorrection.vue +++ b/frontend/src/components/submission_notes/SubmissionCorrection.vue @@ -5,8 +5,8 @@ class="mb-1 elevation-1" slot="header" /> - <template slot="table-content"> - <tr v-for="(code, lineNo) in submission" :key="`${submissionObj.pk}${lineNo}`"> + <template slot="table-content" id='sub-lines'> + <tr v-for="(code, lineNo) in submission" :key="`${submissionObj.pk}${lineNo}`" :id="`sub-line-${lineNo}`"> <submission-line :code="code" :line-no="lineNo" @@ -134,7 +134,8 @@ export default { this.$notify({ title: 'Feedback creation Error!', text: err.message, - type: 'error' + type: 'error', + duration: -1 }) }).finally(() => { this.loading = false diff --git a/frontend/src/components/submission_notes/base/CommentForm.vue b/frontend/src/components/submission_notes/base/CommentForm.vue index 32989753fed1e63530dcffbe8ab46f244c0fc123..1e25cbd8d61604da53ac4c8fd62c4ef9dc5c4cb6 100644 --- a/frontend/src/components/submission_notes/base/CommentForm.vue +++ b/frontend/src/components/submission_notes/base/CommentForm.vue @@ -13,8 +13,8 @@ auto-grow hide-details /> - <v-btn color="success" @click="submitFeedback"><v-icon>check</v-icon>Submit</v-btn> - <v-btn @click="collapseTextField"><v-icon>cancel</v-icon>cancel</v-btn> + <v-btn id="submit-comment" color="success" @click="submitFeedback"><v-icon>check</v-icon>Submit</v-btn> + <v-btn id="cancel-comment" @click="collapseTextField"><v-icon>cancel</v-icon>cancel</v-btn> </div> </template> diff --git a/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue b/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue index 7e25ecf178dcc9d8d81e0cdf9f7e9fdb19122d98..0005c11b5d9fbbb3ef787bddcaa8892ccbd77d8d 100644 --- a/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue +++ b/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue @@ -3,6 +3,7 @@ <v-tooltip top v-if="skippable"> <v-btn slot="activator" + id="skip-submission" outline round color="grey darken-2" @click="skipSubmission" >Skip</v-btn> @@ -19,6 +20,7 @@ <input class="score-text-field" type="number" + id="score-input" v-model="score" @input="validateScore" @change="validateScore" @@ -26,11 +28,13 @@ <span> / {{fullScore}}</span> <v-btn outline round flat + id="score-zero" @click="score = 0" color="red lighten-1" class="score-button">0</v-btn> <v-btn outline round flat + id="score-full" @click="score = fullScore" color="blue darken-3" class="score-button">{{fullScore}}</v-btn> @@ -45,6 +49,7 @@ <v-btn color="success" slot="activator" + id="submit-feedback" :loading="loading" @click="submit" >Submit<v-icon>chevron_right</v-icon></v-btn> diff --git a/frontend/src/components/subscriptions/SubscriptionEnded.vue b/frontend/src/components/subscriptions/SubscriptionEnded.vue index 7db964e7d1ed0ceb8f83b9dadc77c14ec8886da3..598df9d8edff29e81b5330b509241060965a7d78 100644 --- a/frontend/src/components/subscriptions/SubscriptionEnded.vue +++ b/frontend/src/components/subscriptions/SubscriptionEnded.vue @@ -1,5 +1,5 @@ <template> - <v-card class="mx-auto center-page"> + <v-card class="mx-auto center-page" id="subscription-ended"> <v-card-title class="title"> It seems like your subscription has (temporarily) ended. </v-card-title> diff --git a/frontend/src/components/subscriptions/SubscriptionList.vue b/frontend/src/components/subscriptions/SubscriptionList.vue index 316b1b0e4c8cfe76618b35b84ea262d7a8576c98..fcdf863c553b5d4d94c0be5bbc12be420a8ac58a 100644 --- a/frontend/src/components/subscriptions/SubscriptionList.vue +++ b/frontend/src/components/subscriptions/SubscriptionList.vue @@ -21,7 +21,7 @@ {{item}} </v-tab> <v-tab-item v-for="(stage, i) in stages" :key="i"> - <subscriptions-for-stage :stage="stage"/> + <subscriptions-for-stage :stage="stage" :id="`stage-${i}`"/> </v-tab-item> </v-tabs> </v-card> diff --git a/functional_tests/test_feedback_creation.py b/functional_tests/test_feedback_creation.py new file mode 100644 index 0000000000000000000000000000000000000000..74486392b1adcedbff039447467284de14f1fb19 --- /dev/null +++ b/functional_tests/test_feedback_creation.py @@ -0,0 +1,245 @@ +from django.test import LiveServerTestCase +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.common.exceptions import StaleElementReferenceException +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as ec + +from core.models import UserAccount, Submission, FeedbackComment +from functional_tests.util import (login, create_browser, reset_browser_after_test, + subscriptions_loaded_cond) +from util import factory_boys as fact + + +class UntestedParent: + class TestFeedbackCreationGeneric(LiveServerTestCase): + browser: webdriver.Firefox = None + username = None + password = None + role = None + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.browser = create_browser() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + cls.browser.quit() + + def setUp(self): + self.sub_type = fact.SubmissionTypeFactory.create() + fact.SubmissionFactory.create_batch(2, type=self.sub_type) + + def tearDown(self): + reset_browser_after_test(self.browser, self.live_server_url) + + def _login(self): + login(self.browser, self.live_server_url, self.username, self.password) + + def _reconstruct_submission_code(self): + sub_table = self.browser.find_element_by_class_name('submission-table') + lines = sub_table.find_elements_by_tag_name('tr') + line_no_code_pairs = [ + (line.get_attribute('id'), + # call get_attribute here to get non normalized text + # https://github.com/SeleniumHQ/selenium/issues/2608 + line.find_element_by_class_name('code-cell-content').get_attribute('textContent')) + for line in lines + ] + line_no_code_pairs.sort(key=lambda x: x[0]) # sort by ids + code_lines = list(zip(*line_no_code_pairs))[1] + return '\n'.join(code_lines) + + # stage can be 'initial', 'validate', or 'conflict' + def _go_to_subscription(self, stage='initial', sub_type=None): + tasks = self.browser.find_element_by_name('subscription-list') + WebDriverWait(self.browser, 3).until(subscriptions_loaded_cond(tasks)) + tab = tasks.find_element_by_xpath(f'//*[contains(text(), "{stage}")]') + tab.click() + sub_type = sub_type if sub_type is not None else self.sub_type + + sub_type_xpath = f'//*[contains(text(), "{sub_type.name}") ' \ + f'and not(contains(@class, "inactive"))]' + WebDriverWait(self.browser, 3).until( + ec.element_to_be_clickable((By.XPATH, sub_type_xpath)), + message="SubmissionType not clickable" + ) + sub_type_el = tasks.find_element_by_xpath(sub_type_xpath) + sub_type_el.click() + WebDriverWait(self.browser, 3).until(ec.url_contains('subscription')) + + def write_comments_on_lines(self, line_comment_tuples): + """ line_comment_tuples is an iterable containing tuples of + (line_no, comment) where the line number starts at 1 + """ + + sub_table = self.browser.find_element_by_class_name('submission-table') + lines = sub_table.find_elements_by_tag_name('tr') + + for (line_no, comment) in line_comment_tuples: + line = lines[line_no-1] + line.find_element_by_tag_name('button').click() + textarea = line.find_element_by_tag_name('textarea') + textarea.send_keys(comment) + line.find_element_by_id('submit-comment').click() + + def test_student_text_is_correctly_displayed(self): + self._login() + self._go_to_subscription() + code = self._reconstruct_submission_code() + # query db for Submission with seen code, throws if not present and test fails + Submission.objects.get(text=code) + + def test_submission_type_is_correctly_displayed(self): + self._login() + self._go_to_subscription() + sub_type_el = self.browser.find_element_by_id('submission-type') + title = sub_type_el.find_element_by_class_name('title') + self.assertEqual( + f'{self.sub_type.name} - Full score: {self.sub_type.full_score}', + title.text + ) + solution = sub_type_el.find_element_by_class_name('solution-code') + self.assertEqual(self.sub_type.solution, solution.get_attribute('textContent')) + description = sub_type_el.find_element_by_class_name('type-description') + html_el_in_desc = description.find_element_by_tag_name('h1') + self.assertEqual('This', html_el_in_desc.text) + + def test_test_output_is_displayed(self): + # create a test for every submission + test = None + for submission in Submission.objects.all(): + test = fact.TestFactory.create(submission=submission, annotation='This is a test') + self._login() + self._go_to_subscription() + tests = self.browser.find_element_by_id('submission-tests') + name_label = tests.find_element_by_name('test-name-label') + name_label.click() + self.assertIn(test.name, name_label.text) + self.assertIn(test.label, name_label.text) + test_output = tests.find_element_by_name('test-output') + WebDriverWait(self.browser, 3).until(ec.visibility_of(test_output)) + self.assertEqual(test.annotation, test_output.text) + + def wait_until_code_changes(self, code): + def condition(*args): + try: + # code might change during the call resulting in the exception + new_code = self._reconstruct_submission_code() + except StaleElementReferenceException: + return False + return code != new_code + return condition + + def test_can_give_max_score(self): + self._login() + self._go_to_subscription() + code = self._reconstruct_submission_code() + self.browser.find_element_by_id('score-full').click() + submit_btn = self.browser.find_element_by_id('submit-feedback') + submit_btn.click() + WebDriverWait(self.browser, 3).until( + self.wait_until_code_changes(code) + ) + submission_for_code = Submission.objects.get(text=code) + self.assertEqual(self.sub_type.full_score, submission_for_code.feedback.score) + + def test_zero_score_without_warning_gives_error(self): + self._login() + self._go_to_subscription() + self.browser.find_element_by_id('score-zero').click() + submit_btn = self.browser.find_element_by_id('submit-feedback') + submit_btn.click() + notification = self.browser.find_element_by_class_name('notification-content') + self.assertIn('comment', notification.text) + + def test_can_give_zero_score(self): + self._login() + self._go_to_subscription() + code = self._reconstruct_submission_code() + self.browser.find_element_by_id('score-zero').click() + self.write_comments_on_lines([(0, 'A comment')]) + self.browser.find_element_by_id('submit-feedback').click() + WebDriverWait(self.browser, 3).until(self.wait_until_code_changes(code)) + submission_for_code = Submission.objects.get(text=code) + self.assertEqual(0, submission_for_code.feedback.score) + + def test_can_give_comments_and_decreased_score(self): + self._login() + self._go_to_subscription() + code = self._reconstruct_submission_code() + + # give half full score + score_input = self.browser.find_element_by_id('score-input') + score_input.send_keys(self.sub_type.full_score // 2) + + # give feedback on first and last line of submission + comment_text = 'This is feedback' + self.write_comments_on_lines([ + (1, comment_text), (0, comment_text) # 0 corresponds to the last line + ]) + + submit_btn = self.browser.find_element_by_id('submit-feedback') + submit_btn.click() + WebDriverWait(self.browser, 3).until( + self.wait_until_code_changes(code) + ) + submission_for_code = Submission.objects.get(text=code) + self.assertEqual(self.sub_type.full_score // 2, submission_for_code.feedback.score) + self.assertEqual(2, submission_for_code.feedback.feedback_lines.count()) + fst_comment = FeedbackComment.objects.get( + of_feedback=submission_for_code.feedback, + of_line=1 + ) + self.assertEqual(comment_text, fst_comment.text) + last_line_of_sub = len(submission_for_code.text.split('\n')) + snd_comment = FeedbackComment.objects.get( + of_feedback=submission_for_code.feedback, + of_line=last_line_of_sub + ) + self.assertEqual(comment_text, snd_comment.text) + + def test_can_skip_submission(self): + self._login() + self._go_to_subscription() + code = self._reconstruct_submission_code() + self.browser.find_element_by_id('skip-submission').click() + WebDriverWait(self.browser, 3).until(self.wait_until_code_changes(code)) + + def test_can_validate_submission(self): + self._login() + self._go_to_subscription() + code = self._reconstruct_submission_code() + self.write_comments_on_lines([(0, 'A comment by me')]) + self.browser.find_element_by_id('score-zero').click() + self.browser.find_element_by_id('submit-feedback').click() + WebDriverWait(self.browser, 3).until(self.wait_until_code_changes(code)) + reset_browser_after_test(self.browser, self.live_server_url) # logs out user + + user_snd = 'tutor_snd' + password = 'p' + fact.UserAccountFactory(username=user_snd, password=password) + + login(self.browser, self.live_server_url, user_snd, password) + self._go_to_subscription(stage='validate') + self.write_comments_on_lines([(0, 'I disagree'), (1, 'Full points!')]) + self.browser.find_element_by_id('score-full').click() + self.browser.find_element_by_id('submit-feedback').click() + WebDriverWait(self.browser, 5).until(ec.url_contains('subscription/ended')) + submission_for_code = Submission.objects.get(text=code) + self.assertEqual(self.sub_type.full_score, submission_for_code.feedback.score) + self.assertEqual(3, submission_for_code.feedback.feedback_lines.count()) + + +class TestFeedbackCreationTutor(UntestedParent.TestFeedbackCreationGeneric): + def setUp(self): + super().setUp() + self.username = 'tutor' + self.password = 'p' + self.role = UserAccount.TUTOR + fact.UserAccountFactory( + username=self.username, + password=self.password + ) diff --git a/functional_tests/test_front_pages.py b/functional_tests/test_front_pages.py index adcc09757bf23ad1739c9a02bc30c34e74ad4f15..d7a05296f475aaea0804918f9e2a6901034811d9 100644 --- a/functional_tests/test_front_pages.py +++ b/functional_tests/test_front_pages.py @@ -4,7 +4,7 @@ from selenium.webdriver.support.ui import WebDriverWait from core import models from core.models import UserAccount -from functional_tests.util import (login, create_browser, +from functional_tests.util import (login, create_browser, subscriptions_loaded_cond, extract_hrefs_hashes, reset_browser_after_test) from util import factory_boys as fact @@ -46,15 +46,6 @@ class UntestedParent: self.assertEqual('Statistics', title.text) def test_available_tasks_are_shown(self): - # A function that takes the element corresponding to the tasks - # component and return a function that can be used as a condition for - # WebDriverWait - def subscriptions_loaded_cond(tasks_el): - def loaded(*args): - sub_links = tasks_el.find_elements_by_tag_name('a') - return any((link != '/home' for link in extract_hrefs_hashes(sub_links))) - return loaded - self._login() tasks = self.browser.find_element_by_name('subscription-list') title = tasks.find_element_by_class_name('v-toolbar__title') diff --git a/functional_tests/util.py b/functional_tests/util.py index 912d68a388280088cf181b5dabdf55b2b0f9b0e0..d6ca543483faa91504f23942d6b81f4ff25157be 100644 --- a/functional_tests/util.py +++ b/functional_tests/util.py @@ -15,6 +15,7 @@ def create_browser() -> webdriver.Firefox: options.headless = bool(os.environ.get('HEADLESS_TESTS', False)) browser = webdriver.Firefox(options=options) browser.implicitly_wait(10) + browser.set_window_size(1920, 1080) return browser @@ -44,3 +45,13 @@ def nth(iterable, n, default=None): def extract_hrefs_hashes(web_elements: Sequence[WebElement]): return [nth(el.get_attribute('href').split('#'), 1, '') for el in web_elements if el.get_attribute('href')] + + +# A function that takes the element corresponding to the tasks +# component and return a function that can be used as a condition for +# WebDriverWait +def subscriptions_loaded_cond(tasks_el): + def loaded(*args): + sub_links = tasks_el.find_elements_by_tag_name('a') + return any((link != '/home' for link in extract_hrefs_hashes(sub_links))) + return loaded diff --git a/util/factory_boys.py b/util/factory_boys.py index e89dd70a2d671a350dd5b91774cebdb1e89154d7..12db198aff1b7b178afc4913200e262482a8fe0a 100644 --- a/util/factory_boys.py +++ b/util/factory_boys.py @@ -21,10 +21,11 @@ class ExamTypeFactory(DjangoModelFactory): class SubmissionTypeFactory(DjangoModelFactory): class Meta: model = models.SubmissionType - name = factory.Sequence(lambda n: f"[{n}] Example submission type ") + name = factory.Sequence(lambda n: f"[{n}] Example submission type") full_score = 15 - description = '<h1>This</h1> is a <b>description</b> containing html' - solution = 'This usually contains commented code' + description = factory.Sequence( + lambda n: f'Type {n} \n<h1>This</h1> is a description containing html') + solution = factory.Sequence(lambda n: f'//This is a solution\n#include<stdio.h>\n\nint main() {{\n\tprintf("Hello World\\n");\n\treturn {n};\n}}') # noqa programming_language = models.SubmissionType.C @@ -53,20 +54,14 @@ class TestFactory(DjangoModelFactory): name = 'EmptyTest' label = 'Empty' - annotation = 'This is an annotation' + annotation = factory.Sequence(lambda n: f'Test: {n} This is an annotation') class SubmissionFactory(DjangoModelFactory): class Meta: model = models.Submission - text = """#include<stdio.h> - -int main() { - printf("Hello World\n"); - return 0; -} -""" + text = factory.Sequence(lambda n: f'#include<stdio.h>\n\nint main() {{\n\tprintf("Hello World\\n");\n\treturn {n};\n}}') # noqa type = factory.SubFactory(SubmissionTypeFactory) student = factory.SubFactory(StudentInfoFactory)