Merged in feature/VBV-339-assignment-cypress-test (pull request #79)

Feature/VBV-339 assignment cypress test UNFINISHED

* Create assignment submission test data for cypress test

* Add first assignment trainer test

* Add first cypress test which checks DB entry with all instrumentation
This commit is contained in:
Daniel Egger 2023-05-12 14:33:14 +00:00
parent b313bad031
commit e130d65f37
15 changed files with 275 additions and 49 deletions

View File

@ -92,7 +92,7 @@ const assignmentCompletion = computed(() => assignmentStore.assignmentCompletion
<div class="h-content flex">
<div class="h-full w-1/2 overflow-y-auto bg-white">
<!-- Left part content goes here -->
<div class="p-10">
<div class="p-10" data-cy="student-submission">
<h3>Ergebnisse</h3>
<div class="my-6 flex items-center">
<img

View File

@ -65,7 +65,7 @@ async function startEvaluation() {
</p>
<div>
<button class="btn-primary" @click="startEvaluation()">
<button class="btn-primary" data-cy="start-evaluation" @click="startEvaluation()">
<span
v-if="
props.assignmentCompletion.completion_status === 'evaluation_in_progress'
@ -74,7 +74,9 @@ async function startEvaluation() {
Bewertung fortsetzen
</span>
<span
v-if="props.assignmentCompletion.completion_status === 'evaluation_submitted'"
v-else-if="
props.assignmentCompletion.completion_status === 'evaluation_submitted'
"
>
Bewertung ansehen
</span>

View File

@ -76,7 +76,7 @@ const evaluateAssignmentCompletionDebounced = useDebounceFn(
<template>
<!-- eslint-disable vue/no-v-html -->
<div>
<div data-cy="evaluation-task">
<div class="text-bold mb-4 text-sm">
Beurteilungskriterium {{ taskIndex + 1 }} /
{{ props.assignment.evaluation_tasks.length }}
@ -91,6 +91,7 @@ const evaluateAssignmentCompletionDebounced = useDebounceFn(
v-for="(subTask, index) in task.value.sub_tasks"
:key="index"
class="mb-4 flex items-center last:mb-0"
:data-cy="`subtask-${subTask.points}`"
>
<input
:id="String(index)"
@ -120,6 +121,7 @@ const evaluateAssignmentCompletionDebounced = useDebounceFn(
label="Begründung"
:disabled="!props.allowEdit"
placeholder="Hier muss zwingend eine Begründung erfasst werden."
data-cy="reason-text"
@update:model-value="onUpdateText($event)"
></ItTextarea>
</div>

View File

@ -85,6 +85,7 @@ const assignmentDetail = computed(() =>
:key="csu.user_id + csu.session_title"
:name="`${csu.first_name} ${csu.last_name}`"
:avatar-url="csu.avatar_url"
:data-cy="csu.last_name"
>
<template #center>
<section class="flex w-full justify-between px-8">
@ -130,6 +131,7 @@ const assignmentDetail = computed(() =>
v-if="submissionStatusForUser(csu.user_id)?.progressStatus === 'success'"
:to="`/course/${props.courseSession.course.slug}/cockpit/assignment/${assignment.assignmentId}/${csu.user_id}`"
class="w-full text-right underline"
data-cy="show-results"
>
Ergebnisse anzeigen
</router-link>

View File

@ -8,7 +8,7 @@ module.exports = defineConfig({
viewportWidth: 1280,
viewportHeight: 720,
retries: {
runMode: 1,
runMode: 2,
openMode: 0,
},
reporter: "junit",

8
cypress/consts.js Normal file
View File

@ -0,0 +1,8 @@
// ids for cypress test data
export const ADMIN_USER_ID = -1;
export const TEST_TRAINER1_USER_ID = -11;
export const TEST_STUDENT1_USER_ID = -21;
export const TEST_STUDENT2_USER_ID = -22;
export const TEST_COURSE_SESSION_BERN_ID = -1;
export const TEST_COURSE_SESSION_ZURICH_ID = -2;

View File

@ -1,4 +1,4 @@
import { login } from "./helpers";
import { login } from "../helpers";
describe("student test", () => {
beforeEach(() => {

View File

@ -0,0 +1,87 @@
import { TEST_STUDENT1_USER_ID } from "../../consts";
import { login } from "../helpers";
describe("trainer test", () => {
beforeEach(() => {
cy.manageCommand("cypress_reset --create-completion");
login("test-trainer1@example.com", "test");
});
it("can open cockpit assignment page and open user assignment", () => {
cy.visit("/course/test-lehrgang/cockpit/assignment");
cy.get('[data-cy="Student1"]').should("contain", "Ergebnisse abgegeben");
cy.get('[data-cy="Student1"]').find('[data-cy="show-results"]').click();
cy.get('[data-cy="student-submission"]').should("contain", "Ergebnisse");
cy.get('[data-cy="student-submission"]').should("contain", "Student1");
});
it("can start evaluation and store evaluation results", () => {
cy.visit("/course/test-lehrgang/cockpit/assignment");
cy.get('[data-cy="Student1"]').find('[data-cy="show-results"]').click();
cy.get('[data-cy="start-evaluation"]').click();
cy.get('[data-cy="evaluation-task"]').should(
"contain",
"Beurteilungskriterium 1 / 5"
);
// without text input the button should be disabled
cy.get('[data-cy="next-step"]').should("be.disabled");
// with text you can continue
cy.get('[data-cy="subtask-4"]').click();
cy.get('[data-cy="reason-text"]').type("Gut gemacht!");
// wait for debounce
cy.wait(500);
cy.get('[data-cy="next-step"]').click();
cy.get('[data-cy="evaluation-task"]').should(
"contain",
"Beurteilungskriterium 2 / 5"
);
cy.get('[data-cy="subtask-2"]').click();
cy.get('[data-cy="reason-text"]').type("Nicht so gut");
cy.wait(500);
// revisit step 1 will show stored data
cy.get('[data-cy="previous-step"]').click();
cy.get('[data-cy="reason-text"]')
.find("textarea")
.should("have.value", "Gut gemacht!");
// even after reload
cy.reload();
cy.get('[data-cy="reason-text"]')
.find("textarea")
.should("have.value", "Gut gemacht!");
// it can access step directly via url
cy.url().then((url) => {
const step2Url = url.replace("step=1", "step=2");
console.log(step2Url);
cy.visit(step2Url);
});
cy.get('[data-cy="reason-text"]')
.find("textarea")
.should("have.value", "Nicht so gut");
// load AssignmentCompletion from DB and check
cy.loadAssignmentCompletion(
"assignment_user_id",
TEST_STUDENT1_USER_ID
).then((ac) => {
expect(ac.completion_status).to.equal("evaluation_in_progress");
expect(JSON.stringify(ac.completion_data)).to.include("Nicht so gut");
expect(Cypress._.values(ac.completion_data)).to.deep.include({
expert_data: { points: 2, text: "Nicht so gut" },
});
expect(Cypress._.values(ac.completion_data)).to.deep.include({
expert_data: { points: 4, text: "Gut gemacht!" },
});
});
});
});

View File

@ -52,55 +52,77 @@
const _ = Cypress._;
Cypress.Commands.add('manageCommand', (command, preCommand = '') => {
Cypress.Commands.add("manageCommand", (command, preCommand = "") => {
const execCommand = `${preCommand} python server/manage.py ${command} --settings=config.settings.test_cypress`;
console.log(execCommand);
return cy.exec(execCommand, { failOnNonZeroExit: false }).then(result => {
if(result.code) {
throw new Error(`Execution of "${command}" failed
return cy
.exec(
// hack to add my asdf python instance to the path
// so I can run the test directly from within IntelliJ
`PATH=/Users/daniel/workspace/vbv_lernwelt/.direnv/python-3.10.6/bin:$PATH && ${execCommand}`,
{
failOnNonZeroExit: false,
}
)
.then((result) => {
if (result.code) {
throw new Error(`Execution of "${command}" failed
Exit code: ${result.code}
Stdout:\n${result.stdout}
Stderr:\n${result.stderr}`);
}
});
}
});
});
Cypress.Commands.add('manageShellCommand', (command) => {
Cypress.Commands.add("manageShellCommand", (command) => {
return cy.manageCommand(`shell -c '${command}'`);
});
function loadObjectJson(key, value, djangoModelPath, serializerModelPath) {
const djangoModel = _.last(djangoModelPath.split('.'));
const djangoModelImportPath = _.initial(djangoModelPath.split('.')).join('.');
const serializerModel = _.last(serializerModelPath.split('.'));
const serializerModelImportPath = _.initial(serializerModelPath.split('.')).join('.');
const djangoModel = _.last(djangoModelPath.split("."));
const djangoModelImportPath = _.initial(djangoModelPath.split(".")).join(".");
const serializerModel = _.last(serializerModelPath.split("."));
const serializerModelImportPath = _.initial(
serializerModelPath.split(".")
).join(".");
let filterPart = `${key}=${value}`;
if(_.isArray(key)) {
filterPart = _.zip(key, value).map(([k, v]) => {
return `${k}=${v}`;
}).join(',');
if (_.isArray(key)) {
filterPart = _.zip(key, value)
.map(([k, v]) => {
return `${k}=${v}`;
})
.join(",");
}
const command = `from ${djangoModelImportPath} import ${djangoModel};
from ${serializerModelImportPath} import ${serializerModel};
from myservice.apps.core.serializers import create_json_from_objects;
object = ${djangoModel}.objects.filter(${filterPart}).first();
print(create_json_from_objects(object, ${serializerModel}, many=False));
exit()`.replace(/(?:\r\n|\r|\n)/g, '');
return cy.manageShellCommand(command).then(result => {
from ${serializerModelImportPath} import ${serializerModel};
from vbv_lernwelt.core.serializers import create_json_from_objects;
object = ${djangoModel}.objects.filter(${filterPart}).first();
print(create_json_from_objects(object, ${serializerModel}, many=False));
exit();
`.replace(/(?:\r\n|\r|\n)/g, "");
return cy.manageShellCommand(command).then((result) => {
const objectJson = JSON.parse(result.stdout);
console.log(objectJson);
return objectJson;
});
}
Cypress.Commands.add('makeSelfEvaluation', (answers) => {
Cypress.Commands.add("loadAssignmentCompletion", (key, value) => {
return loadObjectJson(
key,
value,
"vbv_lernwelt.assignment.models.AssignmentCompletion",
"vbv_lernwelt.assignment.serializers.AssignmentCompletionSerializer"
);
});
Cypress.Commands.add("makeSelfEvaluation", (answers) => {
for (let i = 0; i < answers.length; i++) {
const answer = answers[i];
if (answer) {
cy.get('[data-cy="success"]').click();
cy.get('[data-cy="success"]').click();
} else {
cy.get('[data-cy="fail"]').click();
}
@ -112,7 +134,6 @@ Cypress.Commands.add('makeSelfEvaluation', (answers) => {
}
});
// Cypress.Commands.add('loadApiClientRequestResponseLog', (key, value) => {
// return loadObjectJson(
// key,

View File

@ -4,7 +4,8 @@ from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth.decorators import user_passes_test
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import include, path, re_path
from django.urls import include, path, re_path, register_converter
from django.urls.converters import IntConverter
from django.views import defaults as default_views
from grapple import urls as grapple_urls
from ratelimit.exceptions import Ratelimited
@ -50,6 +51,20 @@ from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls
class SignedIntConverter(IntConverter):
regex = r"-?\d+"
def to_python(self, value):
return int(value)
def to_url(self, value):
return str(value)
# Register the converter
register_converter(SignedIntConverter, "signed_int")
def raise_example_error(request):
"""
raise error to check if it gets logged
@ -88,15 +103,17 @@ urlpatterns = [
# course
path(r"api/course/sessions/", get_course_sessions, name="get_course_sessions"),
path(r"api/course/sessions/<course_session_id>/users/", get_course_session_users,
path(r"api/course/sessions/<signed_int:course_session_id>/users/",
get_course_session_users,
name="get_course_session_users"),
path(r"api/course/page/<slug_or_id>/", course_page_api_view,
name="course_page_api_view"),
path(r"api/course/completion/mark/", mark_course_completion_view,
name="mark_course_completion"),
path(r"api/course/completion/<course_session_id>/", request_course_completion,
path(r"api/course/completion/<signed_int:course_session_id>/",
request_course_completion,
name="request_course_completion"),
path(r"api/course/completion/<course_session_id>/<int:user_id>/",
path(r"api/course/completion/<signed_int:course_session_id>/<signed_int:user_id>/",
request_course_completion_for_user,
name="request_course_completion_for_user"),
@ -105,15 +122,17 @@ urlpatterns = [
name="upsert_user_assignment_completion"),
path(r"api/assignment/evaluate/", evaluate_assignment_completion,
name="evaluate_assignment_completion"),
path(r"api/assignment/<int:assignment_id>/<int:course_session_id>/",
path(r"api/assignment/<signed_int:assignment_id>/<signed_int:course_session_id>/",
request_assignment_completion,
name="request_assignment_completion"),
path(r"api/assignment/<int:assignment_id>/<int:course_session_id>/status/",
request_assignment_completion_status,
name="request_assignment_completion_status"),
path(r"api/assignment/<int:assignment_id>/<int:course_session_id>/<int:user_id>/",
request_assignment_completion_for_user,
name="request_assignment_completion_for_user"),
path(
r"api/assignment/<signed_int:assignment_id>/<signed_int:course_session_id>/status/",
request_assignment_completion_status,
name="request_assignment_completion_status"),
path(
r"api/assignment/<signed_int:assignment_id>/<signed_int:course_session_id>/<signed_int:user_id>/",
request_assignment_completion_for_user,
name="request_assignment_completion_for_user"),
# documents
path(r'api/core/document/start/', document_upload_start,

View File

@ -3,3 +3,12 @@ DEFAULT_RICH_TEXT_FEATURES = [
"bold",
"italic",
]
# ids for cypress test data
ADMIN_USER_ID = -1
TEST_TRAINER1_USER_ID = -11
TEST_STUDENT1_USER_ID = -21
TEST_STUDENT2_USER_ID = -22
TEST_COURSE_SESSION_BERN_ID = -1
TEST_COURSE_SESSION_ZURICH_ID = -2

View File

@ -1,6 +1,12 @@
from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import Group
from vbv_lernwelt.core.constants import (
ADMIN_USER_ID,
TEST_STUDENT1_USER_ID,
TEST_STUDENT2_USER_ID,
TEST_TRAINER1_USER_ID,
)
from vbv_lernwelt.core.models import User
default_users = [
@ -70,12 +76,14 @@ def create_default_users(user_model=User, group_model=Group, default_password=No
avatar_url="",
password=default_password,
language="de",
id=None,
):
student_user, created = _get_or_create_user(
user_model=user_model,
username=email,
password=password,
language=language,
id=id,
)
student_user.first_name = first_name
student_user.last_name = last_name
@ -83,9 +91,9 @@ def create_default_users(user_model=User, group_model=Group, default_password=No
student_user.groups.add(student_group)
student_user.save()
def _create_admin_user(email, first_name, last_name, avatar_url=""):
def _create_admin_user(email, first_name, last_name, avatar_url="", id=None):
admin_user, created = _get_or_create_user(
user_model=user_model, username=email, password=default_password
user_model=user_model, username=email, password=default_password, id=id
)
admin_user.is_superuser = True
admin_user.is_staff = True
@ -107,6 +115,7 @@ def create_default_users(user_model=User, group_model=Group, default_password=No
first_name="Peter",
last_name="Adminson",
avatar_url="/static/avatars/avatar_iterativ.png",
id=ADMIN_USER_ID,
)
for user_data in default_users:
@ -232,19 +241,25 @@ def create_default_users(user_model=User, group_model=Group, default_password=No
# users for cypress tests
_create_student_user(
id=TEST_TRAINER1_USER_ID,
email="test-trainer1@example.com",
first_name="Test",
last_name="Trainer1",
avatar_url="/static/avatars/uk1.patrizia.huggel.jpg",
)
_create_student_user(
id=TEST_STUDENT1_USER_ID,
email="test-student1@example.com",
first_name="Test",
last_name="Student1",
avatar_url="/static/avatars/uk1.michael.meier.jpg",
)
_create_student_user(
id=TEST_STUDENT2_USER_ID,
email="test-student2@example.com",
first_name="Test",
last_name="Student2",
avatar_url="/static/avatars/uk1.lina.egger.jpg",
)
@ -252,6 +267,7 @@ def _get_or_create_user(user_model, *args, **kwargs):
username = kwargs.get("username", None)
password = kwargs.get("password", None)
language = kwargs.get("language", "de")
id = kwargs.get("id", None)
created = False
user = user_model.objects.filter(username=username).first()
@ -262,6 +278,7 @@ def _get_or_create_user(user_model, *args, **kwargs):
password=make_password(password),
email=username,
language=language,
id=id,
)
created = True
return user, created

View File

@ -1,15 +1,37 @@
import djclick as click
from vbv_lernwelt.assignment.models import AssignmentCompletion
from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletion
from vbv_lernwelt.core.constants import (
TEST_COURSE_SESSION_BERN_ID,
TEST_STUDENT1_USER_ID,
)
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseCompletion
from vbv_lernwelt.course.creators.test_course import (
create_test_assignment_submitted_data,
)
from vbv_lernwelt.course.models import CourseCompletion, CourseSession
from vbv_lernwelt.notify.models import Notification
@click.command()
def command():
@click.option(
"--create-completion/--no-create-completion",
default=False,
help="will create completion data for some users",
)
def command(create_completion):
print("cypress reset data")
CourseCompletion.objects.all().delete()
Notification.objects.all().delete()
AssignmentCompletion.objects.all().delete()
User.objects.all().update(language="de")
if create_completion:
print("create completion data for test course")
create_test_assignment_submitted_data(
assignment=Assignment.objects.get(
slug="test-lehrgang-assignment-überprüfen-einer-motorfahrzeugs-versicherungspolice"
),
course_session=CourseSession.objects.get(id=TEST_COURSE_SESSION_BERN_ID),
user=User.objects.get(id=TEST_STUDENT1_USER_ID),
)

View File

@ -1,9 +1,15 @@
from rest_framework import serializers
from rest_framework.renderers import JSONRenderer
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseSessionUser
def create_json_from_objects(objects, serializer_class, many=True) -> str:
serializer = serializer_class(objects, many=many)
return JSONRenderer().render(serializer.data).decode("utf-8")
class UserSerializer(serializers.ModelSerializer):
course_session_experts = serializers.SerializerMethodField()

View File

@ -7,12 +7,17 @@ from wagtail.models import Site
from vbv_lernwelt.assignment.creators.create_assignments import create_test_assignment
from vbv_lernwelt.assignment.models import Assignment
from vbv_lernwelt.assignment.services import update_assignment_completion
from vbv_lernwelt.competence.factories import (
CompetencePageFactory,
CompetenceProfilePageFactory,
PerformanceCriteriaFactory,
)
from vbv_lernwelt.competence.models import CompetencePage
from vbv_lernwelt.core.constants import (
TEST_COURSE_SESSION_BERN_ID,
TEST_COURSE_SESSION_ZURICH_ID,
)
from vbv_lernwelt.core.models import User
from vbv_lernwelt.core.tests.helpers import create_locales_for_wagtail
from vbv_lernwelt.course.consts import COURSE_TEST_ID
@ -65,11 +70,13 @@ def create_test_course(include_uk=True, include_vv=True, with_sessions=False):
# course sessions
cs_bern = CourseSession.objects.create(
course_id=COURSE_TEST_ID,
title="Bern 2022 a",
title="Test Bern 2022 a",
id=TEST_COURSE_SESSION_BERN_ID,
)
cs_zurich = CourseSession.objects.create(
course_id=COURSE_TEST_ID,
title="Zürich 2022 a",
title="Test Zürich 2022 a",
id=TEST_COURSE_SESSION_ZURICH_ID,
)
trainer1 = User.objects.get(email="test-trainer1@example.com")
@ -100,6 +107,30 @@ def create_test_course(include_uk=True, include_vv=True, with_sessions=False):
return course
def create_test_assignment_submitted_data(assignment, course_session, user):
if assignment and course_session and user:
subtasks = assignment.filter_user_subtasks(subtask_types=["user_text_input"])
for index, subtask in enumerate(subtasks):
user_text = f"Lorem ipsum dolor sit amet... {index}"
update_assignment_completion(
assignment_user=user,
assignment=assignment,
course_session=course_session,
completion_data={
subtask["id"]: {
"user_data": {"text": user_text},
}
},
)
update_assignment_completion(
assignment_user=user,
assignment=assignment,
course_session=course_session,
completion_status="submitted",
)
def create_test_course_with_categories(apps=None, schema_editor=None):
if apps is not None:
Course = apps.get_model("course", "Course")