diff --git a/backend/package.json b/backend/package.json index aa1b169e..275bad7d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,12 +16,11 @@ "test:unit": "vitest --run" }, "dependencies": { - "@dwengo-1/common": "^0.1.1", - "@mikro-orm/core": "6.4.9", - "@mikro-orm/knex": "6.4.9", - "@mikro-orm/postgresql": "6.4.9", - "@mikro-orm/reflection": "6.4.9", - "@mikro-orm/sqlite": "6.4.9", + "@mikro-orm/core": "6.4.12", + "@mikro-orm/knex": "6.4.12", + "@mikro-orm/postgresql": "6.4.12", + "@mikro-orm/reflection": "6.4.12", + "@mikro-orm/sqlite": "6.4.12", "axios": "^1.8.2", "cors": "^2.8.5", "cross": "^1.0.0", @@ -44,7 +43,7 @@ "winston-loki": "^6.1.3" }, "devDependencies": { - "@mikro-orm/cli": "6.4.9", + "@mikro-orm/cli": "6.4.12", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/js-yaml": "^4.0.9", diff --git a/backend/src/controllers/groups.ts b/backend/src/controllers/groups.ts index ec177dcc..53bc96ec 100644 --- a/backend/src/controllers/groups.ts +++ b/backend/src/controllers/groups.ts @@ -69,8 +69,8 @@ export async function getAllGroupsHandler(req: Request, res: Response): Promise< export async function createGroupHandler(req: Request, res: Response): Promise { const classid = req.params.classid; const assignmentId = Number(req.params.assignmentid); - - requireFields({ classid, assignmentId }); + const members = req.body.members; + requireFields({ classid, assignmentId, members }); if (isNaN(assignmentId)) { throw new BadRequestException('Assignment id must be a number'); diff --git a/backend/src/controllers/learning-paths.ts b/backend/src/controllers/learning-paths.ts index 0097d568..1bd3f2b1 100644 --- a/backend/src/controllers/learning-paths.ts +++ b/backend/src/controllers/learning-paths.ts @@ -3,13 +3,10 @@ import { themes } from '../data/themes.js'; import { FALLBACK_LANG } from '../config.js'; import learningPathService from '../services/learning-paths/learning-path-service.js'; import { Language } from '@dwengo-1/common/util/language'; -import { - PersonalizationTarget, - personalizedForGroup, - personalizedForStudent, -} from '../services/learning-paths/learning-path-personalization-util.js'; import { BadRequestException } from '../exceptions/bad-request-exception.js'; import { NotFoundException } from '../exceptions/not-found-exception.js'; +import { Group } from '../entities/assignments/group.entity.js'; +import { getAssignmentRepository, getGroupRepository } from '../data/repositories.js'; /** * Fetch learning paths based on query parameters. @@ -20,20 +17,20 @@ export async function getLearningPaths(req: Request, res: Response): Promise theme.hruids); } - const learningPaths = await learningPathService.fetchLearningPaths( - hruidList, - language as Language, - `HRUIDs: ${hruidList.join(', ')}`, - personalizationTarget - ); + const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language as Language, `HRUIDs: ${hruidList.join(', ')}`, forGroup); res.json(learningPaths.data); } diff --git a/backend/src/controllers/submissions.ts b/backend/src/controllers/submissions.ts index 92cf84c1..a117d7bf 100644 --- a/backend/src/controllers/submissions.ts +++ b/backend/src/controllers/submissions.ts @@ -17,15 +17,18 @@ export async function getSubmissionsHandler(req: Request, res: Response): Promis const lang = languageMap[req.query.language as string] || Language.Dutch; const version = parseInt(req.query.version as string) ?? 1; - const submissions = await getSubmissionsForLearningObjectAndAssignment( + const forGroup = req.query.forGroup as string | undefined; + + const submissions: SubmissionDTO[] = await getSubmissionsForLearningObjectAndAssignment( loHruid, lang, version, req.query.classId as string, - parseInt(req.query.assignmentId as string) + parseInt(req.query.assignmentId as string), + forGroup ? parseInt(forGroup) : undefined ); - res.json(submissions); + res.json({ submissions }); } export async function getSubmissionHandler(req: Request, res: Response): Promise { diff --git a/backend/src/data/assignments/assignment-repository.ts b/backend/src/data/assignments/assignment-repository.ts index db12a74f..1c8bb504 100644 --- a/backend/src/data/assignments/assignment-repository.ts +++ b/backend/src/data/assignments/assignment-repository.ts @@ -4,7 +4,7 @@ import { Class } from '../../entities/classes/class.entity.js'; export class AssignmentRepository extends DwengoEntityRepository { public async findByClassAndId(within: Class, id: number): Promise { - return this.findOne({ within: within, id: id }); + return this.findOne({ within: within, id: id }, { populate: ['groups', 'groups.members'] }); } public async findByClassIdAndAssignmentId(withinClass: string, id: number): Promise { return this.findOne({ within: { classId: withinClass }, id: id }); @@ -23,7 +23,7 @@ export class AssignmentRepository extends DwengoEntityRepository { }); } public async findAllAssignmentsInClass(within: Class): Promise { - return this.findAll({ where: { within: within } }); + return this.findAll({ where: { within: within }, populate: ['groups', 'groups.members'] }); } public async deleteByClassAndId(within: Class, id: number): Promise { return this.deleteWhere({ within: within, id: id }); diff --git a/backend/src/data/assignments/submission-repository.ts b/backend/src/data/assignments/submission-repository.ts index 93089e3b..e9889bcf 100644 --- a/backend/src/data/assignments/submission-repository.ts +++ b/backend/src/data/assignments/submission-repository.ts @@ -61,32 +61,30 @@ export class SubmissionRepository extends DwengoEntityRepository { /** * Looks up all submissions for the given learning object which were submitted as part of the given assignment. - * When forStudentUsername is set, only the submissions of the given user's group are shown. */ - public async findAllSubmissionsForLearningObjectAndAssignment( - loId: LearningObjectIdentifier, - assignment: Assignment, - forStudentUsername?: string - ): Promise { - const onBehalfOf = forStudentUsername - ? { - assignment, - members: { - $some: { - username: forStudentUsername, - }, - }, - } - : { - assignment, - }; - + public async findAllSubmissionsForLearningObjectAndAssignment(loId: LearningObjectIdentifier, assignment: Assignment): Promise { return this.findAll({ where: { learningObjectHruid: loId.hruid, learningObjectLanguage: loId.language, learningObjectVersion: loId.version, - onBehalfOf, + onBehalfOf: { + assignment, + }, + }, + }); + } + + /** + * Looks up all submissions for the given learning object which were submitted by the given group + */ + public async findAllSubmissionsForLearningObjectAndGroup(loId: LearningObjectIdentifier, group: Group): Promise { + return this.findAll({ + where: { + learningObjectHruid: loId.hruid, + learningObjectLanguage: loId.language, + learningObjectVersion: loId.version, + onBehalfOf: group, }, }); } diff --git a/backend/src/data/content/learning-path-repository.ts b/backend/src/data/content/learning-path-repository.ts index 87035f21..67f08a03 100644 --- a/backend/src/data/content/learning-path-repository.ts +++ b/backend/src/data/content/learning-path-repository.ts @@ -1,6 +1,10 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { LearningPath } from '../../entities/content/learning-path.entity.js'; import { Language } from '@dwengo-1/common/util/language'; +import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; +import { RequiredEntityData } from '@mikro-orm/core'; +import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; +import { EntityAlreadyExistsException } from '../../exceptions/entity-already-exists-exception.js'; export class LearningPathRepository extends DwengoEntityRepository { public async findByHruidAndLanguage(hruid: string, language: Language): Promise { @@ -23,4 +27,27 @@ export class LearningPathRepository extends DwengoEntityRepository populate: ['nodes', 'nodes.transitions'], }); } + + public createNode(nodeData: RequiredEntityData): LearningPathNode { + return this.em.create(LearningPathNode, nodeData); + } + + public createTransition(transitionData: RequiredEntityData): LearningPathTransition { + return this.em.create(LearningPathTransition, transitionData); + } + + public async saveLearningPathNodesAndTransitions( + path: LearningPath, + nodes: LearningPathNode[], + transitions: LearningPathTransition[], + options?: { preventOverwrite?: boolean } + ): Promise { + if (options?.preventOverwrite && (await this.findOne(path))) { + throw new EntityAlreadyExistsException('A learning path with this hruid/language combination already exists.'); + } + const em = this.getEntityManager(); + await em.persistAndFlush(path); + await Promise.all(nodes.map(async (it) => em.persistAndFlush(it))); + await Promise.all(transitions.map(async (it) => em.persistAndFlush(it))); + } } diff --git a/backend/src/entities/assignments/assignment.entity.ts b/backend/src/entities/assignments/assignment.entity.ts index e3f75489..ed8745f6 100644 --- a/backend/src/entities/assignments/assignment.entity.ts +++ b/backend/src/entities/assignments/assignment.entity.ts @@ -14,7 +14,7 @@ export class Assignment { }) within!: Class; - @PrimaryKey({ type: 'number', autoincrement: true }) + @PrimaryKey({ type: 'integer', autoincrement: true }) id?: number; @Property({ type: 'string' }) @@ -35,5 +35,5 @@ export class Assignment { entity: () => Group, mappedBy: 'assignment', }) - groups!: Collection; + groups: Collection = new Collection(this); } diff --git a/backend/src/entities/assignments/group.entity.ts b/backend/src/entities/assignments/group.entity.ts index 55770b7f..62d5fee9 100644 --- a/backend/src/entities/assignments/group.entity.ts +++ b/backend/src/entities/assignments/group.entity.ts @@ -7,17 +7,23 @@ import { GroupRepository } from '../../data/assignments/group-repository.js'; repository: () => GroupRepository, }) export class Group { + /* + WARNING: Don't move the definition of groupNumber! If it does not come before the definition of assignment, + creating groups fails because of a MikroORM bug! + */ + @PrimaryKey({ type: 'integer', autoincrement: true }) + groupNumber?: number; + @ManyToOne({ entity: () => Assignment, primary: true, }) assignment!: Assignment; - @PrimaryKey({ type: 'integer', autoincrement: true }) - groupNumber?: number; - @ManyToMany({ entity: () => Student, + owner: true, + inversedBy: 'groups', }) - members!: Collection; + members: Collection = new Collection(this); } diff --git a/backend/src/entities/assignments/submission.entity.ts b/backend/src/entities/assignments/submission.entity.ts index 82d49a40..4018c3af 100644 --- a/backend/src/entities/assignments/submission.entity.ts +++ b/backend/src/entities/assignments/submission.entity.ts @@ -6,6 +6,9 @@ import { Language } from '@dwengo-1/common/util/language'; @Entity({ repository: () => SubmissionRepository }) export class Submission { + @PrimaryKey({ type: 'integer', autoincrement: true }) + submissionNumber?: number; + @PrimaryKey({ type: 'string' }) learningObjectHruid!: string; @@ -15,12 +18,9 @@ export class Submission { }) learningObjectLanguage!: Language; - @PrimaryKey({ type: 'numeric' }) + @PrimaryKey({ type: 'numeric', autoincrement: false }) learningObjectVersion = 1; - @PrimaryKey({ type: 'integer', autoincrement: true }) - submissionNumber?: number; - @ManyToOne({ entity: () => Group, }) diff --git a/backend/src/entities/classes/class.entity.ts b/backend/src/entities/classes/class.entity.ts index 63315304..b2c59ade 100644 --- a/backend/src/entities/classes/class.entity.ts +++ b/backend/src/entities/classes/class.entity.ts @@ -14,9 +14,9 @@ export class Class { @Property({ type: 'string' }) displayName!: string; - @ManyToMany(() => Teacher) + @ManyToMany({ entity: () => Teacher, owner: true, inversedBy: 'classes' }) teachers!: Collection; - @ManyToMany(() => Student) + @ManyToMany({ entity: () => Student, owner: true, inversedBy: 'classes' }) students!: Collection; } diff --git a/backend/src/entities/content/learning-object.entity.ts b/backend/src/entities/content/learning-object.entity.ts index ff858fe6..e0ae09d6 100644 --- a/backend/src/entities/content/learning-object.entity.ts +++ b/backend/src/entities/content/learning-object.entity.ts @@ -1,4 +1,4 @@ -import { Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; +import { ArrayType, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; import { Attachment } from './attachment.entity.js'; import { Teacher } from '../users/teacher.entity.js'; import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js'; @@ -42,7 +42,7 @@ export class LearningObject { @Property({ type: 'array' }) keywords: string[] = []; - @Property({ type: 'array', nullable: true }) + @Property({ type: new ArrayType((i) => Number(i)), nullable: true }) targetAges?: number[] = []; @Property({ type: 'bool' }) diff --git a/backend/src/entities/content/learning-path-node.entity.ts b/backend/src/entities/content/learning-path-node.entity.ts index 3016c367..fd870dcd 100644 --- a/backend/src/entities/content/learning-path-node.entity.ts +++ b/backend/src/entities/content/learning-path-node.entity.ts @@ -1,16 +1,16 @@ -import { Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core'; +import { Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core'; import { LearningPath } from './learning-path.entity.js'; import { LearningPathTransition } from './learning-path-transition.entity.js'; import { Language } from '@dwengo-1/common/util/language'; @Entity() export class LearningPathNode { + @PrimaryKey({ type: 'integer', autoincrement: true }) + nodeNumber?: number; + @ManyToOne({ entity: () => LearningPath, primary: true }) learningPath!: Rel; - @PrimaryKey({ type: 'integer', autoincrement: true }) - nodeNumber!: number; - @Property({ type: 'string' }) learningObjectHruid!: string; @@ -27,7 +27,7 @@ export class LearningPathNode { startNode!: boolean; @OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node' }) - transitions: LearningPathTransition[] = []; + transitions!: Collection; @Property({ length: 3 }) createdAt: Date = new Date(); diff --git a/backend/src/entities/content/learning-path-transition.entity.ts b/backend/src/entities/content/learning-path-transition.entity.ts index 7d6601a3..0f466fdd 100644 --- a/backend/src/entities/content/learning-path-transition.entity.ts +++ b/backend/src/entities/content/learning-path-transition.entity.ts @@ -3,12 +3,12 @@ import { LearningPathNode } from './learning-path-node.entity.js'; @Entity() export class LearningPathTransition { - @ManyToOne({ entity: () => LearningPathNode, primary: true }) - node!: Rel; - @PrimaryKey({ type: 'numeric' }) transitionNumber!: number; + @ManyToOne({ entity: () => LearningPathNode, primary: true }) + node!: Rel; + @Property({ type: 'string' }) condition!: string; diff --git a/backend/src/entities/content/learning-path.entity.ts b/backend/src/entities/content/learning-path.entity.ts index 203af86d..1b96d8ea 100644 --- a/backend/src/entities/content/learning-path.entity.ts +++ b/backend/src/entities/content/learning-path.entity.ts @@ -1,4 +1,4 @@ -import { Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; +import { Collection, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; import { Teacher } from '../users/teacher.entity.js'; import { LearningPathRepository } from '../../data/content/learning-path-repository.js'; import { LearningPathNode } from './learning-path-node.entity.js'; @@ -25,5 +25,5 @@ export class LearningPath { image: Buffer | null = null; @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' }) - nodes: LearningPathNode[] = []; + nodes: Collection = new Collection(this); } diff --git a/backend/src/entities/users/student.entity.ts b/backend/src/entities/users/student.entity.ts index 58e82765..9f294d3c 100644 --- a/backend/src/entities/users/student.entity.ts +++ b/backend/src/entities/users/student.entity.ts @@ -8,9 +8,9 @@ import { StudentRepository } from '../../data/users/student-repository.js'; repository: () => StudentRepository, }) export class Student extends User { - @ManyToMany(() => Class) + @ManyToMany({ entity: () => Class, mappedBy: 'students' }) classes!: Collection; - @ManyToMany(() => Group) - groups!: Collection; + @ManyToMany({ entity: () => Group, mappedBy: 'members' }) + groups: Collection = new Collection(this); } diff --git a/backend/src/entities/users/teacher.entity.ts b/backend/src/entities/users/teacher.entity.ts index d53ca603..8fbe5e51 100644 --- a/backend/src/entities/users/teacher.entity.ts +++ b/backend/src/entities/users/teacher.entity.ts @@ -5,6 +5,6 @@ import { TeacherRepository } from '../../data/users/teacher-repository.js'; @Entity({ repository: () => TeacherRepository }) export class Teacher extends User { - @ManyToMany(() => Class) + @ManyToMany({ entity: () => Class, mappedBy: 'teachers' }) classes!: Collection; } diff --git a/backend/src/exceptions/server-error-exception.ts b/backend/src/exceptions/server-error-exception.ts new file mode 100644 index 00000000..49251bdf --- /dev/null +++ b/backend/src/exceptions/server-error-exception.ts @@ -0,0 +1,12 @@ +import { ExceptionWithHttpState } from './exception-with-http-state.js'; + +/** + * Exception for HTTP 500 Internal Server Error + */ +export class ServerErrorException extends ExceptionWithHttpState { + status = 500; + + constructor(message = 'Internal server error, something went wrong') { + super(500, message); + } +} diff --git a/backend/src/interfaces/assignment.ts b/backend/src/interfaces/assignment.ts index 7abb3d3c..7c5a0909 100644 --- a/backend/src/interfaces/assignment.ts +++ b/backend/src/interfaces/assignment.ts @@ -1,18 +1,14 @@ import { languageMap } from '@dwengo-1/common/util/language'; -import { FALLBACK_LANG } from '../config.js'; import { Assignment } from '../entities/assignments/assignment.entity.js'; import { Class } from '../entities/classes/class.entity.js'; -import { getLogger } from '../logging/initalize.js'; -import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; +import { AssignmentDTO, AssignmentDTOId } from '@dwengo-1/common/interfaces/assignment'; +import { mapToGroupDTO } from './group.js'; +import { getAssignmentRepository } from '../data/repositories.js'; -export function mapToAssignmentDTOId(assignment: Assignment): AssignmentDTO { +export function mapToAssignmentDTOId(assignment: Assignment): AssignmentDTOId { return { id: assignment.id!, within: assignment.within.classId!, - title: assignment.title, - description: assignment.description, - learningPath: assignment.learningPathHruid, - language: assignment.learningPathLanguage, }; } @@ -24,19 +20,17 @@ export function mapToAssignmentDTO(assignment: Assignment): AssignmentDTO { description: assignment.description, learningPath: assignment.learningPathHruid, language: assignment.learningPathLanguage, - // Groups: assignment.groups.map(mapToGroupDTO), + groups: assignment.groups.map((group) => mapToGroupDTO(group, assignment.within)), }; } export function mapToAssignment(assignmentData: AssignmentDTO, cls: Class): Assignment { - const assignment = new Assignment(); - assignment.title = assignmentData.title; - assignment.description = assignmentData.description; - assignment.learningPathHruid = assignmentData.learningPath; - assignment.learningPathLanguage = languageMap[assignmentData.language] || FALLBACK_LANG; - assignment.within = cls; - - getLogger().debug(assignment); - - return assignment; + return getAssignmentRepository().create({ + within: cls, + title: assignmentData.title, + description: assignmentData.description, + learningPathHruid: assignmentData.learningPath, + learningPathLanguage: languageMap[assignmentData.language], + groups: [], + }); } diff --git a/backend/src/interfaces/group.ts b/backend/src/interfaces/group.ts index 792086d4..3cebb9eb 100644 --- a/backend/src/interfaces/group.ts +++ b/backend/src/interfaces/group.ts @@ -1,14 +1,12 @@ import { Group } from '../entities/assignments/group.entity.js'; import { mapToAssignment } from './assignment.js'; import { mapToStudent } from './student.js'; -import { mapToAssignmentDTO } from './assignment.js'; import { mapToStudentDTO } from './student.js'; -import { GroupDTO } from '@dwengo-1/common/interfaces/group'; +import { GroupDTO, GroupDTOId } from '@dwengo-1/common/interfaces/group'; import { getGroupRepository } from '../data/repositories.js'; import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; import { Class } from '../entities/classes/class.entity.js'; import { StudentDTO } from '@dwengo-1/common/interfaces/student'; -import { mapToClassDTO } from './class.js'; export function mapToGroup(groupDto: GroupDTO, clazz: Class): Group { const assignmentDto = groupDto.assignment as AssignmentDTO; @@ -20,18 +18,18 @@ export function mapToGroup(groupDto: GroupDTO, clazz: Class): Group { }); } -export function mapToGroupDTO(group: Group): GroupDTO { +export function mapToGroupDTO(group: Group, cls: Class): GroupDTO { return { - class: mapToClassDTO(group.assignment.within), - assignment: mapToAssignmentDTO(group.assignment), + class: cls.classId!, + assignment: group.assignment.id!, groupNumber: group.groupNumber!, members: group.members.map(mapToStudentDTO), }; } -export function mapToGroupDTOId(group: Group): GroupDTO { +export function mapToGroupDTOId(group: Group, cls: Class): GroupDTOId { return { - class: group.assignment.within.classId!, + class: cls.classId!, assignment: group.assignment.id!, groupNumber: group.groupNumber!, }; diff --git a/backend/src/interfaces/question.ts b/backend/src/interfaces/question.ts index 50a61301..98e6f33c 100644 --- a/backend/src/interfaces/question.ts +++ b/backend/src/interfaces/question.ts @@ -31,7 +31,7 @@ export function mapToQuestionDTO(question: Question): QuestionDTO { learningObjectIdentifier, sequenceNumber: question.sequenceNumber!, author: mapToStudentDTO(question.author), - inGroup: mapToGroupDTOId(question.inGroup), + inGroup: mapToGroupDTOId(question.inGroup, question.inGroup.assignment?.within), timestamp: question.timestamp.toISOString(), content: question.content, }; diff --git a/backend/src/interfaces/submission.ts b/backend/src/interfaces/submission.ts index e179d458..e3b60311 100644 --- a/backend/src/interfaces/submission.ts +++ b/backend/src/interfaces/submission.ts @@ -1,5 +1,5 @@ import { Submission } from '../entities/assignments/submission.entity.js'; -import { mapToGroupDTO } from './group.js'; +import { mapToGroupDTOId } from './group.js'; import { mapToStudentDTO } from './student.js'; import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; import { getSubmissionRepository } from '../data/repositories.js'; @@ -13,11 +13,10 @@ export function mapToSubmissionDTO(submission: Submission): SubmissionDTO { language: submission.learningObjectLanguage, version: submission.learningObjectVersion, }, - submissionNumber: submission.submissionNumber, submitter: mapToStudentDTO(submission.submitter), time: submission.submissionTime, - group: mapToGroupDTO(submission.onBehalfOf), + group: submission.onBehalfOf ? mapToGroupDTOId(submission.onBehalfOf, submission.onBehalfOf.assignment.within) : undefined, content: submission.content, }; } diff --git a/backend/src/middleware/error-handling/error-handler.ts b/backend/src/middleware/error-handling/error-handler.ts index d7315603..8ec93e37 100644 --- a/backend/src/middleware/error-handling/error-handler.ts +++ b/backend/src/middleware/error-handling/error-handler.ts @@ -9,7 +9,7 @@ export function errorHandler(err: unknown, _req: Request, res: Response, _: Next logger.warn(`An error occurred while handling a request: ${err} (-> HTTP ${err.status})`); res.status(err.status).json(err); } else { - logger.error(`Unexpected error occurred while handing a request: ${JSON.stringify(err)}`); + logger.error(`Unexpected error occurred while handing a request: ${(err as { stack: string })?.stack ?? JSON.stringify(err)}`); res.status(500).json(err); } } diff --git a/backend/src/routes/submissions.ts b/backend/src/routes/submissions.ts index 492b6439..fc0aa7c6 100644 --- a/backend/src/routes/submissions.ts +++ b/backend/src/routes/submissions.ts @@ -5,7 +5,7 @@ const router = express.Router({ mergeParams: true }); // Root endpoint used to search objects router.get('/', getSubmissionsHandler); -router.post('/:id', createSubmissionHandler); +router.post('/', createSubmissionHandler); // Information about an submission with id 'id' router.get('/:id', getSubmissionHandler); diff --git a/backend/src/services/assignments.ts b/backend/src/services/assignments.ts index 5fd8f67f..2379ecfb 100644 --- a/backend/src/services/assignments.ts +++ b/backend/src/services/assignments.ts @@ -1,4 +1,4 @@ -import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; +import { AssignmentDTO, AssignmentDTOId } from '@dwengo-1/common/interfaces/assignment'; import { getAssignmentRepository, getClassRepository, @@ -16,6 +16,8 @@ import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; import { EntityDTO } from '@mikro-orm/core'; import { putObject } from './service-helper.js'; +import { fetchStudents } from './students.js'; +import { ServerErrorException } from '../exceptions/server-error-exception.js'; export async function fetchAssignment(classid: string, assignmentNumber: number): Promise { const classRepository = getClassRepository(); @@ -35,7 +37,7 @@ export async function fetchAssignment(classid: string, assignmentNumber: number) return assignment; } -export async function getAllAssignments(classid: string, full: boolean): Promise { +export async function getAllAssignments(classid: string, full: boolean): Promise { const cls = await fetchClass(classid); const assignmentRepository = getAssignmentRepository(); @@ -51,13 +53,39 @@ export async function getAllAssignments(classid: string, full: boolean): Promise export async function createAssignment(classid: string, assignmentData: AssignmentDTO): Promise { const cls = await fetchClass(classid); - const assignment = mapToAssignment(assignmentData, cls); - const assignmentRepository = getAssignmentRepository(); - const newAssignment = assignmentRepository.create(assignment); - await assignmentRepository.save(newAssignment, { preventOverwrite: true }); + const assignment = mapToAssignment(assignmentData, cls); + await assignmentRepository.save(assignment); - return mapToAssignmentDTO(newAssignment); + if (assignmentData.groups) { + /* + For some reason when trying to add groups, it does not work when using the original assignment variable. + The assignment needs to be refetched in order for it to work. + */ + + const assignmentCopy = await assignmentRepository.findByClassAndId(cls, assignment.id!); + + if (assignmentCopy === null) { + throw new ServerErrorException('Something has gone horribly wrong. Could not find newly added assignment which is needed to add groups.'); + } + + const groupRepository = getGroupRepository(); + + (assignmentData.groups as string[][]).forEach(async (memberUsernames) => { + const members = await fetchStudents(memberUsernames); + + const newGroup = groupRepository.create({ + assignment: assignmentCopy, + members: members, + }); + await groupRepository.save(newGroup); + }); + } + + /* Need to refetch the assignment here again such that the groups are added. */ + const assignmentWithGroups = await fetchAssignment(classid, assignment.id!); + + return mapToAssignmentDTO(assignmentWithGroups); } export async function getAssignment(classid: string, id: number): Promise { diff --git a/backend/src/services/groups.ts b/backend/src/services/groups.ts index 3c6f2919..0c73c8c5 100644 --- a/backend/src/services/groups.ts +++ b/backend/src/services/groups.ts @@ -1,13 +1,14 @@ import { EntityDTO } from '@mikro-orm/core'; -import { getGroupRepository, getStudentRepository, getSubmissionRepository } from '../data/repositories.js'; +import { getGroupRepository, getSubmissionRepository } from '../data/repositories.js'; import { Group } from '../entities/assignments/group.entity.js'; -import { mapToGroupDTO, mapToShallowGroupDTO } from '../interfaces/group.js'; +import { mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js'; import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submission.js'; -import { GroupDTO } from '@dwengo-1/common/interfaces/group'; +import { GroupDTO, GroupDTOId } from '@dwengo-1/common/interfaces/group'; import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; import { fetchAssignment } from './assignments.js'; import { NotFoundException } from '../exceptions/not-found-exception.js'; import { putObject } from './service-helper.js'; +import { fetchStudents } from './students.js'; export async function fetchGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise { const assignment = await fetchAssignment(classId, assignmentNumber); @@ -24,7 +25,7 @@ export async function fetchGroup(classId: string, assignmentNumber: number, grou export async function getGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise { const group = await fetchGroup(classId, assignmentNumber, groupNumber); - return mapToGroupDTO(group); + return mapToGroupDTO(group, group.assignment.within); } export async function putGroup( @@ -37,7 +38,7 @@ export async function putGroup( await putObject(group, groupData, getGroupRepository()); - return mapToGroupDTO(group); + return mapToGroupDTO(group, group.assignment.within); } export async function deleteGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise { @@ -47,7 +48,7 @@ export async function deleteGroup(classId: string, assignmentNumber: number, gro const groupRepository = getGroupRepository(); await groupRepository.deleteByAssignmentAndGroupNumber(assignment, groupNumber); - return mapToGroupDTO(group); + return mapToGroupDTO(group, assignment.within); } export async function getExistingGroupFromGroupDTO(groupData: GroupDTO): Promise { @@ -59,12 +60,8 @@ export async function getExistingGroupFromGroupDTO(groupData: GroupDTO): Promise } export async function createGroup(groupData: GroupDTO, classid: string, assignmentNumber: number): Promise { - const studentRepository = getStudentRepository(); - const memberUsernames = (groupData.members as string[]) || []; - const members = (await Promise.all([...memberUsernames].map(async (id) => studentRepository.findByUsername(id)))).filter( - (student) => student !== null - ); + const members = await fetchStudents(memberUsernames); const assignment = await fetchAssignment(classid, assignmentNumber); @@ -73,22 +70,23 @@ export async function createGroup(groupData: GroupDTO, classid: string, assignme assignment: assignment, members: members, }); + await groupRepository.save(newGroup); - return mapToGroupDTO(newGroup); + return mapToGroupDTO(newGroup, newGroup.assignment.within); } -export async function getAllGroups(classId: string, assignmentNumber: number, full: boolean): Promise { +export async function getAllGroups(classId: string, assignmentNumber: number, full: boolean): Promise { const assignment = await fetchAssignment(classId, assignmentNumber); const groupRepository = getGroupRepository(); const groups = await groupRepository.findAllGroupsForAssignment(assignment); if (full) { - return groups.map(mapToGroupDTO); + return groups.map((group) => mapToGroupDTO(group, assignment.within)); } - return groups.map(mapToShallowGroupDTO); + return groups.map((group) => mapToGroupDTOId(group, assignment.within)); } export async function getGroupSubmissions( diff --git a/backend/src/services/learning-objects.ts b/backend/src/services/learning-objects.ts index 436c4a08..089fd25a 100644 --- a/backend/src/services/learning-objects.ts +++ b/backend/src/services/learning-objects.ts @@ -8,12 +8,13 @@ import { LearningPathResponse, } from '@dwengo-1/common/interfaces/learning-content'; import { getLogger } from '../logging/initalize.js'; +import { v4 } from 'uuid'; function filterData(data: LearningObjectMetadata, htmlUrl: string): FilteredLearningObject { return { key: data.hruid, // Hruid learningObject (not path) _id: data._id, - uuid: data.uuid, + uuid: data.uuid || v4(), version: data.version, title: data.title, htmlUrl, // Url to fetch html content diff --git a/backend/src/services/learning-objects/database-learning-object-provider.ts b/backend/src/services/learning-objects/database-learning-object-provider.ts index fa278bba..0b805a56 100644 --- a/backend/src/services/learning-objects/database-learning-object-provider.ts +++ b/backend/src/services/learning-objects/database-learning-object-provider.ts @@ -32,7 +32,7 @@ function convertLearningObject(learningObject: LearningObject | null): FilteredL educationalGoals: learningObject.educationalGoals, returnValue: { callback_url: learningObject.returnValue.callbackUrl, - callback_schema: JSON.parse(learningObject.returnValue.callbackSchema), + callback_schema: learningObject.returnValue.callbackSchema === '' ? '' : JSON.parse(learningObject.returnValue.callbackSchema), }, skosConcepts: learningObject.skosConcepts, targetAges: learningObject.targetAges || [], diff --git a/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts b/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts index e9898f62..4a4bdc54 100644 --- a/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts +++ b/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts @@ -11,6 +11,7 @@ import { LearningPathIdentifier, LearningPathResponse, } from '@dwengo-1/common/interfaces/learning-content'; +import { v4 } from 'uuid'; const logger: Logger = getLogger(); @@ -23,7 +24,7 @@ function filterData(data: LearningObjectMetadata): FilteredLearningObject { return { key: data.hruid, // Hruid learningObject (not path) _id: data._id, - uuid: data.uuid, + uuid: data.uuid ?? v4(), version: data.version, title: data.title, htmlUrl: `/learningObject/${data.hruid}/html?language=${data.language}&version=${data.version}`, // Url to fetch html content diff --git a/backend/src/services/learning-objects/processing/gift/gift-processor.ts b/backend/src/services/learning-objects/processing/gift/gift-processor.ts index 8d548f56..d6fe5adb 100644 --- a/backend/src/services/learning-objects/processing/gift/gift-processor.ts +++ b/backend/src/services/learning-objects/processing/gift/gift-processor.ts @@ -38,7 +38,7 @@ class GiftProcessor extends StringProcessor { let html = "
\n"; let i = 1; for (const question of quizQuestions) { - html += `
\n`; + html += `
\n`; html += ' ' + this.renderQuestion(question, i).replaceAll(/\n(.+)/g, '\n $1'); // Replace for indentation. html += `
\n`; i++; diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/multiple-choice-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/multiple-choice-question-renderer.ts index 9b09004f..40afdbd5 100644 --- a/backend/src/services/learning-objects/processing/gift/question-renderers/multiple-choice-question-renderer.ts +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/multiple-choice-question-renderer.ts @@ -14,7 +14,7 @@ export class MultipleChoiceQuestionRenderer extends GIFTQuestionRenderer\n`; renderedHtml += ` \n`; - renderedHtml += ` \n`; + renderedHtml += ` \n`; renderedHtml += `
\n`; i++; } diff --git a/backend/src/services/learning-paths/database-learning-path-provider.ts b/backend/src/services/learning-paths/database-learning-path-provider.ts index ba312b08..fe05dda1 100644 --- a/backend/src/services/learning-paths/database-learning-path-provider.ts +++ b/backend/src/services/learning-paths/database-learning-path-provider.ts @@ -4,7 +4,7 @@ import { getLearningPathRepository } from '../../data/repositories.js'; import learningObjectService from '../learning-objects/learning-object-service.js'; import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; -import { getLastSubmissionForCustomizationTarget, isTransitionPossible, PersonalizationTarget } from './learning-path-personalization-util.js'; +import { getLastSubmissionForGroup, isTransitionPossible } from './learning-path-personalization-util.js'; import { FilteredLearningObject, LearningObjectNode, @@ -13,13 +13,16 @@ import { Transition, } from '@dwengo-1/common/interfaces/learning-content'; import { Language } from '@dwengo-1/common/util/language'; +import { Group } from '../../entities/assignments/group.entity'; +import { Collection } from '@mikro-orm/core'; +import { v4 } from 'uuid'; /** * Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its * corresponding learning object. * @param nodes The nodes to find the learning object for. */ -async function getLearningObjectsForNodes(nodes: LearningPathNode[]): Promise> { +async function getLearningObjectsForNodes(nodes: Collection): Promise> { // Fetching the corresponding learning object for each of the nodes and creating a map that maps each node to // Its corresponding learning object. const nullableNodesToLearningObjects = new Map( @@ -44,7 +47,7 @@ async function getLearningObjectsForNodes(nodes: LearningPathNode[]): Promise { +async function convertLearningPath(learningPath: LearningPathEntity, order: number, personalizedFor?: Group): Promise { // Fetch the corresponding learning object for each node since some parts of the expected response contains parts // With information which is not available in the LearningPathNodes themselves. const nodesToLearningObjects: Map = await getLearningObjectsForNodes(learningPath.nodes); @@ -89,10 +92,10 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb async function convertNode( node: LearningPathNode, learningObject: FilteredLearningObject, - personalizedFor: PersonalizationTarget | undefined, + personalizedFor: Group | undefined, nodesToLearningObjects: Map ): Promise { - const lastSubmission = personalizedFor ? await getLastSubmissionForCustomizationTarget(node, personalizedFor) : null; + const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(node, personalizedFor) : null; const transitions = node.transitions .filter( (trans) => @@ -108,6 +111,7 @@ async function convertNode( updatedAt: node.updatedAt.toISOString(), learningobject_hruid: node.learningObjectHruid, version: learningObject.version, + done: personalizedFor ? lastSubmission !== null : undefined, transitions, }; } @@ -121,7 +125,7 @@ async function convertNode( */ async function convertNodes( nodesToLearningObjects: Map, - personalizedFor?: PersonalizationTarget + personalizedFor?: Group ): Promise { const nodesPromise = Array.from(nodesToLearningObjects.entries()).map(async (entry) => convertNode(entry[0], entry[1], personalizedFor, nodesToLearningObjects) @@ -161,7 +165,7 @@ function convertTransition( _id: String(index), // Retained for backwards compatibility. The index uniquely identifies the transition within the learning path. default: false, // We don't work with default transitions but retain this for backwards compatibility. next: { - _id: nextNode._id + index, // Construct a unique ID for the transition for backwards compatibility. + _id: nextNode._id ? nextNode._id + index : v4(), // Construct a unique ID for the transition for backwards compatibility. hruid: transition.next.learningObjectHruid, language: nextNode.language, version: nextNode.version, @@ -177,12 +181,7 @@ const databaseLearningPathProvider: LearningPathProvider = { /** * Fetch the learning paths with the given hruids from the database. */ - async fetchLearningPaths( - hruids: string[], - language: Language, - source: string, - personalizedFor?: PersonalizationTarget - ): Promise { + async fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: Group): Promise { const learningPathRepo = getLearningPathRepository(); const learningPaths = (await Promise.all(hruids.map(async (hruid) => learningPathRepo.findByHruidAndLanguage(hruid, language)))).filter( @@ -202,7 +201,7 @@ const databaseLearningPathProvider: LearningPathProvider = { /** * Search learning paths in the database using the given search string. */ - async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise { + async searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise { const learningPathRepo = getLearningPathRepository(); const searchResults = await learningPathRepo.findByQueryStringAndLanguage(query, language); diff --git a/backend/src/services/learning-paths/learning-path-personalization-util.ts b/backend/src/services/learning-paths/learning-path-personalization-util.ts index a9175d13..a10d5ead 100644 --- a/backend/src/services/learning-paths/learning-path-personalization-util.ts +++ b/backend/src/services/learning-paths/learning-path-personalization-util.ts @@ -1,76 +1,22 @@ import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; -import { Student } from '../../entities/users/student.entity.js'; import { Group } from '../../entities/assignments/group.entity.js'; import { Submission } from '../../entities/assignments/submission.entity.js'; -import { getClassRepository, getGroupRepository, getStudentRepository, getSubmissionRepository } from '../../data/repositories.js'; +import { getSubmissionRepository } from '../../data/repositories.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; import { JSONPath } from 'jsonpath-plus'; -export type PersonalizationTarget = { type: 'student'; student: Student } | { type: 'group'; group: Group }; - /** - * Shortcut function to easily create a PersonalizationTarget object for a student by his/her username. - * @param username Username of the student we want to generate a personalized learning path for. - * If there is no student with this username, return undefined. + * Returns the last submission for the learning object associated with the given node and for the group */ -export async function personalizedForStudent(username: string): Promise { - const student = await getStudentRepository().findByUsername(username); - if (student) { - return { - type: 'student', - student: student, - }; - } - return undefined; -} - -/** - * Shortcut function to easily create a PersonalizationTarget object for a group by class name, assignment number and - * group number. - * @param classId Id of the class in which this group was created - * @param assignmentNumber Number of the assignment for which this group was created - * @param groupNumber Number of the group for which we want to personalize the learning path. - */ -export async function personalizedForGroup( - classId: string, - assignmentNumber: number, - groupNumber: number -): Promise { - const clazz = await getClassRepository().findById(classId); - if (!clazz) { - return undefined; - } - const group = await getGroupRepository().findOne({ - assignment: { - within: clazz, - id: assignmentNumber, - }, - groupNumber: groupNumber, - }); - if (group) { - return { - type: 'group', - group: group, - }; - } - return undefined; -} - -/** - * Returns the last submission for the learning object associated with the given node and for the student or group - */ -export async function getLastSubmissionForCustomizationTarget(node: LearningPathNode, pathFor: PersonalizationTarget): Promise { +export async function getLastSubmissionForGroup(node: LearningPathNode, pathFor: Group): Promise { const submissionRepo = getSubmissionRepository(); const learningObjectId: LearningObjectIdentifier = { hruid: node.learningObjectHruid, language: node.language, version: node.version, }; - if (pathFor.type === 'group') { - return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor.group); - } - return await submissionRepo.findMostRecentSubmissionForStudent(learningObjectId, pathFor.student); + return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor); } /** diff --git a/backend/src/services/learning-paths/learning-path-provider.ts b/backend/src/services/learning-paths/learning-path-provider.ts index 3bf734e7..086777bd 100644 --- a/backend/src/services/learning-paths/learning-path-provider.ts +++ b/backend/src/services/learning-paths/learning-path-provider.ts @@ -1,6 +1,6 @@ import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; -import { PersonalizationTarget } from './learning-path-personalization-util.js'; import { Language } from '@dwengo-1/common/util/language'; +import { Group } from '../../entities/assignments/group.entity'; /** * Generic interface for a service which provides access to learning paths from a data source. @@ -9,10 +9,10 @@ export interface LearningPathProvider { /** * Fetch the learning paths with the given hruids from the data source. */ - fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: PersonalizationTarget): Promise; + fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: Group): Promise; /** * Search learning paths in the data source using the given search string. */ - searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise; + searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise; } diff --git a/backend/src/services/learning-paths/learning-path-service.ts b/backend/src/services/learning-paths/learning-path-service.ts index 0e4d2c5e..b20d8f97 100644 --- a/backend/src/services/learning-paths/learning-path-service.ts +++ b/backend/src/services/learning-paths/learning-path-service.ts @@ -1,13 +1,78 @@ import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider.js'; import databaseLearningPathProvider from './database-learning-path-provider.js'; import { envVars, getEnvVar } from '../../util/envVars.js'; -import { PersonalizationTarget } from './learning-path-personalization-util.js'; -import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; +import { LearningObjectNode, LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; import { Language } from '@dwengo-1/common/util/language'; +import { Group } from '../../entities/assignments/group.entity.js'; +import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js'; +import { getLearningPathRepository } from '../../data/repositories.js'; +import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; +import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; +import { base64ToArrayBuffer } from '../../util/base64-buffer-conversion.js'; +import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; +import { mapToTeacher } from '../../interfaces/teacher.js'; +import { Collection } from '@mikro-orm/core'; const userContentPrefix = getEnvVar(envVars.UserContentPrefix); const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider]; +export function mapToLearningPath(dto: LearningPath, adminsDto: TeacherDTO[]): LearningPathEntity { + const admins = adminsDto.map((admin) => mapToTeacher(admin)); + const repo = getLearningPathRepository(); + const path = repo.create({ + hruid: dto.hruid, + language: dto.language as Language, + description: dto.description, + title: dto.title, + admins, + image: dto.image ? Buffer.from(base64ToArrayBuffer(dto.image)) : null, + }); + const nodes = dto.nodes.map((nodeDto: LearningObjectNode, i: number) => + repo.createNode({ + learningPath: path, + learningObjectHruid: nodeDto.learningobject_hruid, + nodeNumber: i, + language: nodeDto.language, + version: nodeDto.version, + startNode: nodeDto.start_node ?? false, + createdAt: new Date(), + updatedAt: new Date(), + }) + ); + dto.nodes.forEach((nodeDto) => { + const fromNode = nodes.find( + (it) => it.learningObjectHruid === nodeDto.learningobject_hruid && it.language === nodeDto.language && it.version === nodeDto.version + )!; + const transitions = nodeDto.transitions + .map((transDto, i) => { + const toNode = nodes.find( + (it) => + it.learningObjectHruid === transDto.next.hruid && + it.language === transDto.next.language && + it.version === transDto.next.version + ); + + if (toNode) { + return repo.createTransition({ + transitionNumber: i, + node: fromNode, + next: toNode, + condition: transDto.condition ?? 'true', + }); + } + return undefined; + }) + .filter((it) => it) + .map((it) => it!); + + fromNode.transitions = new Collection(fromNode, transitions); + }); + + path.nodes = new Collection(path, nodes); + + return path; +} + /** * Service providing access to data about learning paths from the appropriate data source (database or Dwengo-api) */ @@ -19,12 +84,7 @@ const learningPathService = { * @param source * @param personalizedFor If this is set, a learning path personalized for the given group or student will be returned. */ - async fetchLearningPaths( - hruids: string[], - language: Language, - source: string, - personalizedFor?: PersonalizationTarget - ): Promise { + async fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: Group): Promise { const userContentHruids = hruids.filter((hruid) => hruid.startsWith(userContentPrefix)); const nonUserContentHruids = hruids.filter((hruid) => !hruid.startsWith(userContentPrefix)); @@ -48,12 +108,23 @@ const learningPathService = { /** * Search learning paths in the data source using the given search string. */ - async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise { + async searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise { const providerResponses = await Promise.all( allProviders.map(async (provider) => provider.searchLearningPaths(query, language, personalizedFor)) ); return providerResponses.flat(); }, + + /** + * Add a new learning path to the database. + * @param dto Learning path DTO from which the learning path will be created. + * @param admins Teachers who should become an admin of the learning path. + */ + async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise { + const repo = getLearningPathRepository(); + const path = mapToLearningPath(dto, admins); + await repo.save(path, { preventOverwrite: true }); + }, }; export default learningPathService; diff --git a/backend/src/services/students.ts b/backend/src/services/students.ts index b1467886..03a6d8fa 100644 --- a/backend/src/services/students.ts +++ b/backend/src/services/students.ts @@ -7,7 +7,7 @@ import { getSubmissionRepository, } from '../data/repositories.js'; import { mapToClassDTO } from '../interfaces/class.js'; -import { mapToGroupDTO, mapToShallowGroupDTO } from '../interfaces/group.js'; +import { mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js'; import { mapToStudent, mapToStudentDTO } from '../interfaces/student.js'; import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submission.js'; import { getAllAssignments } from './assignments.js'; @@ -18,8 +18,8 @@ import { NotFoundException } from '../exceptions/not-found-exception.js'; import { fetchClass } from './classes.js'; import { StudentDTO } from '@dwengo-1/common/interfaces/student'; import { ClassDTO } from '@dwengo-1/common/interfaces/class'; -import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; -import { GroupDTO } from '@dwengo-1/common/interfaces/group'; +import { AssignmentDTO, AssignmentDTOId } from '@dwengo-1/common/interfaces/assignment'; +import { GroupDTO, GroupDTOId } from '@dwengo-1/common/interfaces/group'; import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; @@ -48,6 +48,11 @@ export async function fetchStudent(username: string): Promise { return user; } +export async function fetchStudents(usernames: string[]): Promise { + const members = await Promise.all(usernames.map(async (username) => await fetchStudent(username))); + return members; +} + export async function getStudent(username: string): Promise { const user = await fetchStudent(username); return mapToStudentDTO(user); @@ -83,7 +88,7 @@ export async function getStudentClasses(username: string, full: boolean): Promis return classes.map((cls) => cls.classId!); } -export async function getStudentAssignments(username: string, full: boolean): Promise { +export async function getStudentAssignments(username: string, full: boolean): Promise { const student = await fetchStudent(username); const classRepository = getClassRepository(); @@ -92,17 +97,17 @@ export async function getStudentAssignments(username: string, full: boolean): Pr return (await Promise.all(classes.map(async (cls) => await getAllAssignments(cls.classId!, full)))).flat(); } -export async function getStudentGroups(username: string, full: boolean): Promise { +export async function getStudentGroups(username: string, full: boolean): Promise { const student = await fetchStudent(username); const groupRepository = getGroupRepository(); const groups = await groupRepository.findAllGroupsWithStudent(student); if (full) { - return groups.map(mapToGroupDTO); + return groups.map((group) => mapToGroupDTO(group, group.assignment.within)); } - return groups.map(mapToShallowGroupDTO); + return groups.map((group) => mapToGroupDTOId(group, group.assignment.within)); } export async function getStudentSubmissions(username: string, full: boolean): Promise { diff --git a/backend/src/services/submissions.ts b/backend/src/services/submissions.ts index 64028a5f..1170bf50 100644 --- a/backend/src/services/submissions.ts +++ b/backend/src/services/submissions.ts @@ -1,4 +1,4 @@ -import { getAssignmentRepository, getSubmissionRepository } from '../data/repositories.js'; +import { getAssignmentRepository, getGroupRepository, getSubmissionRepository } from '../data/repositories.js'; import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; import { NotFoundException } from '../exceptions/not-found-exception.js'; import { mapToSubmission, mapToSubmissionDTO } from '../interfaces/submission.js'; @@ -33,10 +33,11 @@ export async function getAllSubmissions(loId: LearningObjectIdentifier): Promise export async function createSubmission(submissionDTO: SubmissionDTO): Promise { const submitter = await fetchStudent(submissionDTO.submitter.username); - const group = await getExistingGroupFromGroupDTO(submissionDTO.group); + const group = await getExistingGroupFromGroupDTO(submissionDTO.group!); const submissionRepository = getSubmissionRepository(); const submission = mapToSubmission(submissionDTO, submitter, group); + await submissionRepository.save(submission); return mapToSubmissionDTO(submission); @@ -60,12 +61,18 @@ export async function getSubmissionsForLearningObjectAndAssignment( version: number, classId: string, assignmentId: number, - studentUsername?: string + groupId?: number ): Promise { const loId = new LearningObjectIdentifier(learningObjectHruid, language, version); const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, assignmentId); - const submissions = await getSubmissionRepository().findAllSubmissionsForLearningObjectAndAssignment(loId, assignment!, studentUsername); + let submissions: Submission[]; + if (groupId !== undefined) { + const group = await getGroupRepository().findByAssignmentAndGroupNumber(assignment!, groupId); + submissions = await getSubmissionRepository().findAllSubmissionsForLearningObjectAndGroup(loId, group!); + } else { + submissions = await getSubmissionRepository().findAllSubmissionsForLearningObjectAndAssignment(loId, assignment!); + } return submissions.map((s) => mapToSubmissionDTO(s)); } diff --git a/backend/src/sqlite-autoincrement-workaround.ts b/backend/src/sqlite-autoincrement-workaround.ts index e45a5141..5ed48ee6 100644 --- a/backend/src/sqlite-autoincrement-workaround.ts +++ b/backend/src/sqlite-autoincrement-workaround.ts @@ -27,7 +27,7 @@ export class SqliteAutoincrementSubscriber implements EventSubscriber { for (const prop of Object.values(args.meta.properties)) { const property = prop as EntityProperty; - if (property.primary && property.autoincrement && !(args.entity as Record)[property.name]) { + if (property.primary && property.autoincrement && (args.entity as Record)[property.name] === undefined) { // Obtain and increment sequence number of this entity. const propertyKey = args.meta.class.name + '.' + property.name; const nextSeqNumber = this.sequenceNumbersForEntityType.get(propertyKey) || 0; diff --git a/backend/src/util/base64-buffer-conversion.ts b/backend/src/util/base64-buffer-conversion.ts new file mode 100644 index 00000000..a2b23002 --- /dev/null +++ b/backend/src/util/base64-buffer-conversion.ts @@ -0,0 +1,12 @@ +/** + * Convert a Base64-encoded string into a buffer with the same data. + * @param base64 The Base64 encoded string. + */ +export function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes.buffer; +} diff --git a/backend/tests/data/assignments/assignments.test.ts b/backend/tests/data/assignments/assignments.test.ts index 1fe52523..74c858b3 100644 --- a/backend/tests/data/assignments/assignments.test.ts +++ b/backend/tests/data/assignments/assignments.test.ts @@ -16,7 +16,7 @@ describe('AssignmentRepository', () => { it('should return the requested assignment', async () => { const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); - const assignment = await assignmentRepository.findByClassAndId(class_!, 2); + const assignment = await assignmentRepository.findByClassAndId(class_!, 21001); expect(assignment).toBeTruthy(); expect(assignment!.title).toBe('tool'); @@ -35,7 +35,7 @@ describe('AssignmentRepository', () => { const result = await assignmentRepository.findAllByResponsibleTeacher('testleerkracht1'); const resultIds = result.map((it) => it.id).sort((a, b) => (a ?? 0) - (b ?? 0)); - expect(resultIds).toEqual([1, 3, 4]); + expect(resultIds).toEqual([21000, 21002, 21003, 21004]); }); it('should not find removed assignment', async () => { diff --git a/backend/tests/data/assignments/groups.test.ts b/backend/tests/data/assignments/groups.test.ts index f7fb3046..efd477ab 100644 --- a/backend/tests/data/assignments/groups.test.ts +++ b/backend/tests/data/assignments/groups.test.ts @@ -19,16 +19,16 @@ describe('GroupRepository', () => { it('should return the requested group', async () => { const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); - const assignment = await assignmentRepository.findByClassAndId(class_!, 1); + const assignment = await assignmentRepository.findByClassAndId(class_!, 21000); - const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1); + const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 21001); expect(group).toBeTruthy(); }); it('should return all groups for assignment', async () => { const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); - const assignment = await assignmentRepository.findByClassAndId(class_!, 1); + const assignment = await assignmentRepository.findByClassAndId(class_!, 21000); const groups = await groupRepository.findAllGroupsForAssignment(assignment!); @@ -38,9 +38,9 @@ describe('GroupRepository', () => { it('should not find removed group', async () => { const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); - const assignment = await assignmentRepository.findByClassAndId(class_!, 2); + const assignment = await assignmentRepository.findByClassAndId(class_!, 21001); - await groupRepository.deleteByAssignmentAndGroupNumber(assignment!, 1); + await groupRepository.deleteByAssignmentAndGroupNumber(assignment!, 21001); const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1); diff --git a/backend/tests/data/assignments/submissions.test.ts b/backend/tests/data/assignments/submissions.test.ts index 31aafc1d..2bbd00dc 100644 --- a/backend/tests/data/assignments/submissions.test.ts +++ b/backend/tests/data/assignments/submissions.test.ts @@ -54,8 +54,8 @@ describe('SubmissionRepository', () => { it('should find the most recent submission for a group', async () => { const id = new LearningObjectIdentifier('id03', Language.English, 1); const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); - const assignment = await assignmentRepository.findByClassAndId(class_!, 1); - const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1); + const assignment = await assignmentRepository.findByClassAndId(class_!, 21000); + const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 21001); const submission = await submissionRepository.findMostRecentSubmissionForGroup(id, group!); expect(submission).toBeTruthy(); @@ -67,7 +67,7 @@ describe('SubmissionRepository', () => { let loId: LearningObjectIdentifier; it('should find all submissions for a certain learning object and assignment', async () => { clazz = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); - assignment = await assignmentRepository.findByClassAndId(clazz!, 1); + assignment = await assignmentRepository.findByClassAndId(clazz!, 21000); loId = { hruid: 'id02', language: Language.English, @@ -91,9 +91,9 @@ describe('SubmissionRepository', () => { expect(result[2].submissionNumber).toBe(3); }); - it("should find only the submissions for a certain learning object and assignment made for the user's group", async () => { - const result = await submissionRepository.findAllSubmissionsForLearningObjectAndAssignment(loId, assignment!, 'Tool'); - // (student Tool is in group #2) + it('should find only the submissions for a certain learning object and assignment made for the given group', async () => { + const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 21002); + const result = await submissionRepository.findAllSubmissionsForLearningObjectAndGroup(loId, group!); expect(result).toHaveLength(1); diff --git a/backend/tests/data/content/attachment-repository.test.ts b/backend/tests/data/content/attachment-repository.test.ts index b76c9016..162d8cbb 100644 --- a/backend/tests/data/content/attachment-repository.test.ts +++ b/backend/tests/data/content/attachment-repository.test.ts @@ -2,66 +2,57 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { setupTestApp } from '../../setup-tests.js'; import { getAttachmentRepository, getLearningObjectRepository } from '../../../src/data/repositories.js'; import { AttachmentRepository } from '../../../src/data/content/attachment-repository.js'; -import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js'; -import example from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.js'; import { LearningObject } from '../../../src/entities/content/learning-object.entity.js'; import { Attachment } from '../../../src/entities/content/attachment.entity.js'; import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier.js'; - -const NEWER_TEST_SUFFIX = 'nEweR'; - -async function createTestLearningObjects(learningObjectRepo: LearningObjectRepository): Promise<{ - older: LearningObject; - newer: LearningObject; -}> { - const olderExample = example.createLearningObject(); - await learningObjectRepo.save(olderExample); - - const newerExample = example.createLearningObject(); - newerExample.title = 'Newer example'; - newerExample.version = 100; - - return { - older: olderExample, - newer: newerExample, - }; -} +import { testLearningObjectPnNotebooks } from '../../test_assets/content/learning-objects.testdata'; +import { v4 as uuidV4 } from 'uuid'; describe('AttachmentRepository', () => { let attachmentRepo: AttachmentRepository; - let exampleLearningObjects: { older: LearningObject; newer: LearningObject }; + let newLearningObject: LearningObject; let attachmentsOlderLearningObject: Attachment[]; beforeAll(async () => { await setupTestApp(); + + attachmentsOlderLearningObject = testLearningObjectPnNotebooks.attachments as Attachment[]; + attachmentRepo = getAttachmentRepository(); - exampleLearningObjects = await createTestLearningObjects(getLearningObjectRepository()); - }); + const learningObjectRepo = getLearningObjectRepository(); - it('can add attachments to learning objects without throwing an error', async () => { - attachmentsOlderLearningObject = Object.values(example.createAttachment).map((fn) => fn(exampleLearningObjects.older)); + const newLearningObjectData = structuredClone(testLearningObjectPnNotebooks); + newLearningObjectData.title = 'Newer example'; + newLearningObjectData.version = 101; + newLearningObjectData.attachments = []; + newLearningObjectData.uuid = uuidV4(); + newLearningObjectData.content = Buffer.from('Content of the newer example'); - await Promise.all(attachmentsOlderLearningObject.map(async (attachment) => attachmentRepo.save(attachment))); + newLearningObject = learningObjectRepo.create(newLearningObjectData); + await learningObjectRepo.save(newLearningObject); }); let attachmentOnlyNewer: Attachment; it('allows us to add attachments with the same name to a different learning object without throwing an error', async () => { - attachmentOnlyNewer = Object.values(example.createAttachment)[0](exampleLearningObjects.newer); - attachmentOnlyNewer.content.write(NEWER_TEST_SUFFIX); + attachmentOnlyNewer = structuredClone(attachmentsOlderLearningObject[0]); + attachmentOnlyNewer.learningObject = newLearningObject; + attachmentOnlyNewer.content = Buffer.from('New attachment content'); - await attachmentRepo.save(attachmentOnlyNewer); + await attachmentRepo.save(attachmentRepo.create(attachmentOnlyNewer)); }); let olderLearningObjectId: LearningObjectIdentifier; it('returns the correct attachment when queried by learningObjectId and attachment name', async () => { olderLearningObjectId = { - hruid: exampleLearningObjects.older.hruid, - language: exampleLearningObjects.older.language, - version: exampleLearningObjects.older.version, + hruid: testLearningObjectPnNotebooks.hruid, + language: testLearningObjectPnNotebooks.language, + version: testLearningObjectPnNotebooks.version, }; const result = await attachmentRepo.findByLearningObjectIdAndName(olderLearningObjectId, attachmentsOlderLearningObject[0].name); - expect(result).toBe(attachmentsOlderLearningObject[0]); + expect(result).not.toBeNull(); + expect(result!.name).toEqual(attachmentsOlderLearningObject[0].name); + expect(result!.content).toEqual(attachmentsOlderLearningObject[0].content); }); it('returns null when queried by learningObjectId and non-existing attachment name', async () => { @@ -71,10 +62,12 @@ describe('AttachmentRepository', () => { it('returns the newer version of the attachment when only queried by hruid, language and attachment name (but not version)', async () => { const result = await attachmentRepo.findByMostRecentVersionOfLearningObjectAndName( - exampleLearningObjects.older.hruid, - exampleLearningObjects.older.language, + testLearningObjectPnNotebooks.hruid, + testLearningObjectPnNotebooks.language, attachmentOnlyNewer.name ); - expect(result).toBe(attachmentOnlyNewer); + expect(result).not.toBeNull(); + expect(result!.name).toEqual(attachmentOnlyNewer.name); + expect(result!.content).toEqual(attachmentOnlyNewer.content); }); }); diff --git a/backend/tests/data/content/attachments.test.ts b/backend/tests/data/content/attachments.test.ts index 4e65954e..1a393b15 100644 --- a/backend/tests/data/content/attachments.test.ts +++ b/backend/tests/data/content/attachments.test.ts @@ -1,28 +1,21 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { setupTestApp } from '../../setup-tests.js'; -import { getAttachmentRepository, getLearningObjectRepository } from '../../../src/data/repositories.js'; +import { getAttachmentRepository } from '../../../src/data/repositories.js'; import { AttachmentRepository } from '../../../src/data/content/attachment-repository.js'; -import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js'; -import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier.js'; -import { Language } from '@dwengo-1/common/util/language'; +import { testLearningObject02 } from '../../test_assets/content/learning-objects.testdata'; describe('AttachmentRepository', () => { let attachmentRepository: AttachmentRepository; - let learningObjectRepository: LearningObjectRepository; beforeAll(async () => { await setupTestApp(); attachmentRepository = getAttachmentRepository(); - learningObjectRepository = getLearningObjectRepository(); }); it('should return the requested attachment', async () => { - const id = new LearningObjectIdentifier('id02', Language.English, 1); - const learningObject = await learningObjectRepository.findByIdentifier(id); - const attachment = await attachmentRepository.findByMostRecentVersionOfLearningObjectAndName( - learningObject!.hruid, - Language.English, + testLearningObject02.hruid, + testLearningObject02.language, 'attachment01' ); diff --git a/backend/tests/data/content/learning-object-repository.test.ts b/backend/tests/data/content/learning-object-repository.test.ts index 12e14452..4761c297 100644 --- a/backend/tests/data/content/learning-object-repository.test.ts +++ b/backend/tests/data/content/learning-object-repository.test.ts @@ -2,48 +2,33 @@ import { beforeAll, describe, it, expect } from 'vitest'; import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js'; import { setupTestApp } from '../../setup-tests.js'; import { getLearningObjectRepository } from '../../../src/data/repositories.js'; -import example from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.js'; import { LearningObject } from '../../../src/entities/content/learning-object.entity.js'; import { expectToBeCorrectEntity } from '../../test-utils/expectations.js'; +import { testLearningObject01, testLearningObject02, testLearningObject03 } from '../../test_assets/content/learning-objects.testdata'; +import { v4 } from 'uuid'; describe('LearningObjectRepository', () => { let learningObjectRepository: LearningObjectRepository; - let exampleLearningObject: LearningObject; - beforeAll(async () => { await setupTestApp(); learningObjectRepository = getLearningObjectRepository(); }); - it('should be able to add a learning object to it without an error', async () => { - exampleLearningObject = example.createLearningObject(); - await learningObjectRepository.insert(exampleLearningObject); - }); - - it('should return the learning object when queried by id', async () => { + it('should return a learning object when queried by id', async () => { const result = await learningObjectRepository.findByIdentifier({ - hruid: exampleLearningObject.hruid, - language: exampleLearningObject.language, - version: exampleLearningObject.version, + hruid: testLearningObject01.hruid, + language: testLearningObject02.language, + version: testLearningObject03.version, }); expect(result).toBeInstanceOf(LearningObject); - expectToBeCorrectEntity( - { - name: 'actual', - entity: result!, - }, - { - name: 'expected', - entity: exampleLearningObject, - } - ); + expectToBeCorrectEntity(result!, testLearningObject01); }); it('should return null when non-existing version is queried', async () => { const result = await learningObjectRepository.findByIdentifier({ - hruid: exampleLearningObject.hruid, - language: exampleLearningObject.language, + hruid: testLearningObject01.hruid, + language: testLearningObject01.language, version: 100, }); expect(result).toBe(null); @@ -52,9 +37,12 @@ describe('LearningObjectRepository', () => { let newerExample: LearningObject; it('should allow a learning object with the same id except a different version to be added', async () => { - newerExample = example.createLearningObject(); - newerExample.version = 10; - newerExample.title += ' (nieuw)'; + const testLearningObject01Newer = structuredClone(testLearningObject01); + testLearningObject01Newer.version = 10; + testLearningObject01Newer.title += ' (nieuw)'; + testLearningObject01Newer.uuid = v4(); + testLearningObject01Newer.content = Buffer.from('This is the new content.'); + newerExample = learningObjectRepository.create(testLearningObject01Newer); await learningObjectRepository.save(newerExample); }); @@ -66,7 +54,7 @@ describe('LearningObjectRepository', () => { }); it('should return null when queried by non-existing hruid or language', async () => { - const result = await learningObjectRepository.findLatestByHruidAndLanguage('something_that_does_not_exist', exampleLearningObject.language); + const result = await learningObjectRepository.findLatestByHruidAndLanguage('something_that_does_not_exist', testLearningObject01.language); expect(result).toBe(null); }); }); diff --git a/backend/tests/data/content/learning-objects.test.ts b/backend/tests/data/content/learning-objects.test.ts index 3c9d5dde..aa8d3bda 100644 --- a/backend/tests/data/content/learning-objects.test.ts +++ b/backend/tests/data/content/learning-objects.test.ts @@ -4,6 +4,7 @@ import { getLearningObjectRepository } from '../../../src/data/repositories'; import { setupTestApp } from '../../setup-tests'; import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; import { Language } from '@dwengo-1/common/util/language'; +import { testLearningObject01 } from '../../test_assets/content/learning-objects.testdata'; describe('LearningObjectRepository', () => { let learningObjectRepository: LearningObjectRepository; @@ -13,8 +14,8 @@ describe('LearningObjectRepository', () => { learningObjectRepository = getLearningObjectRepository(); }); - const id01 = new LearningObjectIdentifier('id01', Language.English, 1); - const id02 = new LearningObjectIdentifier('test_id', Language.English, 1); + const id01 = new LearningObjectIdentifier(testLearningObject01.hruid, testLearningObject01.language, testLearningObject01.version); + const id02 = new LearningObjectIdentifier('non_existing_id', Language.English, 1); it('should return the learning object that matches identifier 1', async () => { const learningObject = await learningObjectRepository.findByIdentifier(id01); diff --git a/backend/tests/data/content/learning-path-repository.test.ts b/backend/tests/data/content/learning-path-repository.test.ts index bdf5377e..9fadae11 100644 --- a/backend/tests/data/content/learning-path-repository.test.ts +++ b/backend/tests/data/content/learning-path-repository.test.ts @@ -2,41 +2,27 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { setupTestApp } from '../../setup-tests.js'; import { getLearningPathRepository } from '../../../src/data/repositories.js'; import { LearningPathRepository } from '../../../src/data/content/learning-path-repository.js'; -import example from '../../test-assets/learning-paths/pn-werking-example.js'; import { LearningPath } from '../../../src/entities/content/learning-path.entity.js'; -import { expectToBeCorrectEntity } from '../../test-utils/expectations.js'; +import { expectToBeCorrectEntity, expectToHaveFoundNothing, expectToHaveFoundPrecisely } from '../../test-utils/expectations.js'; import { Language } from '@dwengo-1/common/util/language'; - -function expectToHaveFoundPrecisely(expected: LearningPath, result: LearningPath[]): void { - expect(result).toHaveProperty('length'); - expect(result.length).toBe(1); - expectToBeCorrectEntity({ entity: result[0] }, { entity: expected }); -} - -function expectToHaveFoundNothing(result: LearningPath[]): void { - expect(result).toHaveProperty('length'); - expect(result.length).toBe(0); -} +import { testLearningPath01 } from '../../test_assets/content/learning-paths.testdata'; +import { mapToLearningPath } from '../../../src/services/learning-paths/learning-path-service'; describe('LearningPathRepository', () => { let learningPathRepo: LearningPathRepository; + let examplePath: LearningPath; beforeAll(async () => { await setupTestApp(); learningPathRepo = getLearningPathRepository(); + + examplePath = mapToLearningPath(testLearningPath01, []); }); - let examplePath: LearningPath; - - it('should be able to add a learning path without throwing an error', async () => { - examplePath = example.createLearningPath(); - await learningPathRepo.insert(examplePath); - }); - - it('should return the added path when it is queried by hruid and language', async () => { - const result = await learningPathRepo.findByHruidAndLanguage(examplePath.hruid, examplePath.language); + it('should return a learning path when it is queried by hruid and language', async () => { + const result = await learningPathRepo.findByHruidAndLanguage(testLearningPath01.hruid, testLearningPath01.language as Language); expect(result).toBeInstanceOf(LearningPath); - expectToBeCorrectEntity({ entity: result! }, { entity: examplePath }); + expectToBeCorrectEntity(result!, examplePath); }); it('should return null to a query on a non-existing hruid or language', async () => { @@ -45,7 +31,7 @@ describe('LearningPathRepository', () => { }); it('should return the learning path when we search for a search term occurring in its title', async () => { - const result = await learningPathRepo.findByQueryStringAndLanguage(examplePath.title.slice(4, 9), examplePath.language); + const result = await learningPathRepo.findByQueryStringAndLanguage(examplePath.title.slice(9, 13), examplePath.language); expectToHaveFoundPrecisely(examplePath, result); }); diff --git a/backend/tests/data/content/learning-paths.test.ts b/backend/tests/data/content/learning-paths.test.ts index 683e1d40..32e25dde 100644 --- a/backend/tests/data/content/learning-paths.test.ts +++ b/backend/tests/data/content/learning-paths.test.ts @@ -3,6 +3,7 @@ import { getLearningPathRepository } from '../../../src/data/repositories'; import { LearningPathRepository } from '../../../src/data/content/learning-path-repository'; import { setupTestApp } from '../../setup-tests'; import { Language } from '@dwengo-1/common/util/language'; +import { testLearningPath01 } from '../../test_assets/content/learning-paths.testdata'; describe('LearningPathRepository', () => { let learningPathRepository: LearningPathRepository; @@ -19,10 +20,10 @@ describe('LearningPathRepository', () => { }); it('should return requested learning path', async () => { - const learningPath = await learningPathRepository.findByHruidAndLanguage('id01', Language.English); + const learningPath = await learningPathRepository.findByHruidAndLanguage(testLearningPath01.hruid, testLearningPath01.language as Language); expect(learningPath).toBeTruthy(); - expect(learningPath?.title).toBe('repertoire Tool'); - expect(learningPath?.description).toBe('all about Tool'); + expect(learningPath?.title).toBe(testLearningPath01.title); + expect(learningPath?.description).toBe(testLearningPath01.description); }); }); diff --git a/backend/tests/data/questions/questions.test.ts b/backend/tests/data/questions/questions.test.ts index 9565e71d..8ad2d47c 100644 --- a/backend/tests/data/questions/questions.test.ts +++ b/backend/tests/data/questions/questions.test.ts @@ -38,8 +38,8 @@ describe('QuestionRepository', () => { const student = await studentRepository.findByUsername('Noordkaap'); const clazz = await getClassRepository().findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); - const assignment = await getAssignmentRepository().findByClassAndId(clazz!, 1); - const group = await getGroupRepository().findByAssignmentAndGroupNumber(assignment!, 1); + const assignment = await getAssignmentRepository().findByClassAndId(clazz!, 21000); + const group = await getGroupRepository().findByAssignmentAndGroupNumber(assignment!, 21001); await questionRepository.createQuestion({ loId: id, inGroup: group!, @@ -57,7 +57,7 @@ describe('QuestionRepository', () => { let loId: LearningObjectIdentifier; it('should find all questions for a certain learning object and assignment', async () => { clazz = await getClassRepository().findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); - assignment = await getAssignmentRepository().findByClassAndId(clazz!, 1); + assignment = await getAssignmentRepository().findByClassAndId(clazz!, 21000); loId = { hruid: 'id05', language: Language.English, diff --git a/backend/tests/services/learning-objects/database-learning-object-provider.test.ts b/backend/tests/services/learning-objects/database-learning-object-provider.test.ts index 31899ded..79d6a59c 100644 --- a/backend/tests/services/learning-objects/database-learning-object-provider.test.ts +++ b/backend/tests/services/learning-objects/database-learning-object-provider.test.ts @@ -1,37 +1,32 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { setupTestApp } from '../../setup-tests'; -import { getLearningObjectRepository, getLearningPathRepository } from '../../../src/data/repositories'; -import example from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; import { LearningObject } from '../../../src/entities/content/learning-object.entity'; import databaseLearningObjectProvider from '../../../src/services/learning-objects/database-learning-object-provider'; import { expectToBeCorrectFilteredLearningObject } from '../../test-utils/expectations'; import { Language } from '@dwengo-1/common/util/language'; -import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; -import learningPathExample from '../../test-assets/learning-paths/pn-werking-example'; -import { LearningPath } from '../../../src/entities/content/learning-path.entity'; -import { FilteredLearningObject } from '@dwengo-1/common/interfaces/learning-content'; - -async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { - const learningObjectRepo = getLearningObjectRepository(); - const learningPathRepo = getLearningPathRepository(); - const learningObject = learningObjectExample.createLearningObject(); - const learningPath = learningPathExample.createLearningPath(); - await learningObjectRepo.save(learningObject); - await learningPathRepo.save(learningPath); - return { learningObject, learningPath }; -} +import { FilteredLearningObject, LearningObjectNode, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; +import { testPartiallyDatabaseAndPartiallyDwengoApiLearningPath } from '../../test_assets/content/learning-paths.testdata'; +import { testLearningObjectPnNotebooks } from '../../test_assets/content/learning-objects.testdata'; +import { LearningPath } from '@dwengo-1/common/dist/interfaces/learning-content'; +import { RequiredEntityData } from '@mikro-orm/core'; +import { getHtmlRenderingForTestLearningObject } from '../../test-utils/get-html-rendering'; const EXPECTED_TITLE_FROM_DWENGO_LEARNING_OBJECT = 'Notebook opslaan'; describe('DatabaseLearningObjectProvider', () => { - let exampleLearningObject: LearningObject; + let exampleLearningObject: RequiredEntityData; let exampleLearningPath: LearningPath; + let exampleLearningPathId: LearningPathIdentifier; beforeAll(async () => { await setupTestApp(); - const exampleData = await initExampleData(); - exampleLearningObject = exampleData.learningObject; - exampleLearningPath = exampleData.learningPath; + exampleLearningObject = testLearningObjectPnNotebooks; + exampleLearningPath = testPartiallyDatabaseAndPartiallyDwengoApiLearningPath; + + exampleLearningPathId = { + hruid: exampleLearningPath.hruid, + language: exampleLearningPath.language as Language, + }; }); describe('getLearningObjectById', () => { it('should return the learning object when it is queried by its id', async () => { @@ -61,7 +56,7 @@ describe('DatabaseLearningObjectProvider', () => { it('should return the correct rendering of the learning object', async () => { const result = await databaseLearningObjectProvider.getLearningObjectHTML(exampleLearningObject); // Set newlines so your tests are platform-independent. - expect(result).toEqual(example.getHTMLRendering().replace(/\r\n/g, '\n')); + expect(result).toEqual(getHtmlRenderingForTestLearningObject(exampleLearningObject).replace(/\r\n/g, '\n')); }); it('should return null for a non-existing learning object', async () => { const result = await databaseLearningObjectProvider.getLearningObjectHTML({ @@ -73,8 +68,8 @@ describe('DatabaseLearningObjectProvider', () => { }); describe('getLearningObjectIdsFromPath', () => { it('should return all learning object IDs from a path', async () => { - const result = await databaseLearningObjectProvider.getLearningObjectIdsFromPath(exampleLearningPath); - expect(new Set(result)).toEqual(new Set(exampleLearningPath.nodes.map((it) => it.learningObjectHruid))); + const result = await databaseLearningObjectProvider.getLearningObjectIdsFromPath(exampleLearningPathId); + expect(new Set(result)).toEqual(new Set(exampleLearningPath.nodes.map((it: LearningObjectNode) => it.learningobject_hruid))); }); it('should throw an error if queried with a path identifier for which there is no learning path', async () => { await expect( @@ -89,9 +84,11 @@ describe('DatabaseLearningObjectProvider', () => { }); describe('getLearningObjectsFromPath', () => { it('should correctly return all learning objects which are on the path, even those who are not in the database', async () => { - const result = await databaseLearningObjectProvider.getLearningObjectsFromPath(exampleLearningPath); + const result = await databaseLearningObjectProvider.getLearningObjectsFromPath(exampleLearningPathId); expect(result.length).toBe(exampleLearningPath.nodes.length); - expect(new Set(result.map((it) => it.key))).toEqual(new Set(exampleLearningPath.nodes.map((it) => it.learningObjectHruid))); + expect(new Set(result.map((it) => it.key))).toEqual( + new Set(exampleLearningPath.nodes.map((it: LearningObjectNode) => it.learningobject_hruid)) + ); expect(result.map((it) => it.title)).toContainEqual(EXPECTED_TITLE_FROM_DWENGO_LEARNING_OBJECT); }); diff --git a/backend/tests/services/learning-objects/learning-object-service.test.ts b/backend/tests/services/learning-objects/learning-object-service.test.ts index 3ea4143d..819e6470 100644 --- a/backend/tests/services/learning-objects/learning-object-service.test.ts +++ b/backend/tests/services/learning-objects/learning-object-service.test.ts @@ -1,14 +1,14 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { setupTestApp } from '../../setup-tests'; import { LearningObject } from '../../../src/entities/content/learning-object.entity'; -import { getLearningObjectRepository, getLearningPathRepository } from '../../../src/data/repositories'; -import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; import learningObjectService from '../../../src/services/learning-objects/learning-object-service'; import { envVars, getEnvVar } from '../../../src/util/envVars'; -import { LearningPath } from '../../../src/entities/content/learning-path.entity'; -import learningPathExample from '../../test-assets/learning-paths/pn-werking-example'; -import { LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; +import { LearningObjectIdentifierDTO, LearningPath as LearningPathDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; import { Language } from '@dwengo-1/common/util/language'; +import { testLearningObjectPnNotebooks } from '../../test_assets/content/learning-objects.testdata'; +import { testPartiallyDatabaseAndPartiallyDwengoApiLearningPath } from '../../test_assets/content/learning-paths.testdata'; +import { RequiredEntityData } from '@mikro-orm/core'; +import { getHtmlRenderingForTestLearningObject } from '../../test-utils/get-html-rendering'; const EXPECTED_DWENGO_LEARNING_OBJECT_TITLE = 'Werken met notebooks'; const DWENGO_TEST_LEARNING_OBJECT_ID: LearningObjectIdentifierDTO = { @@ -23,25 +23,20 @@ const DWENGO_TEST_LEARNING_PATH_ID: LearningPathIdentifier = { }; const DWENGO_TEST_LEARNING_PATH_HRUIDS = new Set(['pn_werkingnotebooks', 'pn_werkingnotebooks2', 'pn_werkingnotebooks3']); -async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { - const learningObjectRepo = getLearningObjectRepository(); - const learningPathRepo = getLearningPathRepository(); - const learningObject = learningObjectExample.createLearningObject(); - const learningPath = learningPathExample.createLearningPath(); - await learningObjectRepo.save(learningObject); - await learningPathRepo.save(learningPath); - return { learningObject, learningPath }; -} - describe('LearningObjectService', () => { - let exampleLearningObject: LearningObject; - let exampleLearningPath: LearningPath; + let exampleLearningObject: RequiredEntityData; + let exampleLearningPath: LearningPathDTO; + let exampleLearningPathId: LearningPathIdentifier; beforeAll(async () => { await setupTestApp(); - const exampleData = await initExampleData(); - exampleLearningObject = exampleData.learningObject; - exampleLearningPath = exampleData.learningPath; + exampleLearningObject = testLearningObjectPnNotebooks; + exampleLearningPath = testPartiallyDatabaseAndPartiallyDwengoApiLearningPath; + + exampleLearningPathId = { + hruid: exampleLearningPath.hruid, + language: exampleLearningPath.language as Language, + }; }); describe('getLearningObjectById', () => { @@ -69,7 +64,7 @@ describe('LearningObjectService', () => { const result = await learningObjectService.getLearningObjectHTML(exampleLearningObject); expect(result).not.toBeNull(); // Set newlines so your tests are platform-independent. - expect(result).toEqual(learningObjectExample.getHTMLRendering().replace(/\r\n/g, '\n')); + expect(result).toEqual(getHtmlRenderingForTestLearningObject(exampleLearningObject).replace(/\r\n/g, '\n')); }); it( 'returns the same HTML as the Dwengo API when queried with the identifier of a learning object that does ' + @@ -97,8 +92,8 @@ describe('LearningObjectService', () => { describe('getLearningObjectsFromPath', () => { it('returns all learning objects when a learning path in the database is queried', async () => { - const result = await learningObjectService.getLearningObjectsFromPath(exampleLearningPath); - expect(result.map((it) => it.key)).toEqual(exampleLearningPath.nodes.map((it) => it.learningObjectHruid)); + const result = await learningObjectService.getLearningObjectsFromPath(exampleLearningPathId); + expect(result.map((it) => it.key)).toEqual(exampleLearningPath.nodes.map((it) => it.learningobject_hruid)); }); it('also returns all learning objects when a learning path from the Dwengo API is queried', async () => { const result = await learningObjectService.getLearningObjectsFromPath(DWENGO_TEST_LEARNING_PATH_ID); @@ -115,8 +110,8 @@ describe('LearningObjectService', () => { describe('getLearningObjectIdsFromPath', () => { it('returns all learning objects when a learning path in the database is queried', async () => { - const result = await learningObjectService.getLearningObjectIdsFromPath(exampleLearningPath); - expect(result).toEqual(exampleLearningPath.nodes.map((it) => it.learningObjectHruid)); + const result = await learningObjectService.getLearningObjectIdsFromPath(exampleLearningPathId); + expect(result).toEqual(exampleLearningPath.nodes.map((it) => it.learningobject_hruid)); }); it('also returns all learning object hruids when a learning path from the Dwengo API is queried', async () => { const result = await learningObjectService.getLearningObjectIdsFromPath(DWENGO_TEST_LEARNING_PATH_ID); diff --git a/backend/tests/services/learning-objects/processing/processing-service.test.ts b/backend/tests/services/learning-objects/processing/processing-service.test.ts index 570a014d..1995ce4a 100644 --- a/backend/tests/services/learning-objects/processing/processing-service.test.ts +++ b/backend/tests/services/learning-objects/processing/processing-service.test.ts @@ -1,26 +1,35 @@ -import { describe, expect, it } from 'vitest'; -import mdExample from '../../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; -import multipleChoiceExample from '../../../test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example'; -import essayExample from '../../../test-assets/learning-objects/test-essay/test-essay-example'; +import { beforeAll, describe, expect, it } from 'vitest'; import processingService from '../../../../src/services/learning-objects/processing/processing-service'; +import { + testLearningObjectEssayQuestion, + testLearningObjectMultipleChoice, + testLearningObjectPnNotebooks, +} from '../../../test_assets/content/learning-objects.testdata'; +import { getHtmlRenderingForTestLearningObject } from '../../../test-utils/get-html-rendering'; +import { getLearningObjectRepository } from '../../../../src/data/repositories'; +import { setupTestApp } from '../../../setup-tests'; describe('ProcessingService', () => { + beforeAll(async () => { + await setupTestApp(); + }); + it('renders a markdown learning object correctly', async () => { - const markdownLearningObject = mdExample.createLearningObject(); + const markdownLearningObject = getLearningObjectRepository().create(testLearningObjectPnNotebooks); const result = await processingService.render(markdownLearningObject); // Set newlines so your tests are platform-independent. - expect(result).toEqual(mdExample.getHTMLRendering().replace(/\r\n/g, '\n')); + expect(result).toEqual(getHtmlRenderingForTestLearningObject(markdownLearningObject).replace(/\r\n/g, '\n')); }); it('renders a multiple choice question correctly', async () => { - const multipleChoiceLearningObject = multipleChoiceExample.createLearningObject(); - const result = await processingService.render(multipleChoiceLearningObject); - expect(result).toEqual(multipleChoiceExample.getHTMLRendering().replace(/\r\n/g, '\n')); + const testLearningObject = getLearningObjectRepository().create(testLearningObjectMultipleChoice); + const result = await processingService.render(testLearningObject); + expect(result).toEqual(getHtmlRenderingForTestLearningObject(testLearningObjectMultipleChoice).replace(/\r\n/g, '\n')); }); it('renders an essay question correctly', async () => { - const essayLearningObject = essayExample.createLearningObject(); + const essayLearningObject = getLearningObjectRepository().create(testLearningObjectEssayQuestion); const result = await processingService.render(essayLearningObject); - expect(result).toEqual(essayExample.getHTMLRendering().replace(/\r\n/g, '\n')); + expect(result).toEqual(getHtmlRenderingForTestLearningObject(essayLearningObject).replace(/\r\n/g, '\n')); }); }); diff --git a/backend/tests/services/learning-path/database-learning-path-provider.test.ts b/backend/tests/services/learning-path/database-learning-path-provider.test.ts index 0a0370a3..2cc594d1 100644 --- a/backend/tests/services/learning-path/database-learning-path-provider.test.ts +++ b/backend/tests/services/learning-path/database-learning-path-provider.test.ts @@ -2,227 +2,112 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { LearningObject } from '../../../src/entities/content/learning-object.entity.js'; import { setupTestApp } from '../../setup-tests.js'; import { LearningPath } from '../../../src/entities/content/learning-path.entity.js'; -import { - getAssignmentRepository, - getClassRepository, - getGroupRepository, - getLearningObjectRepository, - getLearningPathRepository, - getStudentRepository, - getSubmissionRepository, -} from '../../../src/data/repositories.js'; -import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.js'; -import learningPathExample from '../../test-assets/learning-paths/pn-werking-example.js'; +import { getSubmissionRepository } from '../../../src/data/repositories.js'; + import databaseLearningPathProvider from '../../../src/services/learning-paths/database-learning-path-provider.js'; import { expectToBeCorrectLearningPath } from '../../test-utils/expectations.js'; -import learningObjectService from '../../../src/services/learning-objects/learning-object-service.js'; import { Language } from '@dwengo-1/common/util/language'; -import { - ConditionTestLearningPathAndLearningObjects, - createConditionTestLearningPathAndLearningObjects, -} from '../../test-assets/learning-paths/test-conditions-example.js'; -import { Student } from '../../../src/entities/users/student.entity.js'; import { LearningObjectNode, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; +import { + testLearningObject01, + testLearningObjectEssayQuestion, + testLearningObjectMultipleChoice, +} from '../../test_assets/content/learning-objects.testdata'; +import { testLearningPathWithConditions } from '../../test_assets/content/learning-paths.testdata'; +import { mapToLearningPath } from '../../../src/services/learning-paths/learning-path-service'; +import { getTestGroup01, getTestGroup02 } from '../../test_assets/assignments/groups.testdata'; +import { Group } from '../../../src/entities/assignments/group.entity.js'; +import { RequiredEntityData } from '@mikro-orm/core'; -const STUDENT_A_USERNAME = 'student_a'; -const STUDENT_B_USERNAME = 'student_b'; -const CLASS_NAME = 'test_class'; - -async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { - const learningObjectRepo = getLearningObjectRepository(); - const learningPathRepo = getLearningPathRepository(); - const learningObject = learningObjectExample.createLearningObject(); - const learningPath = learningPathExample.createLearningPath(); - await learningObjectRepo.save(learningObject); - await learningPathRepo.save(learningPath); - return { learningObject, learningPath }; -} - -async function initPersonalizationTestData(): Promise<{ - learningContent: ConditionTestLearningPathAndLearningObjects; - studentA: Student; - studentB: Student; -}> { - const studentRepo = getStudentRepository(); - const classRepo = getClassRepository(); - const assignmentRepo = getAssignmentRepository(); - const groupRepo = getGroupRepository(); - const submissionRepo = getSubmissionRepository(); - const learningPathRepo = getLearningPathRepository(); - const learningObjectRepo = getLearningObjectRepository(); - const learningContent = createConditionTestLearningPathAndLearningObjects(); - await learningObjectRepo.save(learningContent.branchingObject); - await learningObjectRepo.save(learningContent.finalObject); - await learningObjectRepo.save(learningContent.extraExerciseObject); - await learningPathRepo.save(learningContent.learningPath); - - // Create students - const studentA = studentRepo.create({ - username: STUDENT_A_USERNAME, - firstName: 'Aron', - lastName: 'Student', - }); - await studentRepo.save(studentA); - - const studentB = studentRepo.create({ - username: STUDENT_B_USERNAME, - firstName: 'Bill', - lastName: 'Student', - }); - await studentRepo.save(studentB); - - // Create class for students - const testClass = classRepo.create({ - classId: CLASS_NAME, - displayName: 'Test class', - }); - await classRepo.save(testClass); - - // Create assignment for students and assign them to different groups - const assignment = assignmentRepo.create({ - id: 0, - title: 'Test assignment', - description: 'Test description', - learningPathHruid: learningContent.learningPath.hruid, - learningPathLanguage: learningContent.learningPath.language, - within: testClass, - }); - - const groupA = groupRepo.create({ - groupNumber: 0, - members: [studentA], - assignment, - }); - await groupRepo.save(groupA); - - const groupB = groupRepo.create({ - groupNumber: 1, - members: [studentB], - assignment, - }); - await groupRepo.save(groupB); - - // Let each of the students make a submission in his own group. - const submissionA = submissionRepo.create({ - learningObjectHruid: learningContent.branchingObject.hruid, - learningObjectLanguage: learningContent.branchingObject.language, - learningObjectVersion: learningContent.branchingObject.version, - onBehalfOf: groupA, - submitter: studentA, - submissionTime: new Date(), - content: '[0]', - }); - await submissionRepo.save(submissionA); - - const submissionB = submissionRepo.create({ - learningObjectHruid: learningContent.branchingObject.hruid, - learningObjectLanguage: learningContent.branchingObject.language, - learningObjectVersion: learningContent.branchingObject.version, - onBehalfOf: groupA, - submitter: studentB, - submissionTime: new Date(), - content: '[1]', - }); - await submissionRepo.save(submissionB); - - return { - learningContent: learningContent, - studentA: studentA, - studentB: studentB, - }; -} - -function expectBranchingObjectNode( - result: LearningPathResponse, - persTestData: { - learningContent: ConditionTestLearningPathAndLearningObjects; - studentA: Student; - studentB: Student; - } -): LearningObjectNode { - const branchingObjectMatches = result.data![0].nodes.filter( - (it) => it.learningobject_hruid === persTestData.learningContent.branchingObject.hruid - ); +function expectBranchingObjectNode(result: LearningPathResponse): LearningObjectNode { + const branchingObjectMatches = result.data![0].nodes.filter((it) => it.learningobject_hruid === testLearningObjectMultipleChoice.hruid); expect(branchingObjectMatches.length).toBe(1); return branchingObjectMatches[0]; } describe('DatabaseLearningPathProvider', () => { - let example: { learningObject: LearningObject; learningPath: LearningPath }; - let persTestData: { learningContent: ConditionTestLearningPathAndLearningObjects; studentA: Student; studentB: Student }; + let testLearningPath: LearningPath; + let branchingLearningObject: RequiredEntityData; + let extraExerciseLearningObject: RequiredEntityData; + let finalLearningObject: RequiredEntityData; + let groupA: Group; + let groupB: Group; beforeAll(async () => { await setupTestApp(); - example = await initExampleData(); - persTestData = await initPersonalizationTestData(); + testLearningPath = mapToLearningPath(testLearningPathWithConditions, []); + branchingLearningObject = testLearningObjectMultipleChoice; + extraExerciseLearningObject = testLearningObject01; + finalLearningObject = testLearningObjectEssayQuestion; + groupA = getTestGroup01(); + groupB = getTestGroup02(); + + // Place different submissions for group A and B. + const submissionRepo = getSubmissionRepository(); + const submissionA = submissionRepo.create({ + learningObjectHruid: branchingLearningObject.hruid, + learningObjectLanguage: branchingLearningObject.language, + learningObjectVersion: branchingLearningObject.version, + content: '[0]', + onBehalfOf: groupA, + submissionTime: new Date(), + submitter: groupA.members[0], + }); + await submissionRepo.save(submissionA); + + const submissionB = submissionRepo.create({ + learningObjectHruid: branchingLearningObject.hruid, + learningObjectLanguage: branchingLearningObject.language, + learningObjectVersion: branchingLearningObject.version, + content: '[1]', + onBehalfOf: groupB, + submissionTime: new Date(), + submitter: groupB.members[0], + }); + await submissionRepo.save(submissionB); }); describe('fetchLearningPaths', () => { it('returns the learning path correctly', async () => { - const result = await databaseLearningPathProvider.fetchLearningPaths( - [example.learningPath.hruid], - example.learningPath.language, - 'the source' - ); + const result = await databaseLearningPathProvider.fetchLearningPaths([testLearningPath.hruid], testLearningPath.language, 'the source'); expect(result.success).toBe(true); expect(result.data?.length).toBe(1); - const learningObjectsOnPath = ( - await Promise.all( - example.learningPath.nodes.map(async (node) => - learningObjectService.getLearningObjectById({ - hruid: node.learningObjectHruid, - version: node.version, - language: node.language, - }) - ) - ) - ).filter((it) => it !== null); - - expectToBeCorrectLearningPath(result.data![0], example.learningPath, learningObjectsOnPath); + expectToBeCorrectLearningPath(result.data![0], testLearningPathWithConditions); }); it('returns the correct personalized learning path', async () => { // For student A: let result = await databaseLearningPathProvider.fetchLearningPaths( - [persTestData.learningContent.learningPath.hruid], - persTestData.learningContent.learningPath.language, + [testLearningPath.hruid], + testLearningPath.language, 'the source', - { type: 'student', student: persTestData.studentA } + groupA ); expect(result.success).toBeTruthy(); expect(result.data?.length).toBe(1); // There should be exactly one branching object - let branchingObject = expectBranchingObjectNode(result, persTestData); + let branchingObject = expectBranchingObjectNode(result); - expect(branchingObject.transitions.filter((it) => it.next.hruid === persTestData.learningContent.finalObject.hruid).length).toBe(0); // StudentA picked the first option, therefore, there should be no direct path to the final object. - expect(branchingObject.transitions.filter((it) => it.next.hruid === persTestData.learningContent.extraExerciseObject.hruid).length).toBe( - 1 - ); // There should however be a path to the extra exercise object. + expect(branchingObject.transitions.filter((it) => it.next.hruid === finalLearningObject.hruid).length).toBe(0); // StudentA picked the first option, therefore, there should be no direct path to the final object. + expect(branchingObject.transitions.filter((it) => it.next.hruid === extraExerciseLearningObject.hruid).length).toBe(1); // There should however be a path to the extra exercise object. // For student B: - result = await databaseLearningPathProvider.fetchLearningPaths( - [persTestData.learningContent.learningPath.hruid], - persTestData.learningContent.learningPath.language, - 'the source', - { type: 'student', student: persTestData.studentB } - ); + result = await databaseLearningPathProvider.fetchLearningPaths([testLearningPath.hruid], testLearningPath.language, 'the source', groupB); expect(result.success).toBeTruthy(); expect(result.data?.length).toBe(1); // There should still be exactly one branching object - branchingObject = expectBranchingObjectNode(result, persTestData); + branchingObject = expectBranchingObjectNode(result); // However, now the student picks the other option. - expect(branchingObject.transitions.filter((it) => it.next.hruid === persTestData.learningContent.finalObject.hruid).length).toBe(1); // StudentB picked the second option, therefore, there should be a direct path to the final object. - expect(branchingObject.transitions.filter((it) => it.next.hruid === persTestData.learningContent.extraExerciseObject.hruid).length).toBe( - 0 - ); // There should not be a path anymore to the extra exercise object. + expect(branchingObject.transitions.filter((it) => it.next.hruid === finalLearningObject.hruid).length).toBe(1); // StudentB picked the second option, therefore, there should be a direct path to the final object. + expect(branchingObject.transitions.filter((it) => it.next.hruid === extraExerciseLearningObject.hruid).length).toBe(0); // There should not be a path anymore to the extra exercise object. }); it('returns a non-successful response if a non-existing learning path is queried', async () => { const result = await databaseLearningPathProvider.fetchLearningPaths( - [example.learningPath.hruid], + [testLearningPath.hruid], Language.Abkhazian, // Wrong language 'the source' ); @@ -233,27 +118,24 @@ describe('DatabaseLearningPathProvider', () => { describe('searchLearningPaths', () => { it('returns the correct learning path when queried with a substring of its title', async () => { - const result = await databaseLearningPathProvider.searchLearningPaths( - example.learningPath.title.substring(2, 6), - example.learningPath.language - ); + const result = await databaseLearningPathProvider.searchLearningPaths(testLearningPath.title.substring(2, 6), testLearningPath.language); expect(result.length).toBe(1); - expect(result[0].title).toBe(example.learningPath.title); - expect(result[0].description).toBe(example.learningPath.description); + expect(result[0].title).toBe(testLearningPath.title); + expect(result[0].description).toBe(testLearningPath.description); }); it('returns the correct learning path when queried with a substring of the description', async () => { const result = await databaseLearningPathProvider.searchLearningPaths( - example.learningPath.description.substring(5, 12), - example.learningPath.language + testLearningPath.description.substring(5, 12), + testLearningPath.language ); expect(result.length).toBe(1); - expect(result[0].title).toBe(example.learningPath.title); - expect(result[0].description).toBe(example.learningPath.description); + expect(result[0].title).toBe(testLearningPath.title); + expect(result[0].description).toBe(testLearningPath.description); }); it('returns an empty result when queried with a text which is not a substring of the title or the description of a learning path', async () => { const result = await databaseLearningPathProvider.searchLearningPaths( 'substring which does not occur in the title or the description of a learning object', - example.learningPath.language + testLearningPath.language ); expect(result.length).toBe(0); }); diff --git a/backend/tests/services/learning-path/learning-path-service.test.ts b/backend/tests/services/learning-path/learning-path-service.test.ts index 972a7fa1..ae4d9e99 100644 --- a/backend/tests/services/learning-path/learning-path-service.test.ts +++ b/backend/tests/services/learning-path/learning-path-service.test.ts @@ -1,22 +1,9 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { setupTestApp } from '../../setup-tests'; -import { LearningObject } from '../../../src/entities/content/learning-object.entity'; -import { LearningPath } from '../../../src/entities/content/learning-path.entity'; -import { getLearningObjectRepository, getLearningPathRepository } from '../../../src/data/repositories'; -import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; -import learningPathExample from '../../test-assets/learning-paths/pn-werking-example'; import learningPathService from '../../../src/services/learning-paths/learning-path-service'; import { Language } from '@dwengo-1/common/util/language'; - -async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { - const learningObjectRepo = getLearningObjectRepository(); - const learningPathRepo = getLearningPathRepository(); - const learningObject = learningObjectExample.createLearningObject(); - const learningPath = learningPathExample.createLearningPath(); - await learningObjectRepo.save(learningObject); - await learningPathRepo.save(learningPath); - return { learningObject, learningPath }; -} +import { testPartiallyDatabaseAndPartiallyDwengoApiLearningPath } from '../../test_assets/content/learning-paths.testdata'; +import { LearningPath as LearningPathDTO } from '@dwengo-1/common/interfaces/learning-content'; const TEST_DWENGO_LEARNING_PATH_HRUID = 'pn_werking'; const TEST_DWENGO_LEARNING_PATH_TITLE = 'Werken met notebooks'; @@ -24,42 +11,49 @@ const TEST_DWENGO_EXCLUSIVE_LEARNING_PATH_SEARCH_QUERY = 'Microscopie'; const TEST_SEARCH_QUERY_EXPECTING_NO_MATCHES = 'su$m8f9usf89ud { - let example: { learningObject: LearningObject; learningPath: LearningPath }; + let testLearningPath: LearningPathDTO; beforeAll(async () => { await setupTestApp(); - example = await initExampleData(); + testLearningPath = testPartiallyDatabaseAndPartiallyDwengoApiLearningPath; }); describe('fetchLearningPaths', () => { it('should return learning paths both from the database and from the Dwengo API', async () => { const result = await learningPathService.fetchLearningPaths( - [example.learningPath.hruid, TEST_DWENGO_LEARNING_PATH_HRUID], - example.learningPath.language, + [testLearningPath.hruid, TEST_DWENGO_LEARNING_PATH_HRUID], + testLearningPath.language as Language, 'the source' ); expect(result.success).toBeTruthy(); expect(result.data?.filter((it) => it.hruid === TEST_DWENGO_LEARNING_PATH_HRUID).length).not.toBe(0); - expect(result.data?.filter((it) => it.hruid === example.learningPath.hruid).length).not.toBe(0); + expect(result.data?.filter((it) => it.hruid === testLearningPath.hruid).length).not.toBe(0); expect(result.data?.find((it) => it.hruid === TEST_DWENGO_LEARNING_PATH_HRUID)?.title).toEqual(TEST_DWENGO_LEARNING_PATH_TITLE); - expect(result.data?.find((it) => it.hruid === example.learningPath.hruid)?.title).toEqual(example.learningPath.title); + expect(result.data?.find((it) => it.hruid === testLearningPath.hruid)?.title).toEqual(testLearningPath.title); }); it('should include both the learning objects from the Dwengo API and learning objects from the database in its response', async () => { - const result = await learningPathService.fetchLearningPaths([example.learningPath.hruid], example.learningPath.language, 'the source'); + const result = await learningPathService.fetchLearningPaths( + [testLearningPath.hruid], + testLearningPath.language as Language, + 'the source' + ); expect(result.success).toBeTruthy(); expect(result.data?.length).toBe(1); // Should include all the nodes, even those pointing to foreign learning objects. expect([...result.data![0].nodes.map((it) => it.learningobject_hruid)].sort((a, b) => a.localeCompare(b))).toEqual( - example.learningPath.nodes.map((it) => it.learningObjectHruid).sort((a, b) => a.localeCompare(b)) + testLearningPath.nodes.map((it) => it.learningobject_hruid).sort((a, b) => a.localeCompare(b)) ); }); }); describe('searchLearningPath', () => { it('should include both the learning paths from the Dwengo API and those from the database in its response', async () => { // This matches the learning object in the database, but definitely also some learning objects in the Dwengo API. - const result = await learningPathService.searchLearningPaths(example.learningPath.title.substring(2, 3), example.learningPath.language); + const result = await learningPathService.searchLearningPaths( + testLearningPath.title.substring(2, 3), + testLearningPath.language as Language + ); // Should find the one from the database - expect(result.filter((it) => it.hruid === example.learningPath.hruid && it.title === example.learningPath.title).length).toBe(1); + expect(result.filter((it) => it.hruid === testLearningPath.hruid && it.title === testLearningPath.title).length).toBe(1); // But should not only find that one. expect(result.length).not.toBeLessThan(2); @@ -71,7 +65,7 @@ describe('LearningPathService', () => { expect(result.length).not.toBe(0); // But not the example learning path. - expect(result.filter((it) => it.hruid === example.learningPath.hruid && it.title === example.learningPath.title).length).toBe(0); + expect(result.filter((it) => it.hruid === testLearningPath.hruid && it.title === testLearningPath.title).length).toBe(0); }); it('should return an empty list if neither the Dwengo API nor the database contains matches', async () => { const result = await learningPathService.searchLearningPaths(TEST_SEARCH_QUERY_EXPECTING_NO_MATCHES, Language.Dutch); diff --git a/backend/tests/setup-tests.ts b/backend/tests/setup-tests.ts index 699e081b..23fc43ee 100644 --- a/backend/tests/setup-tests.ts +++ b/backend/tests/setup-tests.ts @@ -14,6 +14,7 @@ import { makeTestQuestions } from './test_assets/questions/questions.testdata.js import { makeTestAnswers } from './test_assets/questions/answers.testdata.js'; import { makeTestSubmissions } from './test_assets/assignments/submission.testdata.js'; import { Collection } from '@mikro-orm/core'; +import { Group } from '../src/entities/assignments/group.entity'; export async function setupTestApp(): Promise { dotenv.config({ path: '.env.test' }); @@ -29,8 +30,8 @@ export async function setupTestApp(): Promise { const assignments = makeTestAssignemnts(em, classes); const groups = makeTestGroups(em, students, assignments); - assignments[0].groups = new Collection(groups.slice(0, 3)); - assignments[1].groups = new Collection(groups.slice(3, 4)); + assignments[0].groups = new Collection(groups.slice(0, 3)); + assignments[1].groups = new Collection(groups.slice(3, 4)); const teacherInvitations = makeTestTeacherInvitations(em, teachers, classes); const classJoinRequests = makeTestClassJoinRequests(em, students, classes); diff --git a/backend/tests/test-assets/learning-objects/create-example-learning-object-with-attachments.ts b/backend/tests/test-assets/learning-objects/create-example-learning-object-with-attachments.ts deleted file mode 100644 index 9bd0b4c3..00000000 --- a/backend/tests/test-assets/learning-objects/create-example-learning-object-with-attachments.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { LearningObjectExample } from './learning-object-example'; -import { LearningObject } from '../../../src/entities/content/learning-object.entity'; - -export function createExampleLearningObjectWithAttachments(example: LearningObjectExample): LearningObject { - const learningObject = example.createLearningObject(); - for (const creationFn of Object.values(example.createAttachment)) { - learningObject.attachments.push(creationFn(learningObject)); - } - return learningObject; -} diff --git a/backend/tests/test-assets/learning-objects/dummy/dummy-learning-object-example.ts b/backend/tests/test-assets/learning-objects/dummy/dummy-learning-object-example.ts deleted file mode 100644 index 6889c93b..00000000 --- a/backend/tests/test-assets/learning-objects/dummy/dummy-learning-object-example.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { LearningObjectExample } from '../learning-object-example'; -import { LearningObject } from '../../../../src/entities/content/learning-object.entity'; -import { Language } from '@dwengo-1/common/util/language'; -import { loadTestAsset } from '../../../test-utils/load-test-asset'; -import { DwengoContentType } from '../../../../src/services/learning-objects/processing/content-type'; -import { envVars, getEnvVar } from '../../../../src/util/envVars'; - -/** - * Create a dummy learning object to be used in tests where multiple learning objects are needed (for example for use - * on a path), but where the precise contents of the learning object are not important. - */ -export function dummyLearningObject(hruid: string, language: Language, title: string): LearningObjectExample { - return { - createLearningObject: (): LearningObject => { - const learningObject = new LearningObject(); - learningObject.hruid = getEnvVar(envVars.UserContentPrefix) + hruid; - learningObject.language = language; - learningObject.version = 1; - learningObject.title = title; - learningObject.description = 'Just a dummy learning object for testing purposes'; - learningObject.contentType = DwengoContentType.TEXT_PLAIN; - learningObject.content = Buffer.from('Dummy content'); - learningObject.returnValue = { - callbackUrl: `/learningObject/${hruid}/submissions`, - callbackSchema: '[]', - }; - return learningObject; - }, - createAttachment: {}, - getHTMLRendering: () => loadTestAsset('learning-objects/dummy/rendering.txt').toString(), - }; -} diff --git a/backend/tests/test-assets/learning-objects/learning-object-example.d.ts b/backend/tests/test-assets/learning-objects/learning-object-example.d.ts deleted file mode 100644 index 3054644f..00000000 --- a/backend/tests/test-assets/learning-objects/learning-object-example.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { LearningObject } from '../../../src/entities/content/learning-object.entity'; -import { Attachment } from '../../../src/entities/content/attachment.entity'; - -interface LearningObjectExample { - createLearningObject: () => LearningObject; - createAttachment: Record Attachment>; - getHTMLRendering: () => string; -} diff --git a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.ts b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.ts deleted file mode 100644 index ab0c8640..00000000 --- a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { LearningObjectExample } from '../learning-object-example'; -import { Language } from '@dwengo-1/common/util/language'; -import { DwengoContentType } from '../../../../src/services/learning-objects/processing/content-type'; -import { loadTestAsset } from '../../../test-utils/load-test-asset'; -import { LearningObject } from '../../../../src/entities/content/learning-object.entity'; -import { Attachment } from '../../../../src/entities/content/attachment.entity'; -import { envVars, getEnvVar } from '../../../../src/util/envVars'; -import { EducationalGoal } from '../../../../src/entities/content/educational-goal.entity'; -import { ReturnValue } from '../../../../src/entities/content/return-value.entity'; - -const ASSETS_PREFIX = 'learning-objects/pn-werkingnotebooks/'; - -const example: LearningObjectExample = { - createLearningObject: () => { - const learningObject = new LearningObject(); - learningObject.hruid = `${getEnvVar(envVars.UserContentPrefix)}pn_werkingnotebooks`; - learningObject.version = 3; - learningObject.language = Language.Dutch; - learningObject.title = 'Werken met notebooks'; - learningObject.description = 'Leren werken met notebooks'; - learningObject.keywords = ['Python', 'KIKS', 'Wiskunde', 'STEM', 'AI']; - - const educationalGoal1 = new EducationalGoal(); - educationalGoal1.source = 'Source'; - educationalGoal1.id = 'id'; - - const educationalGoal2 = new EducationalGoal(); - educationalGoal2.source = 'Source2'; - educationalGoal2.id = 'id2'; - - learningObject.educationalGoals = [educationalGoal1, educationalGoal2]; - learningObject.admins = []; - learningObject.contentType = DwengoContentType.TEXT_MARKDOWN; - learningObject.teacherExclusive = false; - learningObject.skosConcepts = [ - 'http://ilearn.ilabt.imec.be/vocab/curr1/s-vaktaal', - 'http://ilearn.ilabt.imec.be/vocab/curr1/s-digitale-media-en-toepassingen', - 'http://ilearn.ilabt.imec.be/vocab/curr1/s-computers-en-systemen', - ]; - learningObject.copyright = 'dwengo'; - learningObject.license = 'dwengo'; - learningObject.estimatedTime = 10; - - const returnValue = new ReturnValue(); - returnValue.callbackUrl = 'callback_url_example'; - returnValue.callbackSchema = '{"att": "test", "att2": "test2"}'; - - learningObject.returnValue = returnValue; - learningObject.available = true; - learningObject.content = loadTestAsset(`${ASSETS_PREFIX}/content.md`); - - return learningObject; - }, - createAttachment: { - dwengoLogo: (learningObject) => { - const att = new Attachment(); - att.learningObject = learningObject; - att.name = 'dwengo.png'; - att.mimeType = 'image/png'; - att.content = loadTestAsset(`${ASSETS_PREFIX}/dwengo.png`); - return att; - }, - knop: (learningObject) => { - const att = new Attachment(); - att.learningObject = learningObject; - att.name = 'Knop.png'; - att.mimeType = 'image/png'; - att.content = loadTestAsset(`${ASSETS_PREFIX}/Knop.png`); - return att; - }, - }, - getHTMLRendering: () => loadTestAsset(`${ASSETS_PREFIX}/rendering.txt`).toString(), -}; -export default example; diff --git a/backend/tests/test-assets/learning-objects/test-essay/content.txt b/backend/tests/test-assets/learning-objects/test-essay/content.txt deleted file mode 100644 index dd6b5c77..00000000 --- a/backend/tests/test-assets/learning-objects/test-essay/content.txt +++ /dev/null @@ -1,2 +0,0 @@ -::MC basic:: -How are you? {} diff --git a/backend/tests/test-assets/learning-objects/test-essay/rendering.txt b/backend/tests/test-assets/learning-objects/test-essay/rendering.txt deleted file mode 100644 index adb072a0..00000000 --- a/backend/tests/test-assets/learning-objects/test-essay/rendering.txt +++ /dev/null @@ -1,7 +0,0 @@ -
-
-

MC basic

-

How are you?

- -
-
diff --git a/backend/tests/test-assets/learning-objects/test-essay/test-essay-example.ts b/backend/tests/test-assets/learning-objects/test-essay/test-essay-example.ts deleted file mode 100644 index 5a444fc0..00000000 --- a/backend/tests/test-assets/learning-objects/test-essay/test-essay-example.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { LearningObjectExample } from '../learning-object-example'; -import { LearningObject } from '../../../../src/entities/content/learning-object.entity'; -import { loadTestAsset } from '../../../test-utils/load-test-asset'; -import { envVars, getEnvVar } from '../../../../src/util/envVars'; -import { Language } from '@dwengo-1/common/util/language'; -import { DwengoContentType } from '../../../../src/services/learning-objects/processing/content-type'; - -const example: LearningObjectExample = { - createLearningObject: () => { - const learningObject = new LearningObject(); - learningObject.hruid = `${getEnvVar(envVars.UserContentPrefix)}test_essay`; - learningObject.language = Language.English; - learningObject.version = 1; - learningObject.title = 'Essay question for testing'; - learningObject.description = 'This essay question was only created for testing purposes.'; - learningObject.contentType = DwengoContentType.GIFT; - learningObject.returnValue = { - callbackUrl: `/learningObject/${learningObject.hruid}/submissions`, - callbackSchema: '["antwoord vraag 1"]', - }; - learningObject.content = loadTestAsset('learning-objects/test-essay/content.txt'); - return learningObject; - }, - createAttachment: {}, - getHTMLRendering: () => loadTestAsset('learning-objects/test-essay/rendering.txt').toString(), -}; - -export default example; diff --git a/backend/tests/test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example.ts b/backend/tests/test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example.ts deleted file mode 100644 index 129665ae..00000000 --- a/backend/tests/test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { LearningObjectExample } from '../learning-object-example'; -import { LearningObject } from '../../../../src/entities/content/learning-object.entity'; -import { loadTestAsset } from '../../../test-utils/load-test-asset'; -import { envVars, getEnvVar } from '../../../../src/util/envVars'; -import { DwengoContentType } from '../../../../src/services/learning-objects/processing/content-type'; -import { Language } from '@dwengo-1/common/util/language'; - -const example: LearningObjectExample = { - createLearningObject: () => { - const learningObject = new LearningObject(); - learningObject.hruid = `${getEnvVar(envVars.UserContentPrefix)}test_multiple_choice`; - learningObject.language = Language.English; - learningObject.version = 1; - learningObject.title = 'Multiple choice question for testing'; - learningObject.description = 'This multiple choice question was only created for testing purposes.'; - learningObject.contentType = DwengoContentType.GIFT; - learningObject.returnValue = { - callbackUrl: `/learningObject/${learningObject.hruid}/submissions`, - callbackSchema: '["antwoord vraag 1"]', - }; - learningObject.content = loadTestAsset('learning-objects/test-multiple-choice/content.txt'); - return learningObject; - }, - createAttachment: {}, - getHTMLRendering: () => loadTestAsset('learning-objects/test-multiple-choice/rendering.txt').toString(), -}; - -export default example; diff --git a/backend/tests/test-assets/learning-paths/learning-path-example.d.ts b/backend/tests/test-assets/learning-paths/learning-path-example.d.ts deleted file mode 100644 index d8e94dc8..00000000 --- a/backend/tests/test-assets/learning-paths/learning-path-example.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -interface LearningPathExample { - createLearningPath: () => LearningPath; -} diff --git a/backend/tests/test-assets/learning-paths/learning-path-utils.ts b/backend/tests/test-assets/learning-paths/learning-path-utils.ts deleted file mode 100644 index 177d905f..00000000 --- a/backend/tests/test-assets/learning-paths/learning-path-utils.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Language } from '@dwengo-1/common/util/language'; -import { LearningPathTransition } from '../../../src/entities/content/learning-path-transition.entity'; -import { LearningPathNode } from '../../../src/entities/content/learning-path-node.entity'; -import { LearningPath } from '../../../src/entities/content/learning-path.entity'; - -export function createLearningPathTransition( - node: LearningPathNode, - transitionNumber: number, - condition: string | null, - to: LearningPathNode -): LearningPathTransition { - const trans = new LearningPathTransition(); - trans.node = node; - trans.transitionNumber = transitionNumber; - trans.condition = condition || 'true'; - trans.next = to; - return trans; -} - -export function createLearningPathNode( - learningPath: LearningPath, - nodeNumber: number, - learningObjectHruid: string, - version: number, - language: Language, - startNode: boolean -): LearningPathNode { - const node = new LearningPathNode(); - node.learningPath = learningPath; - node.nodeNumber = nodeNumber; - node.learningObjectHruid = learningObjectHruid; - node.version = version; - node.language = language; - node.startNode = startNode; - return node; -} diff --git a/backend/tests/test-assets/learning-paths/pn-werking-example.ts b/backend/tests/test-assets/learning-paths/pn-werking-example.ts deleted file mode 100644 index 1ac1c40d..00000000 --- a/backend/tests/test-assets/learning-paths/pn-werking-example.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { LearningPath } from '../../../src/entities/content/learning-path.entity'; -import { Language } from '@dwengo-1/common/util/language'; -import { envVars, getEnvVar } from '../../../src/util/envVars'; -import { createLearningPathNode, createLearningPathTransition } from './learning-path-utils'; -import { LearningPathNode } from '../../../src/entities/content/learning-path-node.entity'; - -function createNodes(learningPath: LearningPath): LearningPathNode[] { - const nodes = [ - createLearningPathNode(learningPath, 0, 'u_pn_werkingnotebooks', 3, Language.Dutch, true), - createLearningPathNode(learningPath, 1, 'pn_werkingnotebooks2', 3, Language.Dutch, false), - createLearningPathNode(learningPath, 2, 'pn_werkingnotebooks3', 3, Language.Dutch, false), - ]; - nodes[0].transitions.push(createLearningPathTransition(nodes[0], 0, 'true', nodes[1])); - nodes[1].transitions.push(createLearningPathTransition(nodes[1], 0, 'true', nodes[2])); - return nodes; -} - -const example: LearningPathExample = { - createLearningPath: () => { - const path = new LearningPath(); - path.language = Language.Dutch; - path.hruid = `${getEnvVar(envVars.UserContentPrefix)}pn_werking`; - path.title = 'Werken met notebooks'; - path.description = 'Een korte inleiding tot Python notebooks. Hoe ga je gemakkelijk en efficiënt met de notebooks aan de slag?'; - path.nodes = createNodes(path); - return path; - }, -}; - -export default example; diff --git a/backend/tests/test-assets/learning-paths/test-conditions-example.ts b/backend/tests/test-assets/learning-paths/test-conditions-example.ts deleted file mode 100644 index 0fb7ead5..00000000 --- a/backend/tests/test-assets/learning-paths/test-conditions-example.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { LearningPath } from '../../../src/entities/content/learning-path.entity'; -import { Language } from '@dwengo-1/common/util/language'; -import testMultipleChoiceExample from '../learning-objects/test-multiple-choice/test-multiple-choice-example'; -import { dummyLearningObject } from '../learning-objects/dummy/dummy-learning-object-example'; -import { createLearningPathNode, createLearningPathTransition } from './learning-path-utils'; -import { LearningObject } from '../../../src/entities/content/learning-object.entity'; -import { envVars, getEnvVar } from '../../../src/util/envVars'; - -export interface ConditionTestLearningPathAndLearningObjects { - branchingObject: LearningObject; - extraExerciseObject: LearningObject; - finalObject: LearningObject; - learningPath: LearningPath; -} - -export function createConditionTestLearningPathAndLearningObjects(): ConditionTestLearningPathAndLearningObjects { - const learningPath = new LearningPath(); - learningPath.hruid = `${getEnvVar(envVars.UserContentPrefix)}test_conditions`; - learningPath.language = Language.English; - learningPath.title = 'Example learning path with conditional transitions'; - learningPath.description = 'This learning path was made for the purpose of testing conditional transitions'; - - const branchingLearningObject = testMultipleChoiceExample.createLearningObject(); - const extraExerciseLearningObject = dummyLearningObject( - 'test_extra_exercise', - Language.English, - 'Extra exercise (for students with difficulties)' - ).createLearningObject(); - const finalLearningObject = dummyLearningObject( - 'test_final_learning_object', - Language.English, - 'Final exercise (for everyone)' - ).createLearningObject(); - - const branchingNode = createLearningPathNode( - learningPath, - 0, - branchingLearningObject.hruid, - branchingLearningObject.version, - branchingLearningObject.language, - true - ); - const extraExerciseNode = createLearningPathNode( - learningPath, - 1, - extraExerciseLearningObject.hruid, - extraExerciseLearningObject.version, - extraExerciseLearningObject.language, - false - ); - const finalNode = createLearningPathNode( - learningPath, - 2, - finalLearningObject.hruid, - finalLearningObject.version, - finalLearningObject.language, - false - ); - - const transitionToExtraExercise = createLearningPathTransition( - branchingNode, - 0, - '$[?(@[0] == 0)]', // The answer to the first question was the first one, which says that it is difficult for the student to follow along. - extraExerciseNode - ); - const directTransitionToFinal = createLearningPathTransition(branchingNode, 1, '$[?(@[0] == 1)]', finalNode); - const transitionExtraExerciseToFinal = createLearningPathTransition(extraExerciseNode, 0, 'true', finalNode); - - branchingNode.transitions = [transitionToExtraExercise, directTransitionToFinal]; - extraExerciseNode.transitions = [transitionExtraExerciseToFinal]; - - learningPath.nodes = [branchingNode, extraExerciseNode, finalNode]; - - return { - branchingObject: branchingLearningObject, - finalObject: finalLearningObject, - extraExerciseObject: extraExerciseLearningObject, - learningPath: learningPath, - }; -} diff --git a/backend/tests/test-utils/expectations.ts b/backend/tests/test-utils/expectations.ts index b6462702..6579be16 100644 --- a/backend/tests/test-utils/expectations.ts +++ b/backend/tests/test-utils/expectations.ts @@ -1,8 +1,8 @@ import { AssertionError } from 'node:assert'; import { LearningObject } from '../../src/entities/content/learning-object.entity'; -import { LearningPath as LearningPathEntity } from '../../src/entities/content/learning-path.entity'; import { expect } from 'vitest'; import { FilteredLearningObject, LearningPath } from '@dwengo-1/common/interfaces/learning-content'; +import { RequiredEntityData } from '@mikro-orm/core'; // Ignored properties because they belang for example to the class, not to the entity itself. const IGNORE_PROPERTIES = ['parent']; @@ -11,53 +11,44 @@ const IGNORE_PROPERTIES = ['parent']; * Checks if the actual entity from the database conforms to the entity that was added previously. * @param actual The actual entity retrieved from the database * @param expected The (previously added) entity we would expect to retrieve + * @param propertyPrefix Prefix to append to property in error messages. */ -export function expectToBeCorrectEntity(actual: { entity: T; name?: string }, expected: { entity: T; name?: string }): void { - if (!actual.name) { - actual.name = 'actual'; - } - if (!expected.name) { - expected.name = 'expected'; - } - for (const property in expected.entity) { - if ( - property in IGNORE_PROPERTIES && - expected.entity[property] !== undefined && // If we don't expect a certain value for a property, we assume it can be filled in by the database however it wants. - typeof expected.entity[property] !== 'function' // Functions obviously are not persisted via the database - ) { - if (!Object.prototype.hasOwnProperty.call(actual.entity, property)) { - throw new AssertionError({ - message: `${expected.name} has defined property ${property}, but ${actual.name} is missing it.`, - }); - } - if (typeof expected.entity[property] === 'boolean') { - // Sometimes, booleans get represented by numbers 0 and 1 in the objects actual from the database. - if (Boolean(expected.entity[property]) !== Boolean(actual.entity[property])) { +export function expectToBeCorrectEntity(actual: T, expected: T, propertyPrefix = ''): void { + for (const property in expected) { + if (Object.prototype.hasOwnProperty.call(expected, property)) { + const prefixedProperty = propertyPrefix + property; + if ( + property in IGNORE_PROPERTIES && + expected[property] !== undefined && // If we don't expect a certain value for a property, we assume it can be filled in by the database however it wants. + typeof expected[property] !== 'function' // Functions obviously are not persisted via the database + ) { + if (!Object.prototype.hasOwnProperty.call(actual, property)) { throw new AssertionError({ - message: `${property} was ${expected.entity[property]} in ${expected.name}, - but ${actual.entity[property]} (${Boolean(expected.entity[property])}) in ${actual.name}`, + message: `Expected property ${prefixedProperty}, but it is missing.`, }); } - } else if (typeof expected.entity[property] !== typeof actual.entity[property]) { - throw new AssertionError({ - message: `${property} has type ${typeof expected.entity[property]} in ${expected.name}, but type ${typeof actual.entity[property]} in ${actual.name}.`, - }); - } else if (typeof expected.entity[property] === 'object') { - expectToBeCorrectEntity( - { - name: actual.name + '.' + property, - entity: actual.entity[property] as object, - }, - { - name: expected.name + '.' + property, - entity: expected.entity[property] as object, + if (typeof expected[property] === 'boolean') { + // Sometimes, booleans get represented by numbers 0 and 1 in the objects actual from the database. + if (Boolean(expected[property]) !== Boolean(actual[property])) { + throw new AssertionError({ + message: `Expected ${prefixedProperty} to be ${expected[property]}, + but was ${actual[property]} (${Boolean(expected[property])}).`, + }); } - ); - } else { - if (expected.entity[property] !== actual.entity[property]) { + } else if (typeof expected[property] !== typeof actual[property]) { throw new AssertionError({ - message: `${property} was ${expected.entity[property]} in ${expected.name}, but ${actual.entity[property]} in ${actual.name}`, + message: + `${prefixedProperty} was expected to have type ${typeof expected[property]},` + + `but had type ${typeof actual[property]}.`, }); + } else if (typeof expected[property] === 'object') { + expectToBeCorrectEntity(actual[property] as object, expected[property] as object, property); + } else { + if (expected[property] !== actual[property]) { + throw new AssertionError({ + message: `${prefixedProperty} was expected to be ${expected[property]}, ` + `but was ${actual[property]}.`, + }); + } } } } @@ -67,9 +58,9 @@ export function expectToBeCorrectEntity(actual: { entity: T; n /** * Checks that filtered is the correct representation of original as FilteredLearningObject. * @param filtered the representation as FilteredLearningObject - * @param original the original entity added to the database + * @param original the data of the entity in the database that was filtered. */ -export function expectToBeCorrectFilteredLearningObject(filtered: FilteredLearningObject, original: LearningObject): void { +export function expectToBeCorrectFilteredLearningObject(filtered: FilteredLearningObject, original: RequiredEntityData): void { expect(filtered.uuid).toEqual(original.uuid); expect(filtered.version).toEqual(original.version); expect(filtered.language).toEqual(original.language); @@ -97,54 +88,55 @@ export function expectToBeCorrectFilteredLearningObject(filtered: FilteredLearni * is a correct representation of the given learning path entity. * * @param learningPath The learning path returned by the retriever, service or endpoint - * @param expectedEntity The expected entity - * @param learningObjectsOnPath The learning objects on LearningPath. Necessary since some information in - * the learning path returned from the API endpoint + * @param expected The learning path that should have been returned. */ -export function expectToBeCorrectLearningPath( - learningPath: LearningPath, - expectedEntity: LearningPathEntity, - learningObjectsOnPath: FilteredLearningObject[] -): void { - expect(learningPath.hruid).toEqual(expectedEntity.hruid); - expect(learningPath.language).toEqual(expectedEntity.language); - expect(learningPath.description).toEqual(expectedEntity.description); - expect(learningPath.title).toEqual(expectedEntity.title); +export function expectToBeCorrectLearningPath(learningPath: LearningPath, expected: LearningPath): void { + expect(learningPath.hruid).toEqual(expected.hruid); + expect(learningPath.language).toEqual(expected.language); + expect(learningPath.description).toEqual(expected.description); + expect(learningPath.title).toEqual(expected.title); - const keywords = new Set(learningObjectsOnPath.flatMap((it) => it.keywords || [])); - expect(new Set(learningPath.keywords.split(' '))).toEqual(keywords); + expect(new Set(learningPath.keywords.split(' '))).toEqual(new Set(learningPath.keywords.split(' '))); - const targetAges = new Set(learningObjectsOnPath.flatMap((it) => it.targetAges || [])); - expect(new Set(learningPath.target_ages)).toEqual(targetAges); - expect(learningPath.min_age).toEqual(Math.min(...targetAges)); - expect(learningPath.max_age).toEqual(Math.max(...targetAges)); + expect(new Set(learningPath.target_ages)).toEqual(new Set(expected.target_ages)); + expect(learningPath.min_age).toEqual(Math.min(...expected.target_ages)); + expect(learningPath.max_age).toEqual(Math.max(...expected.target_ages)); - expect(learningPath.num_nodes).toEqual(expectedEntity.nodes.length); - expect(learningPath.image || null).toEqual(expectedEntity.image); + expect(learningPath.num_nodes).toEqual(expected.nodes.length); + expect(learningPath.image ?? null).toEqual(expected.image ?? null); - const expectedLearningPathNodes = new Map( - expectedEntity.nodes.map((node) => [ - { learningObjectHruid: node.learningObjectHruid, language: node.language, version: node.version }, - { startNode: node.startNode, transitions: node.transitions }, - ]) - ); - - for (const node of learningPath.nodes) { - const nodeKey = { - learningObjectHruid: node.learningobject_hruid, - language: node.language, - version: node.version, - }; - expect(expectedLearningPathNodes.keys()).toContainEqual(nodeKey); - const expectedNode = [...expectedLearningPathNodes.entries()].find( - ([key, _]) => key.learningObjectHruid === nodeKey.learningObjectHruid && key.language === node.language && key.version === node.version - )![1]; - expect(node.start_node).toEqual(expectedNode.startNode); - - expect(new Set(node.transitions.map((it) => it.next.hruid))).toEqual( - new Set(expectedNode.transitions.map((it) => it.next.learningObjectHruid)) + for (const node of expected.nodes) { + const correspondingNode = learningPath.nodes.find( + (it) => node.learningobject_hruid === it.learningobject_hruid && node.language === it.language && node.version === it.version ); - expect(new Set(node.transitions.map((it) => it.next.language))).toEqual(new Set(expectedNode.transitions.map((it) => it.next.language))); - expect(new Set(node.transitions.map((it) => it.next.version))).toEqual(new Set(expectedNode.transitions.map((it) => it.next.version))); + expect(correspondingNode).toBeTruthy(); + expect(Boolean(correspondingNode!.start_node)).toEqual(Boolean(node.start_node)); + + for (const transition of node.transitions) { + const correspondingTransition = correspondingNode!.transitions.find( + (it) => + it.next.hruid === transition.next.hruid && + it.next.language === transition.next.language && + it.next.version === transition.next.version + ); + expect(correspondingTransition).toBeTruthy(); + } } } + +/** + * Expect that the given result is a singleton list with exactly the given element. + */ +export function expectToHaveFoundPrecisely(expected: T, result: T[]): void { + expect(result).toHaveProperty('length'); + expect(result.length).toBe(1); + expectToBeCorrectEntity(result[0], expected); +} + +/** + * Expect that the given result is an empty list. + */ +export function expectToHaveFoundNothing(result: T[]): void { + expect(result).toHaveProperty('length'); + expect(result.length).toBe(0); +} diff --git a/backend/tests/test-utils/get-html-rendering.ts b/backend/tests/test-utils/get-html-rendering.ts new file mode 100644 index 00000000..23ea7f86 --- /dev/null +++ b/backend/tests/test-utils/get-html-rendering.ts @@ -0,0 +1,10 @@ +import { RequiredEntityData } from '@mikro-orm/core'; +import { loadTestAsset } from './load-test-asset'; +import { LearningObject } from '../../src/entities/content/learning-object.entity'; +import { envVars, getEnvVar } from '../../src/util/envVars'; + +export function getHtmlRenderingForTestLearningObject(learningObject: RequiredEntityData): string { + const userPrefix = getEnvVar(envVars.UserContentPrefix); + const cleanedHruid = learningObject.hruid.startsWith(userPrefix) ? learningObject.hruid.substring(userPrefix.length) : learningObject.hruid; + return loadTestAsset(`/content/learning-object-resources/${cleanedHruid}/rendering.txt`).toString(); +} diff --git a/backend/tests/test-utils/load-test-asset.ts b/backend/tests/test-utils/load-test-asset.ts index 35f6cdbf..2920afe0 100644 --- a/backend/tests/test-utils/load-test-asset.ts +++ b/backend/tests/test-utils/load-test-asset.ts @@ -1,10 +1,14 @@ import fs from 'fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const fileName = fileURLToPath(import.meta.url); +const dirName = path.dirname(fileName); /** * Load the asset at the given path. * @param relPath Path of the asset relative to the test-assets folder. */ export function loadTestAsset(relPath: string): Buffer { - return fs.readFileSync(path.resolve(__dirname, `../test-assets/${relPath}`)); + return fs.readFileSync(path.resolve(dirName, `../test_assets/${relPath}`)); } diff --git a/backend/tests/test_assets/assignments/assignments.testdata.ts b/backend/tests/test_assets/assignments/assignments.testdata.ts index 14253c0a..337ec98f 100644 --- a/backend/tests/test_assets/assignments/assignments.testdata.ts +++ b/backend/tests/test_assets/assignments/assignments.testdata.ts @@ -2,11 +2,13 @@ import { EntityManager } from '@mikro-orm/core'; import { Assignment } from '../../../src/entities/assignments/assignment.entity'; import { Class } from '../../../src/entities/classes/class.entity'; import { Language } from '@dwengo-1/common/util/language'; +import { testLearningPathWithConditions } from '../content/learning-paths.testdata'; +import { getClassWithTestleerlingAndTestleerkracht } from '../classes/classes.testdata'; export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assignment[] { - const assignment01 = em.create(Assignment, { + assignment01 = em.create(Assignment, { + id: 21000, within: classes[0], - id: 1, title: 'dire straits', description: 'reading', learningPathHruid: 'id02', @@ -14,9 +16,9 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign groups: [], }); - const assignment02 = em.create(Assignment, { + assignment02 = em.create(Assignment, { + id: 21001, within: classes[1], - id: 2, title: 'tool', description: 'reading', learningPathHruid: 'id01', @@ -24,9 +26,9 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign groups: [], }); - const assignment03 = em.create(Assignment, { + assignment03 = em.create(Assignment, { + id: 21002, within: classes[0], - id: 3, title: 'delete', description: 'will be deleted', learningPathHruid: 'id02', @@ -34,9 +36,9 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign groups: [], }); - const assignment04 = em.create(Assignment, { + assignment04 = em.create(Assignment, { + id: 21003, within: classes[0], - id: 4, title: 'another assignment', description: 'with a description', learningPathHruid: 'id01', @@ -44,5 +46,41 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign groups: [], }); - return [assignment01, assignment02, assignment03, assignment04]; + conditionalPathAssignment = em.create(Assignment, { + within: getClassWithTestleerlingAndTestleerkracht(), + id: 21004, + title: 'Assignment: Conditional Learning Path', + description: 'You have to do the testing learning path with a condition.', + learningPathHruid: testLearningPathWithConditions.hruid, + learningPathLanguage: testLearningPathWithConditions.language as Language, + groups: [], + }); + + return [assignment01, assignment02, assignment03, assignment04, conditionalPathAssignment]; +} + +let assignment01: Assignment; +let assignment02: Assignment; +let assignment03: Assignment; +let assignment04: Assignment; +let conditionalPathAssignment: Assignment; + +export function getAssignment01(): Assignment { + return assignment01; +} + +export function getAssignment02(): Assignment { + return assignment02; +} + +export function getAssignment03(): Assignment { + return assignment03; +} + +export function getAssignment04(): Assignment { + return assignment04; +} + +export function getConditionalPathAssignment(): Assignment { + return conditionalPathAssignment; } diff --git a/backend/tests/test_assets/assignments/groups.testdata.ts b/backend/tests/test_assets/assignments/groups.testdata.ts index c82887bb..16674843 100644 --- a/backend/tests/test_assets/assignments/groups.testdata.ts +++ b/backend/tests/test_assets/assignments/groups.testdata.ts @@ -2,15 +2,17 @@ import { EntityManager } from '@mikro-orm/core'; import { Group } from '../../../src/entities/assignments/group.entity'; import { Assignment } from '../../../src/entities/assignments/assignment.entity'; import { Student } from '../../../src/entities/users/student.entity'; +import { getConditionalPathAssignment } from './assignments.testdata'; +import { getTestleerling1 } from '../users/students.testdata'; export function makeTestGroups(em: EntityManager, students: Student[], assignments: Assignment[]): Group[] { /* * Group #1 for Assignment #1 in class 'id01' * => Assigned to do learning path 'id02' */ - const group01 = em.create(Group, { + group01 = em.create(Group, { assignment: assignments[0], - groupNumber: 1, + groupNumber: 21001, members: students.slice(0, 2), }); @@ -18,9 +20,9 @@ export function makeTestGroups(em: EntityManager, students: Student[], assignmen * Group #2 for Assignment #1 in class 'id01' * => Assigned to do learning path 'id02' */ - const group02 = em.create(Group, { + group02 = em.create(Group, { assignment: assignments[0], - groupNumber: 2, + groupNumber: 21002, members: students.slice(2, 4), }); @@ -28,9 +30,9 @@ export function makeTestGroups(em: EntityManager, students: Student[], assignmen * Group #3 for Assignment #1 in class 'id01' * => Assigned to do learning path 'id02' */ - const group03 = em.create(Group, { + group03 = em.create(Group, { assignment: assignments[0], - groupNumber: 3, + groupNumber: 21003, members: students.slice(4, 6), }); @@ -38,9 +40,9 @@ export function makeTestGroups(em: EntityManager, students: Student[], assignmen * Group #4 for Assignment #2 in class 'id02' * => Assigned to do learning path 'id01' */ - const group04 = em.create(Group, { + group04 = em.create(Group, { assignment: assignments[1], - groupNumber: 4, + groupNumber: 21004, members: students.slice(3, 4), }); @@ -48,11 +50,51 @@ export function makeTestGroups(em: EntityManager, students: Student[], assignmen * Group #5 for Assignment #4 in class 'id01' * => Assigned to do learning path 'id01' */ - const group05 = em.create(Group, { + group05 = em.create(Group, { assignment: assignments[3], - groupNumber: 1, + groupNumber: 21001, members: students.slice(0, 2), }); - return [group01, group02, group03, group04, group05]; + /** + * Group 1 for the assignment of the testing learning path with conditions. + */ + group1ConditionalLearningPath = em.create(Group, { + assignment: getConditionalPathAssignment(), + groupNumber: 1, + members: [getTestleerling1()], + }); + + return [group01, group02, group03, group04, group05, group1ConditionalLearningPath]; +} + +let group01: Group; +let group02: Group; +let group03: Group; +let group04: Group; +let group05: Group; +let group1ConditionalLearningPath: Group; + +export function getTestGroup01(): Group { + return group01; +} + +export function getTestGroup02(): Group { + return group02; +} + +export function getTestGroup03(): Group { + return group03; +} + +export function getTestGroup04(): Group { + return group04; +} + +export function getTestGroup05(): Group { + return group05; +} + +export function getGroup1ConditionalLearningPath(): Group { + return group1ConditionalLearningPath; } diff --git a/backend/tests/test_assets/classes/classes.testdata.ts b/backend/tests/test_assets/classes/classes.testdata.ts index 0f223ec4..7b5f2976 100644 --- a/backend/tests/test_assets/classes/classes.testdata.ts +++ b/backend/tests/test_assets/classes/classes.testdata.ts @@ -2,12 +2,14 @@ import { EntityManager } from '@mikro-orm/core'; import { Class } from '../../../src/entities/classes/class.entity'; import { Student } from '../../../src/entities/users/student.entity'; import { Teacher } from '../../../src/entities/users/teacher.entity'; +import { getTestleerkracht1 } from '../users/teachers.testdata'; +import { getTestleerling1 } from '../users/students.testdata'; export function makeTestClasses(em: EntityManager, students: Student[], teachers: Teacher[]): Class[] { const studentsClass01 = students.slice(0, 8); const teacherClass01: Teacher[] = teachers.slice(4, 5); - const class01 = em.create(Class, { + class01 = em.create(Class, { classId: '8764b861-90a6-42e5-9732-c0d9eb2f55f9', displayName: 'class01', teachers: teacherClass01, @@ -17,7 +19,7 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers const studentsClass02: Student[] = students.slice(0, 2).concat(students.slice(3, 4)); const teacherClass02: Teacher[] = teachers.slice(1, 2); - const class02 = em.create(Class, { + class02 = em.create(Class, { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', displayName: 'class02', teachers: teacherClass02, @@ -27,7 +29,7 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers const studentsClass03: Student[] = students.slice(1, 4); const teacherClass03: Teacher[] = teachers.slice(2, 3); - const class03 = em.create(Class, { + class03 = em.create(Class, { classId: '80dcc3e0-1811-4091-9361-42c0eee91cfa', displayName: 'class03', teachers: teacherClass03, @@ -37,12 +39,45 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers const studentsClass04: Student[] = students.slice(0, 2); const teacherClass04: Teacher[] = teachers.slice(2, 3); - const class04 = em.create(Class, { + class04 = em.create(Class, { classId: '33d03536-83b8-4880-9982-9bbf2f908ddf', displayName: 'class04', teachers: teacherClass04, students: studentsClass04, }); - return [class01, class02, class03, class04]; + classWithTestleerlingAndTestleerkracht = em.create(Class, { + classId: 'a75298b5-18aa-471d-8eeb-5d77eb989393', + displayName: 'Testklasse', + teachers: [getTestleerkracht1()], + students: [getTestleerling1()], + }); + + return [class01, class02, class03, class04, classWithTestleerlingAndTestleerkracht]; +} + +let class01: Class; +let class02: Class; +let class03: Class; +let class04: Class; +let classWithTestleerlingAndTestleerkracht: Class; + +export function getClass01(): Class { + return class01; +} + +export function getClass02(): Class { + return class02; +} + +export function getClass03(): Class { + return class03; +} + +export function getClass04(): Class { + return class04; +} + +export function getClassWithTestleerlingAndTestleerkracht(): Class { + return classWithTestleerlingAndTestleerkracht; } diff --git a/backend/tests/test-assets/learning-objects/dummy/rendering.txt b/backend/tests/test_assets/content/learning-object-resources/dummy/rendering.txt similarity index 100% rename from backend/tests/test-assets/learning-objects/dummy/rendering.txt rename to backend/tests/test_assets/content/learning-object-resources/dummy/rendering.txt diff --git a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/Knop.png b/backend/tests/test_assets/content/learning-object-resources/pn_werkingnotebooks/Knop.png similarity index 100% rename from backend/tests/test-assets/learning-objects/pn-werkingnotebooks/Knop.png rename to backend/tests/test_assets/content/learning-object-resources/pn_werkingnotebooks/Knop.png diff --git a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/content.md b/backend/tests/test_assets/content/learning-object-resources/pn_werkingnotebooks/content.md similarity index 100% rename from backend/tests/test-assets/learning-objects/pn-werkingnotebooks/content.md rename to backend/tests/test_assets/content/learning-object-resources/pn_werkingnotebooks/content.md diff --git a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/dwengo.png b/backend/tests/test_assets/content/learning-object-resources/pn_werkingnotebooks/dwengo.png similarity index 100% rename from backend/tests/test-assets/learning-objects/pn-werkingnotebooks/dwengo.png rename to backend/tests/test_assets/content/learning-object-resources/pn_werkingnotebooks/dwengo.png diff --git a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/rendering.txt b/backend/tests/test_assets/content/learning-object-resources/pn_werkingnotebooks/rendering.txt similarity index 100% rename from backend/tests/test-assets/learning-objects/pn-werkingnotebooks/rendering.txt rename to backend/tests/test_assets/content/learning-object-resources/pn_werkingnotebooks/rendering.txt diff --git a/backend/tests/test_assets/content/learning-object-resources/test_essay_question/content.txt b/backend/tests/test_assets/content/learning-object-resources/test_essay_question/content.txt new file mode 100644 index 00000000..0e9fa7de --- /dev/null +++ b/backend/tests/test_assets/content/learning-object-resources/test_essay_question/content.txt @@ -0,0 +1,2 @@ +::Reflection:: +Reflect on this learning path. What have you learned today? {} diff --git a/backend/tests/test_assets/content/learning-object-resources/test_essay_question/rendering.txt b/backend/tests/test_assets/content/learning-object-resources/test_essay_question/rendering.txt new file mode 100644 index 00000000..53c58584 --- /dev/null +++ b/backend/tests/test_assets/content/learning-object-resources/test_essay_question/rendering.txt @@ -0,0 +1,7 @@ +
+
+

Reflection

+

Reflect on this learning path. What have you learned today?

+ +
+
diff --git a/backend/tests/test-assets/learning-objects/test-multiple-choice/content.txt b/backend/tests/test_assets/content/learning-object-resources/test_multiple_choice/content.txt similarity index 52% rename from backend/tests/test-assets/learning-objects/test-multiple-choice/content.txt rename to backend/tests/test_assets/content/learning-object-resources/test_multiple_choice/content.txt index 7dd5527d..4c2ba46e 100644 --- a/backend/tests/test-assets/learning-objects/test-multiple-choice/content.txt +++ b/backend/tests/test_assets/content/learning-object-resources/test_multiple_choice/content.txt @@ -1,5 +1,5 @@ -::MC basic:: -Are you following along well with the class? { +::Self-evaluation:: +Are you following along well? { ~No, it's very difficult to follow along. =Yes, no problem! } diff --git a/backend/tests/test-assets/learning-objects/test-multiple-choice/rendering.txt b/backend/tests/test_assets/content/learning-object-resources/test_multiple_choice/rendering.txt similarity index 56% rename from backend/tests/test-assets/learning-objects/test-multiple-choice/rendering.txt rename to backend/tests/test_assets/content/learning-object-resources/test_multiple_choice/rendering.txt index c1829f24..982f40b4 100644 --- a/backend/tests/test-assets/learning-objects/test-multiple-choice/rendering.txt +++ b/backend/tests/test_assets/content/learning-object-resources/test_multiple_choice/rendering.txt @@ -1,14 +1,14 @@
-
-

MC basic

-

Are you following along well with the class?

+
+

Self-evaluation

+

Are you following along well?

- +
- +
diff --git a/backend/tests/test_assets/content/learning-objects.testdata.ts b/backend/tests/test_assets/content/learning-objects.testdata.ts index 6e28dc16..6ee3bccf 100644 --- a/backend/tests/test_assets/content/learning-objects.testdata.ts +++ b/backend/tests/test_assets/content/learning-objects.testdata.ts @@ -1,135 +1,261 @@ -import { EntityManager } from '@mikro-orm/core'; +import { EntityManager, RequiredEntityData } from '@mikro-orm/core'; import { LearningObject } from '../../../src/entities/content/learning-object.entity'; import { Language } from '@dwengo-1/common/util/language'; import { DwengoContentType } from '../../../src/services/learning-objects/processing/content-type'; import { ReturnValue } from '../../../src/entities/content/return-value.entity'; +import { envVars, getEnvVar } from '../../../src/util/envVars'; +import { loadTestAsset } from '../../test-utils/load-test-asset'; +import { v4 } from 'uuid'; export function makeTestLearningObjects(em: EntityManager): LearningObject[] { const returnValue: ReturnValue = new ReturnValue(); returnValue.callbackSchema = ''; returnValue.callbackUrl = ''; - const learningObject01 = em.create(LearningObject, { - hruid: 'id01', - language: Language.English, - version: 1, - admins: [], - title: 'Undertow', - description: 'debute', - contentType: DwengoContentType.TEXT_MARKDOWN, - keywords: [], - teacherExclusive: false, - skosConcepts: [], - educationalGoals: [], - copyright: '', - license: '', - estimatedTime: 45, - returnValue: returnValue, - available: true, - contentLocation: '', - attachments: [], - content: Buffer.from("there's a shadow just behind me, shrouding every step i take, making every promise empty pointing every finger at me"), - }); + const learningObject01 = em.create(LearningObject, testLearningObject01); + const learningObject02 = em.create(LearningObject, testLearningObject02); + const learningObject03 = em.create(LearningObject, testLearningObject03); + const learningObject04 = em.create(LearningObject, testLearningObject04); + const learningObject05 = em.create(LearningObject, testLearningObject05); - const learningObject02 = em.create(LearningObject, { - hruid: 'id02', - language: Language.English, - version: 1, - admins: [], - title: 'Aenema', - description: 'second album', - contentType: DwengoContentType.TEXT_MARKDOWN, - keywords: [], - teacherExclusive: false, - skosConcepts: [], - educationalGoals: [], - copyright: '', - license: '', - estimatedTime: 80, - returnValue: returnValue, - available: true, - contentLocation: '', - attachments: [], - content: Buffer.from( - "I've been crawling on my belly clearing out what could've been I've been wallowing in my own confused and insecure delusions" - ), - }); + const learningObjectMultipleChoice = em.create(LearningObject, testLearningObjectMultipleChoice); + const learningObjectEssayQuestion = em.create(LearningObject, testLearningObjectEssayQuestion); - const learningObject03 = em.create(LearningObject, { - hruid: 'id03', - language: Language.English, - version: 1, - admins: [], - title: 'love over gold', - description: 'third album', - contentType: DwengoContentType.TEXT_MARKDOWN, - keywords: [], - teacherExclusive: false, - skosConcepts: [], - educationalGoals: [], - copyright: '', - license: '', - estimatedTime: 55, - returnValue: returnValue, - available: true, - contentLocation: '', - attachments: [], - content: Buffer.from( - 'he wrote me a prescription, he said you are depressed, \ - but I am glad you came to see me to get this off your chest, \ - come back and see me later next patient please \ - send in another victim of industrial disease' - ), - }); + const learningObjectPnNotebooks = em.create(LearningObject, testLearningObjectPnNotebooks); - const learningObject04 = em.create(LearningObject, { - hruid: 'id04', - language: Language.English, - version: 1, - admins: [], - title: 'making movies', - description: 'fifth album', - contentType: DwengoContentType.TEXT_MARKDOWN, - keywords: [], - teacherExclusive: false, - skosConcepts: [], - educationalGoals: [], - copyright: '', - license: '', - estimatedTime: 55, - returnValue: returnValue, - available: true, - contentLocation: '', - attachments: [], - content: Buffer.from( - 'I put my hand upon the lever \ - Said let it rock and let it roll \ - I had the one-arm bandit fever \ - There was an arrow through my heart and my soul' - ), - }); - - const learningObject05 = em.create(LearningObject, { - hruid: 'id05', - language: Language.English, - version: 1, - admins: [], - title: 'on every street', - description: 'sixth album', - contentType: DwengoContentType.TEXT_MARKDOWN, - keywords: [], - teacherExclusive: false, - skosConcepts: [], - educationalGoals: [], - copyright: '', - license: '', - estimatedTime: 55, - returnValue: returnValue, - available: true, - contentLocation: '', - attachments: [], - content: Buffer.from('calling Elvis, is anybody home, calling elvis, I am here all alone'), - }); - - return [learningObject01, learningObject02, learningObject03, learningObject04, learningObject05]; + return [ + learningObject01, + learningObject02, + learningObject03, + learningObject04, + learningObject05, + learningObjectMultipleChoice, + learningObjectEssayQuestion, + learningObjectPnNotebooks, + ]; } + +export function createReturnValue(): ReturnValue { + const returnValue: ReturnValue = new ReturnValue(); + returnValue.callbackSchema = '[]'; + returnValue.callbackUrl = '%SUBMISSION%'; + return returnValue; +} + +export const testLearningObject01: RequiredEntityData = { + hruid: `${getEnvVar(envVars.UserContentPrefix)}id01`, + language: Language.English, + version: 1, + admins: [], + title: 'Undertow', + description: 'debute', + contentType: DwengoContentType.TEXT_MARKDOWN, + keywords: [], + uuid: v4(), + targetAges: [16, 17, 18], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: '', + license: '', + estimatedTime: 45, + returnValue: createReturnValue(), + available: true, + contentLocation: '', + attachments: [], + content: Buffer.from("there's a shadow just behind me, shrouding every step i take, making every promise empty pointing every finger at me"), +}; + +export const testLearningObject02: RequiredEntityData = { + hruid: `${getEnvVar(envVars.UserContentPrefix)}id02`, + language: Language.English, + version: 1, + admins: [], + title: 'Aenema', + description: 'second album', + contentType: DwengoContentType.TEXT_MARKDOWN, + keywords: [], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: '', + license: '', + estimatedTime: 80, + returnValue: createReturnValue(), + available: true, + contentLocation: '', + attachments: [], + content: Buffer.from( + "I've been crawling on my belly clearing out what could've been I've been wallowing in my own confused and insecure delusions" + ), +}; + +export const testLearningObject03: RequiredEntityData = { + hruid: `${getEnvVar(envVars.UserContentPrefix)}id03`, + language: Language.English, + version: 1, + admins: [], + title: 'love over gold', + description: 'third album', + contentType: DwengoContentType.TEXT_MARKDOWN, + keywords: [], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: '', + license: '', + estimatedTime: 55, + returnValue: createReturnValue(), + available: true, + contentLocation: '', + attachments: [], + content: Buffer.from( + 'he wrote me a prescription, he said you are depressed, \ + but I am glad you came to see me to get this off your chest, \ + come back and see me later next patient please \ + send in another victim of industrial disease' + ), +}; + +export const testLearningObject04: RequiredEntityData = { + hruid: `${getEnvVar(envVars.UserContentPrefix)}id04`, + language: Language.English, + version: 1, + admins: [], + title: 'making movies', + description: 'fifth album', + contentType: DwengoContentType.TEXT_MARKDOWN, + keywords: [], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: '', + license: '', + estimatedTime: 55, + returnValue: createReturnValue(), + available: true, + contentLocation: '', + attachments: [], + content: Buffer.from( + 'I put my hand upon the lever \ + Said let it rock and let it roll \ + I had the one-arm bandit fever \ + There was an arrow through my heart and my soul' + ), +}; + +export const testLearningObject05: RequiredEntityData = { + hruid: `${getEnvVar(envVars.UserContentPrefix)}id05`, + language: Language.English, + version: 1, + admins: [], + title: 'on every street', + description: 'sixth album', + contentType: DwengoContentType.TEXT_MARKDOWN, + keywords: [], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: '', + license: '', + estimatedTime: 55, + returnValue: createReturnValue(), + available: true, + contentLocation: '', + attachments: [], + content: Buffer.from('calling Elvis, is anybody home, calling elvis, I am here all alone'), +}; + +export const testLearningObjectMultipleChoice: RequiredEntityData = { + hruid: `${getEnvVar(envVars.UserContentPrefix)}test_multiple_choice`, + language: Language.English, + version: 1, + title: 'Self-evaluation', + description: "Time to evaluate how well you understand what you've learned so far.", + keywords: ['test'], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: 'Groep 1 SEL-2 2025', + license: 'CC0', + difficulty: 1, + estimatedTime: 1, + attachments: [], + available: true, + targetAges: [10, 11, 12, 13, 14, 15, 16, 17, 18], + admins: [], + contentType: DwengoContentType.GIFT, + content: loadTestAsset('content/learning-object-resources/test_multiple_choice/content.txt'), + returnValue: { + callbackUrl: `%SUBMISSION%`, + callbackSchema: '["antwoord vraag 1"]', + }, +}; + +export const testLearningObjectEssayQuestion: RequiredEntityData = { + hruid: `${getEnvVar(envVars.UserContentPrefix)}test_essay_question`, + language: Language.English, + version: 1, + title: 'Reflection', + description: 'Reflect on your learning progress.', + keywords: ['test'], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: 'Groep 1 SEL-2 2025', + license: 'CC0', + difficulty: 1, + estimatedTime: 1, + attachments: [], + available: true, + targetAges: [10, 11, 12, 13, 14, 15, 16, 17, 18], + admins: [], + contentType: DwengoContentType.GIFT, + content: loadTestAsset('content/learning-object-resources/test_essay_question/content.txt'), + returnValue: { + callbackUrl: `%SUBMISSION%`, + callbackSchema: '["antwoord vraag 1"]', + }, +}; + +export const testLearningObjectPnNotebooks: RequiredEntityData = { + hruid: `${getEnvVar(envVars.UserContentPrefix)}pn_werkingnotebooks`, + version: 3, + language: Language.Dutch, + title: 'Werken met notebooks', + description: 'Leren werken met notebooks', + keywords: ['Python', 'KIKS', 'Wiskunde', 'STEM', 'AI'], + targetAges: [14, 15, 16, 17, 18], + admins: [], + copyright: 'dwengo', + educationalGoals: [], + license: 'dwengo', + contentType: DwengoContentType.TEXT_MARKDOWN, + difficulty: 3, + estimatedTime: 10, + uuid: '2adf9929-b424-4650-bf60-186f730d38ab', + teacherExclusive: false, + skosConcepts: [ + 'http://ilearn.ilabt.imec.be/vocab/curr1/s-vaktaal', + 'http://ilearn.ilabt.imec.be/vocab/curr1/s-digitale-media-en-toepassingen', + 'http://ilearn.ilabt.imec.be/vocab/curr1/s-computers-en-systemen', + ], + attachments: [ + { + name: 'dwengo.png', + mimeType: 'image/png', + content: loadTestAsset('/content/learning-object-resources/pn_werkingnotebooks/dwengo.png'), + }, + { + name: 'Knop.png', + mimeType: 'image/png', + content: loadTestAsset('/content/learning-object-resources/pn_werkingnotebooks/Knop.png'), + }, + ], + available: false, + content: loadTestAsset('/content/learning-object-resources/pn_werkingnotebooks/content.md'), + returnValue: { + callbackUrl: '%SUBMISSION%', + callbackSchema: '[]', + }, +}; diff --git a/backend/tests/test_assets/content/learning-paths.testdata.ts b/backend/tests/test_assets/content/learning-paths.testdata.ts index 72581f42..2a0640f8 100644 --- a/backend/tests/test_assets/content/learning-paths.testdata.ts +++ b/backend/tests/test_assets/content/learning-paths.testdata.ts @@ -1,100 +1,236 @@ import { EntityManager } from '@mikro-orm/core'; import { LearningPath } from '../../../src/entities/content/learning-path.entity'; import { Language } from '@dwengo-1/common/util/language'; -import { LearningPathTransition } from '../../../src/entities/content/learning-path-transition.entity'; -import { LearningPathNode } from '../../../src/entities/content/learning-path-node.entity'; +import { mapToLearningPath } from '../../../src/services/learning-paths/learning-path-service'; +import { envVars, getEnvVar } from '../../../src/util/envVars'; +import { LearningPath as LearningPathDTO } from '@dwengo-1/common/interfaces/learning-content'; +import { + testLearningObject01, + testLearningObject02, + testLearningObject03, + testLearningObject04, + testLearningObject05, + testLearningObjectEssayQuestion, + testLearningObjectMultipleChoice, + testLearningObjectPnNotebooks, +} from './learning-objects.testdata'; -export function makeTestLearningPaths(em: EntityManager): LearningPath[] { - const learningPathNode01: LearningPathNode = new LearningPathNode(); - const learningPathNode02: LearningPathNode = new LearningPathNode(); - const learningPathNode03: LearningPathNode = new LearningPathNode(); - const learningPathNode04: LearningPathNode = new LearningPathNode(); - const learningPathNode05: LearningPathNode = new LearningPathNode(); +export function makeTestLearningPaths(_em: EntityManager): LearningPath[] { + const learningPath01 = mapToLearningPath(testLearningPath01, []); + const learningPath02 = mapToLearningPath(testLearningPath02, []); - const transitions01: LearningPathTransition = new LearningPathTransition(); - const transitions02: LearningPathTransition = new LearningPathTransition(); - const transitions03: LearningPathTransition = new LearningPathTransition(); - const transitions04: LearningPathTransition = new LearningPathTransition(); - const transitions05: LearningPathTransition = new LearningPathTransition(); + const partiallyDatabasePartiallyDwengoApiLearningPath = mapToLearningPath(testPartiallyDatabaseAndPartiallyDwengoApiLearningPath, []); + const learningPathWithConditions = mapToLearningPath(testLearningPathWithConditions, []); - transitions01.condition = 'true'; - transitions01.next = learningPathNode02; - - transitions02.condition = 'true'; - transitions02.next = learningPathNode02; - - transitions03.condition = 'true'; - transitions03.next = learningPathNode04; - - transitions04.condition = 'true'; - transitions04.next = learningPathNode05; - - transitions05.condition = 'true'; - transitions05.next = learningPathNode05; - - learningPathNode01.instruction = ''; - learningPathNode01.language = Language.English; - learningPathNode01.learningObjectHruid = 'id01'; - learningPathNode01.startNode = true; - learningPathNode01.transitions = [transitions01]; - learningPathNode01.version = 1; - - learningPathNode02.instruction = ''; - learningPathNode02.language = Language.English; - learningPathNode02.learningObjectHruid = 'id02'; - learningPathNode02.startNode = false; - learningPathNode02.transitions = [transitions02]; - learningPathNode02.version = 1; - - learningPathNode03.instruction = ''; - learningPathNode03.language = Language.English; - learningPathNode03.learningObjectHruid = 'id03'; - learningPathNode03.startNode = true; - learningPathNode03.transitions = [transitions03]; - learningPathNode03.version = 1; - - learningPathNode04.instruction = ''; - learningPathNode04.language = Language.English; - learningPathNode04.learningObjectHruid = 'id04'; - learningPathNode04.startNode = false; - learningPathNode04.transitions = [transitions04]; - learningPathNode04.version = 1; - - learningPathNode05.instruction = ''; - learningPathNode05.language = Language.English; - learningPathNode05.learningObjectHruid = 'id05'; - learningPathNode05.startNode = false; - learningPathNode05.transitions = [transitions05]; - learningPathNode05.version = 1; - - const nodes01: LearningPathNode[] = [ - // LearningPathNode01, - // LearningPathNode02, - ]; - const learningPath01 = em.create(LearningPath, { - hruid: 'id01', - language: Language.English, - admins: [], - title: 'repertoire Tool', - description: 'all about Tool', - image: null, - nodes: nodes01, - }); - - const nodes02: LearningPathNode[] = [ - // LearningPathNode03, - // LearningPathNode04, - // LearningPathNode05, - ]; - const learningPath02 = em.create(LearningPath, { - hruid: 'id02', - language: Language.English, - admins: [], - title: 'repertoire Dire Straits', - description: 'all about Dire Straits', - image: null, - nodes: nodes02, - }); - - return [learningPath01, learningPath02]; + return [learningPath01, learningPath02, partiallyDatabasePartiallyDwengoApiLearningPath, learningPathWithConditions]; } + +const nowString = new Date().toString(); + +export const testLearningPath01: LearningPathDTO = { + keywords: 'test', + target_ages: [16, 17, 18], + hruid: `${getEnvVar(envVars.UserContentPrefix)}id01`, + language: Language.English, + title: 'repertoire Tool', + description: 'all about Tool', + nodes: [ + { + learningobject_hruid: testLearningObject01.hruid, + language: testLearningObject01.language, + version: testLearningObject01.version, + start_node: true, + created_at: nowString, + updatedAt: nowString, + transitions: [ + { + next: { + hruid: testLearningObject02.hruid, + language: testLearningObject02.language, + version: testLearningObject02.version, + }, + }, + ], + }, + { + learningobject_hruid: testLearningObject02.hruid, + language: testLearningObject02.language, + version: testLearningObject02.version, + created_at: nowString, + updatedAt: nowString, + transitions: [], + }, + ], +}; + +export const testLearningPath02: LearningPathDTO = { + keywords: 'test', + target_ages: [16, 17, 18], + hruid: `${getEnvVar(envVars.UserContentPrefix)}id02`, + language: Language.English, + title: 'repertoire Dire Straits', + description: 'all about Dire Straits', + nodes: [ + { + learningobject_hruid: testLearningObject03.hruid, + language: testLearningObject03.language, + version: testLearningObject03.version, + start_node: true, + created_at: nowString, + updatedAt: nowString, + transitions: [ + { + next: { + hruid: testLearningObject04.hruid, + language: testLearningObject04.language, + version: testLearningObject04.version, + }, + }, + ], + }, + { + learningobject_hruid: testLearningObject04.hruid, + language: testLearningObject04.language, + version: testLearningObject04.version, + created_at: nowString, + updatedAt: nowString, + transitions: [ + { + next: { + hruid: testLearningObject05.hruid, + language: testLearningObject05.language, + version: testLearningObject05.version, + }, + }, + ], + }, + { + learningobject_hruid: testLearningObject05.hruid, + language: testLearningObject05.language, + version: testLearningObject05.version, + created_at: nowString, + updatedAt: nowString, + transitions: [], + }, + ], +}; + +export const testPartiallyDatabaseAndPartiallyDwengoApiLearningPath: LearningPathDTO = { + hruid: `${getEnvVar(envVars.UserContentPrefix)}pn_werking`, + title: 'Werken met notebooks', + language: Language.Dutch, + description: 'Een korte inleiding tot Python notebooks. Hoe ga je gemakkelijk en efficiënt met de notebooks aan de slag?', + keywords: 'Python KIKS Wiskunde STEM AI', + target_ages: [14, 15, 16, 17, 18], + nodes: [ + { + learningobject_hruid: testLearningObjectPnNotebooks.hruid, + language: testLearningObjectPnNotebooks.language, + version: testLearningObjectPnNotebooks.version, + start_node: true, + created_at: nowString, + updatedAt: nowString, + transitions: [ + { + default: true, + next: { + hruid: 'pn_werkingnotebooks2', + language: Language.Dutch, + version: 3, + }, + }, + ], + }, + { + learningobject_hruid: 'pn_werkingnotebooks2', + language: Language.Dutch, + version: 3, + created_at: nowString, + updatedAt: nowString, + transitions: [ + { + default: true, + next: { + hruid: 'pn_werkingnotebooks3', + language: Language.Dutch, + version: 3, + }, + }, + ], + }, + { + learningobject_hruid: 'pn_werkingnotebooks3', + language: Language.Dutch, + version: 3, + created_at: nowString, + updatedAt: nowString, + transitions: [], + }, + ], +}; + +export const testLearningPathWithConditions: LearningPathDTO = { + hruid: `${getEnvVar(envVars.UserContentPrefix)}test_conditions`, + language: Language.English, + title: 'Example learning path with conditional transitions', + description: 'This learning path was made for the purpose of testing conditional transitions', + keywords: 'test', + target_ages: [10, 11, 12, 13, 14, 15, 16, 17, 18], + nodes: [ + { + learningobject_hruid: testLearningObjectMultipleChoice.hruid, + language: testLearningObjectMultipleChoice.language, + version: testLearningObjectMultipleChoice.version, + start_node: true, + created_at: nowString, + updatedAt: nowString, + transitions: [ + { + // If the answer to the first question was the first one (It's difficult to follow along): + condition: '$[?(@[0] == 0)]', + next: { + //... we let the student do an extra exercise. + hruid: testLearningObject01.hruid, + language: testLearningObject01.language, + version: testLearningObject01.version, + }, + }, + { + // If the answer to the first question was the second one (I can follow along): + condition: '$[?(@[0] == 1)]', + next: { + //... we let the student right through to the final question. + hruid: testLearningObjectEssayQuestion.hruid, + language: testLearningObjectEssayQuestion.language, + version: testLearningObjectEssayQuestion.version, + }, + }, + ], + }, + { + learningobject_hruid: testLearningObject01.hruid, + language: testLearningObject01.language, + version: testLearningObject01.version, + created_at: nowString, + updatedAt: nowString, + transitions: [ + { + default: true, + next: { + hruid: testLearningObjectEssayQuestion.hruid, + language: testLearningObjectEssayQuestion.language, + version: testLearningObjectEssayQuestion.version, + }, + }, + ], + }, + { + learningobject_hruid: testLearningObjectEssayQuestion.hruid, + language: testLearningObjectEssayQuestion.language, + version: testLearningObjectEssayQuestion.version, + created_at: nowString, + updatedAt: nowString, + transitions: [], + }, + ], +}; diff --git a/backend/tests/test_assets/users/students.testdata.ts b/backend/tests/test_assets/users/students.testdata.ts index 7a1fe191..e596b388 100644 --- a/backend/tests/test_assets/users/students.testdata.ts +++ b/backend/tests/test_assets/users/students.testdata.ts @@ -15,7 +15,14 @@ export const TEST_STUDENTS = [ { username: 'testleerling1', firstName: 'Gerald', lastName: 'Schmittinger' }, ]; +let testStudents: Student[]; + // 🏗️ Functie die ORM entities maakt uit de data array export function makeTestStudents(em: EntityManager): Student[] { - return TEST_STUDENTS.map((data) => em.create(Student, data)); + testStudents = TEST_STUDENTS.map((data) => em.create(Student, data)); + return testStudents; +} + +export function getTestleerling1(): Student { + return testStudents.find((it) => it.username === 'testleerling1'); } diff --git a/backend/tests/test_assets/users/teachers.testdata.ts b/backend/tests/test_assets/users/teachers.testdata.ts index db726dcf..03366c80 100644 --- a/backend/tests/test_assets/users/teachers.testdata.ts +++ b/backend/tests/test_assets/users/teachers.testdata.ts @@ -2,37 +2,63 @@ import { Teacher } from '../../../src/entities/users/teacher.entity'; import { EntityManager } from '@mikro-orm/core'; export function makeTestTeachers(em: EntityManager): Teacher[] { - const teacher01 = em.create(Teacher, { + teacher01 = em.create(Teacher, { username: 'FooFighters', firstName: 'Dave', lastName: 'Grohl', }); - const teacher02 = em.create(Teacher, { + teacher02 = em.create(Teacher, { username: 'LimpBizkit', firstName: 'Fred', lastName: 'Durst', }); - const teacher03 = em.create(Teacher, { + teacher03 = em.create(Teacher, { username: 'Staind', firstName: 'Aaron', lastName: 'Lewis', }); // Should not be used, gets deleted in a unit test - const teacher04 = em.create(Teacher, { + teacher04 = em.create(Teacher, { username: 'ZesdeMetaal', firstName: 'Wannes', lastName: 'Cappelle', }); // Makes sure when logged in as testleerkracht1, there exists a corresponding user - const teacher05 = em.create(Teacher, { + testleerkracht1 = em.create(Teacher, { username: 'testleerkracht1', - firstName: 'Bob', - lastName: 'Dylan', + firstName: 'Kris', + lastName: 'Coolsaet', }); - return [teacher01, teacher02, teacher03, teacher04, teacher05]; + return [teacher01, teacher02, teacher03, teacher04, testleerkracht1]; +} + +let teacher01: Teacher; +let teacher02: Teacher; +let teacher03: Teacher; +let teacher04: Teacher; +let testleerkracht1: Teacher; + +export function getTeacher01(): Teacher { + return teacher01; +} + +export function getTeacher02(): Teacher { + return teacher02; +} + +export function getTeacher03(): Teacher { + return teacher03; +} + +export function getTeacher04(): Teacher { + return teacher04; +} + +export function getTestleerkracht1(): Teacher { + return testleerkracht1; } diff --git a/backend/tool/seed.ts b/backend/tool/seed.ts index 3ded9379..3cb4543c 100644 --- a/backend/tool/seed.ts +++ b/backend/tool/seed.ts @@ -15,7 +15,7 @@ import { makeTestStudents } from '../tests/test_assets/users/students.testdata.j import { makeTestTeachers } from '../tests/test_assets/users/teachers.testdata.js'; import { getLogger, Logger } from '../src/logging/initalize.js'; import { Collection } from '@mikro-orm/core'; -import { Group } from '../dist/entities/assignments/group.entity.js'; +import { Group } from '../src/entities/assignments/group.entity'; const logger: Logger = getLogger(); @@ -34,6 +34,7 @@ export async function seedDatabase(): Promise { const learningPaths = makeTestLearningPaths(em); const classes = makeTestClasses(em, students, teachers); const assignments = makeTestAssignemnts(em, classes); + const groups = makeTestGroups(em, students, assignments); assignments[0].groups = new Collection(groups.slice(0, 3)); diff --git a/common/src/interfaces/assignment.ts b/common/src/interfaces/assignment.ts index 9ff4fdcb..fb7dfbf0 100644 --- a/common/src/interfaces/assignment.ts +++ b/common/src/interfaces/assignment.ts @@ -7,5 +7,10 @@ export interface AssignmentDTO { description: string; learningPath: string; language: string; - groups?: GroupDTO[] | string[][]; // TODO + groups: GroupDTO[] | string[][]; +} + +export interface AssignmentDTOId { + id: number; + within: string; } diff --git a/common/src/interfaces/group.ts b/common/src/interfaces/group.ts index 6baa79a5..77721a1a 100644 --- a/common/src/interfaces/group.ts +++ b/common/src/interfaces/group.ts @@ -8,3 +8,9 @@ export interface GroupDTO { groupNumber: number; members?: string[] | StudentDTO[]; } + +export interface GroupDTOId { + class: string; + assignment: number; + groupNumber: number; +} diff --git a/common/src/interfaces/learning-content.ts b/common/src/interfaces/learning-content.ts index 435e7001..582e0086 100644 --- a/common/src/interfaces/learning-content.ts +++ b/common/src/interfaces/learning-content.ts @@ -1,14 +1,15 @@ import { Language } from '../util/language'; export interface Transition { - default: boolean; - _id: string; + default?: boolean; + _id?: string; next: { - _id: string; + _id?: string; hruid: string; version: number; language: string; }; + condition?: string; } export interface LearningObjectIdentifierDTO { @@ -18,7 +19,7 @@ export interface LearningObjectIdentifierDTO { } export interface LearningObjectNode { - _id: string; + _id?: string; learningobject_hruid: string; version: number; language: Language; @@ -30,20 +31,20 @@ export interface LearningObjectNode { } export interface LearningPath { - _id: string; + _id?: string; language: string; hruid: string; title: string; description: string; image?: string; // Image might be missing, so it's optional - num_nodes: number; - num_nodes_left: number; + num_nodes?: number; + num_nodes_left?: number; nodes: LearningObjectNode[]; keywords: string; target_ages: number[]; - min_age: number; - max_age: number; - __order: number; + min_age?: number; + max_age?: number; + __order?: number; } export interface LearningPathIdentifier { @@ -62,8 +63,8 @@ export interface ReturnValue { } export interface LearningObjectMetadata { - _id: string; - uuid: string; + _id?: string; + uuid?: string; hruid: string; version: number; language: Language; @@ -84,7 +85,7 @@ export interface LearningObjectMetadata { export interface FilteredLearningObject { key: string; - _id: string; + _id?: string; uuid: string; version: number; title: string; diff --git a/common/src/interfaces/submission.ts b/common/src/interfaces/submission.ts index 7643f0e6..9101df79 100644 --- a/common/src/interfaces/submission.ts +++ b/common/src/interfaces/submission.ts @@ -9,7 +9,7 @@ export interface SubmissionDTO { submissionNumber?: number; submitter: StudentDTO; time?: Date; - group: GroupDTO; + group?: GroupDTO; content: string; } diff --git a/eslint.config.ts b/eslint.config.ts index 30e8fe2f..68e264b8 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -71,7 +71,7 @@ export default [ 'init-declarations': 'off', '@typescript-eslint/init-declarations': 'off', 'max-params': 'off', - '@typescript-eslint/max-params': ['error', { max: 6 }], + '@typescript-eslint/max-params': 'off', '@typescript-eslint/member-ordering': 'error', '@typescript-eslint/method-signature-style': 'off', // Don't care about TypeScript strict mode. '@typescript-eslint/naming-convention': [ @@ -87,6 +87,7 @@ export default [ modifiers: ['const'], format: ['camelCase', 'UPPER_CASE'], trailingUnderscore: 'allow', + leadingUnderscore: 'allow', }, { // Enforce that private members are prefixed with an underscore diff --git a/frontend/package.json b/frontend/package.json index 4be35d40..26e7fabf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,12 +10,14 @@ "preview": "vite preview", "type-check": "vue-tsc --build", "format": "prettier --write src/", + "test:e2e": "playwright test", "format-check": "prettier --check src/", "lint": "eslint . --fix", - "test:unit": "vitest --run", - "test:e2e": "playwright test" + "pretest:unit": "tsx ../docs/api/generate.ts && npm run build", + "test:unit": "vitest --run" }, "dependencies": { + "@dwengo-1/common": "^0.1.1", "@tanstack/react-query": "^5.69.0", "@tanstack/vue-query": "^5.69.0", "@vueuse/core": "^13.1.0", diff --git a/frontend/src/controllers/assignments.ts b/frontend/src/controllers/assignments.ts index a66f8e84..0d46e311 100644 --- a/frontend/src/controllers/assignments.ts +++ b/frontend/src/controllers/assignments.ts @@ -1,11 +1,11 @@ import { BaseController } from "./base-controller"; -import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; +import type { AssignmentDTO, AssignmentDTOId } from "@dwengo-1/common/interfaces/assignment"; import type { SubmissionsResponse } from "./submissions"; import type { QuestionsResponse } from "./questions"; import type { GroupsResponse } from "./groups"; export interface AssignmentsResponse { - assignments: AssignmentDTO[] | string[]; + assignments: AssignmentDTO[] | AssignmentDTOId[]; } export interface AssignmentResponse { diff --git a/frontend/src/controllers/groups.ts b/frontend/src/controllers/groups.ts index 4c38290f..47f5c10c 100644 --- a/frontend/src/controllers/groups.ts +++ b/frontend/src/controllers/groups.ts @@ -1,10 +1,10 @@ import { BaseController } from "./base-controller"; -import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; +import type { GroupDTO, GroupDTOId } from "@dwengo-1/common/interfaces/group"; import type { SubmissionsResponse } from "./submissions"; import type { QuestionsResponse } from "./questions"; export interface GroupsResponse { - groups: GroupDTO[]; + groups: GroupDTO[] | GroupDTOId[]; } export interface GroupResponse { diff --git a/frontend/src/controllers/learning-paths.ts b/frontend/src/controllers/learning-paths.ts index f1db8b2b..bad54286 100644 --- a/frontend/src/controllers/learning-paths.ts +++ b/frontend/src/controllers/learning-paths.ts @@ -1,35 +1,33 @@ -import {BaseController} from "@/controllers/base-controller.ts"; -import {LearningPath} from "@/data-objects/learning-paths/learning-path.ts"; -import type {Language} from "@/data-objects/language.ts"; -import {single} from "@/utils/response-assertions.ts"; -import type {LearningPathDTO} from "@/data-objects/learning-paths/learning-path-dto.ts"; +import { BaseController } from "@/controllers/base-controller.ts"; +import { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; +import type { Language } from "@/data-objects/language.ts"; +import { single } from "@/utils/response-assertions.ts"; +import type { LearningPathDTO } from "@/data-objects/learning-paths/learning-path-dto.ts"; export class LearningPathController extends BaseController { constructor() { super("learningPath"); } - - async search(query: string): Promise { - const dtos = await this.get("/", {search: query}); + async search(query: string, language: string): Promise { + const dtos = await this.get("/", { search: query, language }); return dtos.map((dto) => LearningPath.fromDTO(dto)); } - async getBy( hruid: string, language: Language, - options?: { forGroup?: string; forStudent?: string }, + forGroup?: { forGroup: number; assignmentNo: number; classId: string }, ): Promise { const dtos = await this.get("/", { hruid, language, - forGroup: options?.forGroup, - forStudent: options?.forStudent, + forGroup: forGroup?.forGroup, + assignmentNo: forGroup?.assignmentNo, + classId: forGroup?.classId, }); return LearningPath.fromDTO(single(dtos)); } - async getAllByTheme(theme: string): Promise { - const dtos = await this.get("/", {theme}); + const dtos = await this.get("/", { theme }); return dtos.map((dto) => LearningPath.fromDTO(dto)); } diff --git a/frontend/src/controllers/submissions.ts b/frontend/src/controllers/submissions.ts index 0d9c73f0..0be1c122 100644 --- a/frontend/src/controllers/submissions.ts +++ b/frontend/src/controllers/submissions.ts @@ -1,5 +1,6 @@ import { BaseController } from "./base-controller"; import type { SubmissionDTO, SubmissionDTOId } from "@dwengo-1/common/interfaces/submission"; +import type { Language } from "@dwengo-1/common/util/language"; export interface SubmissionsResponse { submissions: SubmissionDTO[] | SubmissionDTOId[]; @@ -10,16 +11,36 @@ export interface SubmissionResponse { } export class SubmissionController extends BaseController { - constructor(classid: string, assignmentNumber: number, groupNumber: number) { - super(`class/${classid}/assignments/${assignmentNumber}/groups/${groupNumber}/submissions`); + constructor(hruid: string) { + super(`learningObject/${hruid}/submissions`); } - async getAll(full = true): Promise { - return this.get(`/`, { full }); + async getAll( + language: Language, + version: number, + classId: string, + assignmentId: number, + groupId?: number, + full = true, + ): Promise { + return this.get(`/`, { language, version, classId, assignmentId, groupId, full }); } - async getByNumber(submissionNumber: number): Promise { - return this.get(`/${submissionNumber}`); + async getByNumber( + language: Language, + version: number, + classId: string, + assignmentId: number, + groupId: number, + submissionNumber: number, + ): Promise { + return this.get(`/${submissionNumber}`, { + language, + version, + classId, + assignmentId, + groupId, + }); } async createSubmission(data: SubmissionDTO): Promise { diff --git a/frontend/src/i18n/locale/de.json b/frontend/src/i18n/locale/de.json index 080c1c43..d7215d77 100644 --- a/frontend/src/i18n/locale/de.json +++ b/frontend/src/i18n/locale/de.json @@ -1,13 +1,13 @@ { "welcome": "Willkommen", - "student": "schüler", - "teacher": "lehrer", + "student": "Schüler", + "teacher": "Lehrer", "assignments": "Aufgaben", - "classes": "Klasses", + "classes": "Klassen", "discussions": "Diskussionen", "login": "einloggen", "logout": "ausloggen", - "cancel": "kündigen", + "cancel": "abbrechen", "logoutVerification": "Sind Sie sicher, dass Sie sich abmelden wollen?", "homeTitle": "Unsere Stärken", "homeIntroduction1": "Wir entwickeln innovative Workshops und Bildungsressourcen, die wir in Zusammenarbeit mit Lehrern und Freiwilligen Schülern auf der ganzen Welt zur Verfügung stellen. Unsere Train-the-Trainer-Sitzungen ermöglichen es ihnen, unsere praktischen Workshops an die Schüler weiterzugeben.", @@ -23,10 +23,10 @@ "submitCode": "senden", "members": "Mitglieder", "themes": "Themen", - "choose-theme": "Wähle ein thema", + "choose-theme": "Wählen Sie ein Thema", "choose-age": "Alter auswählen", "theme-options": { - "all": "Alle themen", + "all": "Alle Themen", "culture": "Kultur", "electricity-and-mechanics": "Elektrizität und Mechanik", "nature-and-climate": "Natur und Klima", @@ -37,11 +37,11 @@ "algorithms": "Algorithmisches Denken" }, "age-options": { - "all": "Alle altersgruppen", + "all": "Alle Altersgruppen", "primary-school": "Grundschule", - "lower-secondary": "12-14 jahre alt", - "upper-secondary": "14-16 jahre alt", - "high-school": "16-18 jahre alt", + "lower-secondary": "12-14 Jahre alt", + "upper-secondary": "14-16 Jahre alt", + "high-school": "16-18 Jahre alt", "older": "18 und älter" }, "read-more": "Mehr lesen", @@ -86,9 +86,20 @@ "accept": "akzeptieren", "deny": "ablehnen", "sent": "sent", - "failed": "gescheitert", + "failed": "fehlgeschlagen", "wrong": "etwas ist schief gelaufen", "created": "erstellt", + "submitSolution": "Lösung einreichen", + "submitNewSolution": "Neue Lösung einreichen", + "markAsDone": "Als fertig markieren", + "groupSubmissions": "Einreichungen dieser Gruppe", + "taskCompleted": "Aufgabe erledigt.", + "submittedBy": "Eingereicht von", + "timestamp": "Zeitpunkt", + "loadSubmission": "Einladen", + "noSubmissionsYet": "Noch keine Lösungen eingereicht.", + "viewAsGroup": "Fortschritt ansehen von Gruppe...", + "assignLearningPath": "Als Aufgabe geben" "group": "Gruppe", "description": "Beschreibung", "no-submission": "keine vorlage", diff --git a/frontend/src/i18n/locale/en.json b/frontend/src/i18n/locale/en.json index e2dba551..d8a45837 100644 --- a/frontend/src/i18n/locale/en.json +++ b/frontend/src/i18n/locale/en.json @@ -89,6 +89,17 @@ "failed": "failed", "wrong": "something went wrong", "created": "created", + "submitSolution": "Submit solution", + "submitNewSolution": "Submit new solution", + "markAsDone": "Mark as completed", + "groupSubmissions": "This group's submissions", + "taskCompleted": "Task completed.", + "submittedBy": "Submitted by", + "timestamp": "Timestamp", + "loadSubmission": "Load", + "noSubmissionsYet": "No submissions yet.", + "viewAsGroup": "View progress of group...", + "assignLearningPath": "assign", "group": "Group", "description": "Description", "no-submission": "no submission", diff --git a/frontend/src/i18n/locale/fr.json b/frontend/src/i18n/locale/fr.json index 850f1a13..df938987 100644 --- a/frontend/src/i18n/locale/fr.json +++ b/frontend/src/i18n/locale/fr.json @@ -89,6 +89,17 @@ "failed": "échoué", "wrong": "quelque chose n'a pas fonctionné", "created": "créé", + "submitSolution": "Soumettre la solution", + "submitNewSolution": "Soumettre une nouvelle solution", + "markAsDone": "Marquer comme terminé", + "groupSubmissions": "Soumissions de ce groupe", + "taskCompleted": "Tâche terminée.", + "submittedBy": "Soumis par", + "timestamp": "Horodatage", + "loadSubmission": "Charger", + "noSubmissionsYet": "Pas encore de soumissions.", + "viewAsGroup": "Voir la progression du groupe...", + "assignLearningPath": "donner comme tâche" "group": "Groupe", "description": "Description", "no-submission": "aucune soumission", diff --git a/frontend/src/i18n/locale/nl.json b/frontend/src/i18n/locale/nl.json index e97f7425..828bcdf4 100644 --- a/frontend/src/i18n/locale/nl.json +++ b/frontend/src/i18n/locale/nl.json @@ -89,6 +89,17 @@ "failed": "mislukt", "wrong": "er ging iets verkeerd", "created": "gecreëerd", + "submitSolution": "Oplossing indienen", + "submitNewSolution": "Nieuwe oplossing indienen", + "markAsDone": "Markeren als afgewerkt", + "groupSubmissions": "Indieningen van deze groep", + "taskCompleted": "Taak afgewerkt.", + "submittedBy": "Ingediend door", + "timestamp": "Tijdstip", + "loadSubmission": "Inladen", + "noSubmissionsYet": "Nog geen indieningen.", + "viewAsGroup": "Vooruitgang bekijken van groep...", + "assignLearningPath": "Als opdracht geven" "group": "Groep", "description": "Beschrijving", "no-submission": "geen indiening", diff --git a/frontend/src/queries/groups.ts b/frontend/src/queries/groups.ts index 2ac92348..b925a298 100644 --- a/frontend/src/queries/groups.ts +++ b/frontend/src/queries/groups.ts @@ -10,7 +10,7 @@ import { useQueryClient, type UseQueryReturnType, } from "@tanstack/vue-query"; -import { computed, type MaybeRefOrGetter, toValue } from "vue"; +import { computed, toValue, type MaybeRefOrGetter } from "vue"; import { invalidateAllSubmissionKeys } from "./submissions"; type GroupsQueryKey = ["groups", string, number, boolean]; @@ -160,7 +160,7 @@ export function useDeleteGroupMutation(): UseMutationReturnType< const gn = response.group.groupNumber; await invalidateAllGroupKeys(queryClient, cid, an, gn); - await invalidateAllSubmissionKeys(queryClient, cid, an, gn); + await invalidateAllSubmissionKeys(queryClient, undefined, undefined, undefined, cid, an, gn); }, }); } diff --git a/frontend/src/queries/learning-objects.ts b/frontend/src/queries/learning-objects.ts index 3ff801b4..35ed7ae4 100644 --- a/frontend/src/queries/learning-objects.ts +++ b/frontend/src/queries/learning-objects.ts @@ -5,7 +5,7 @@ import { getLearningObjectController } from "@/controllers/controllers.ts"; import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts"; import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; -const LEARNING_OBJECT_KEY = "learningObject"; +export const LEARNING_OBJECT_KEY = "learningObject"; const learningObjectController = getLearningObjectController(); export function useLearningObjectMetadataQuery( diff --git a/frontend/src/queries/learning-paths.ts b/frontend/src/queries/learning-paths.ts index 0ce9cbbd..33402a62 100644 --- a/frontend/src/queries/learning-paths.ts +++ b/frontend/src/queries/learning-paths.ts @@ -4,19 +4,19 @@ import {useQuery, type UseQueryReturnType} from "@tanstack/vue-query"; import {getLearningPathController} from "@/controllers/controllers"; import type {LearningPath} from "@/data-objects/learning-paths/learning-path.ts"; -const LEARNING_PATH_KEY = "learningPath"; +export const LEARNING_PATH_KEY = "learningPath"; const learningPathController = getLearningPathController(); export function useGetLearningPathQuery( hruid: MaybeRefOrGetter, language: MaybeRefOrGetter, - options?: MaybeRefOrGetter<{ forGroup?: string; forStudent?: string }>, + forGroup?: MaybeRefOrGetter<{ forGroup: number; assignmentNo: number; classId: string } | undefined>, ): UseQueryReturnType { return useQuery({ - queryKey: [LEARNING_PATH_KEY, "get", hruid, language, options], + queryKey: [LEARNING_PATH_KEY, "get", hruid, language, forGroup], queryFn: async () => { - const [hruidVal, languageVal, optionsVal] = [toValue(hruid), toValue(language), toValue(options)]; - return learningPathController.getBy(hruidVal, languageVal, optionsVal); + const [hruidVal, languageVal, forGroupVal] = [toValue(hruid), toValue(language), toValue(forGroup)]; + return learningPathController.getBy(hruidVal, languageVal, forGroupVal); }, enabled: () => Boolean(toValue(hruid)) && Boolean(toValue(language)), }); @@ -34,12 +34,14 @@ export function useGetAllLearningPathsByThemeQuery( export function useSearchLearningPathQuery( query: MaybeRefOrGetter, + language: MaybeRefOrGetter, ): UseQueryReturnType { return useQuery({ - queryKey: [LEARNING_PATH_KEY, "search", query], + queryKey: [LEARNING_PATH_KEY, "search", query, language], queryFn: async () => { const queryVal = toValue(query)!; - return learningPathController.search(queryVal); + const languageVal = toValue(language)!; + return learningPathController.search(queryVal, languageVal); }, enabled: () => Boolean(toValue(query)), }); diff --git a/frontend/src/queries/submissions.ts b/frontend/src/queries/submissions.ts index b9e94e12..571931be 100644 --- a/frontend/src/queries/submissions.ts +++ b/frontend/src/queries/submissions.ts @@ -1,39 +1,39 @@ -import { SubmissionController, type SubmissionResponse, type SubmissionsResponse } from "@/controllers/submissions"; +import { SubmissionController, type SubmissionResponse } from "@/controllers/submissions"; import type { SubmissionDTO } from "@dwengo-1/common/interfaces/submission"; import { QueryClient, useMutation, + type UseMutationReturnType, useQuery, useQueryClient, - type UseMutationReturnType, type UseQueryReturnType, } from "@tanstack/vue-query"; -import { computed, toValue, type MaybeRefOrGetter } from "vue"; +import { computed, type MaybeRefOrGetter, toValue } from "vue"; +import { LEARNING_PATH_KEY } from "@/queries/learning-paths.ts"; +import { LEARNING_OBJECT_KEY } from "@/queries/learning-objects.ts"; +import type { Language } from "@dwengo-1/common/util/language"; -type SubmissionsQueryKey = ["submissions", string, number, number, boolean]; +export const SUBMISSION_KEY = "submissions"; -function submissionsQueryKey( - classid: string, - assignmentNumber: number, - groupNumber: number, - full: boolean, -): SubmissionsQueryKey { - return ["submissions", classid, assignmentNumber, groupNumber, full]; -} - -type SubmissionQueryKey = ["submission", string, number, number, number]; +type SubmissionQueryKey = ["submission", string, Language | undefined, number, string, number, number, number]; function submissionQueryKey( + hruid: string, + language: Language, + version: number, classid: string, assignmentNumber: number, groupNumber: number, submissionNumber: number, ): SubmissionQueryKey { - return ["submission", classid, assignmentNumber, groupNumber, submissionNumber]; + return ["submission", hruid, language, version, classid, assignmentNumber, groupNumber, submissionNumber]; } export async function invalidateAllSubmissionKeys( queryClient: QueryClient, + hruid?: string, + language?: Language, + version?: number, classid?: string, assignmentNumber?: number, groupNumber?: number, @@ -43,101 +43,134 @@ export async function invalidateAllSubmissionKeys( await Promise.all( keys.map(async (key) => { - const queryKey = [key, classid, assignmentNumber, groupNumber, submissionNumber].filter( - (arg) => arg !== undefined, - ); + const queryKey = [ + key, + hruid, + language, + version, + classid, + assignmentNumber, + groupNumber, + submissionNumber, + ].filter((arg) => arg !== undefined); return queryClient.invalidateQueries({ queryKey: queryKey }); }), ); await queryClient.invalidateQueries({ - queryKey: ["submissions", classid, assignmentNumber, groupNumber].filter((arg) => arg !== undefined), + queryKey: ["submissions", hruid, language, version, classid, assignmentNumber, groupNumber].filter( + (arg) => arg !== undefined, + ), }); await queryClient.invalidateQueries({ - queryKey: ["group-submissions", classid, assignmentNumber, groupNumber].filter((arg) => arg !== undefined), + queryKey: ["group-submissions", hruid, language, version, classid, assignmentNumber, groupNumber].filter( + (arg) => arg !== undefined, + ), }); await queryClient.invalidateQueries({ - queryKey: ["assignment-submissions", classid, assignmentNumber].filter((arg) => arg !== undefined), + queryKey: ["assignment-submissions", hruid, language, version, classid, assignmentNumber].filter( + (arg) => arg !== undefined, + ), }); } -function checkEnabled( - classid: string | undefined, - assignmentNumber: number | undefined, - groupNumber: number | undefined, - submissionNumber: number | undefined, -): boolean { - return ( - Boolean(classid) && - !isNaN(Number(groupNumber)) && - !isNaN(Number(assignmentNumber)) && - !isNaN(Number(submissionNumber)) - ); -} - -interface Values { - cid: string | undefined; - an: number | undefined; - gn: number | undefined; - sn: number | undefined; - f: boolean; -} - -function toValues( - classid: MaybeRefOrGetter, - assignmentNumber: MaybeRefOrGetter, - groupNumber: MaybeRefOrGetter, - submissionNumber: MaybeRefOrGetter, - full: MaybeRefOrGetter, -): Values { - return { - cid: toValue(classid), - an: toValue(assignmentNumber), - gn: toValue(groupNumber), - sn: toValue(submissionNumber), - f: toValue(full), - }; +function checkEnabled(properties: MaybeRefOrGetter[]): boolean { + return properties.every((prop) => Boolean(toValue(prop))); } export function useSubmissionsQuery( + hruid: MaybeRefOrGetter, + language: MaybeRefOrGetter, + version: MaybeRefOrGetter, classid: MaybeRefOrGetter, assignmentNumber: MaybeRefOrGetter, groupNumber: MaybeRefOrGetter, full: MaybeRefOrGetter = true, -): UseQueryReturnType { - const { cid, an, gn, sn, f } = toValues(classid, assignmentNumber, groupNumber, 1, full); - +): UseQueryReturnType { return useQuery({ - queryKey: computed(() => submissionsQueryKey(cid!, an!, gn!, f)), - queryFn: async () => new SubmissionController(cid!, an!, gn!).getAll(f), - enabled: () => checkEnabled(cid, an, gn, sn), + queryKey: ["submissions", hruid, language, version, classid, assignmentNumber, groupNumber, full], + queryFn: async () => { + const hruidVal = toValue(hruid); + const languageVal = toValue(language); + const versionVal = toValue(version); + const classIdVal = toValue(classid); + const assignmentNumberVal = toValue(assignmentNumber); + const groupNumberVal = toValue(groupNumber); + const fullVal = toValue(full); + + const response = await new SubmissionController(hruidVal!).getAll( + languageVal, + versionVal!, + classIdVal!, + assignmentNumberVal!, + groupNumberVal, + fullVal, + ); + return response ? (response.submissions as SubmissionDTO[]) : undefined; + }, + enabled: () => checkEnabled([hruid, language, version, classid, assignmentNumber]), }); } export function useSubmissionQuery( + hruid: MaybeRefOrGetter, + language: MaybeRefOrGetter, + version: MaybeRefOrGetter, classid: MaybeRefOrGetter, assignmentNumber: MaybeRefOrGetter, groupNumber: MaybeRefOrGetter, + submissionNumber: MaybeRefOrGetter, ): UseQueryReturnType { - const { cid, an, gn, sn } = toValues(classid, assignmentNumber, groupNumber, 1, true); + const hruidVal = toValue(hruid); + const languageVal = toValue(language); + const versionVal = toValue(version); + const classIdVal = toValue(classid); + const assignmentNumberVal = toValue(assignmentNumber); + const groupNumberVal = toValue(groupNumber); + const submissionNumberVal = toValue(submissionNumber); return useQuery({ - queryKey: computed(() => submissionQueryKey(cid!, an!, gn!, sn!)), - queryFn: async () => new SubmissionController(cid!, an!, gn!).getByNumber(sn!), - enabled: () => checkEnabled(cid, an, gn, sn), + queryKey: computed(() => + submissionQueryKey( + hruidVal!, + languageVal, + versionVal!, + classIdVal!, + assignmentNumberVal!, + groupNumberVal!, + submissionNumberVal!, + ), + ), + queryFn: async () => + new SubmissionController(hruidVal!).getByNumber( + languageVal, + versionVal!, + classIdVal!, + assignmentNumberVal!, + groupNumberVal!, + submissionNumberVal!, + ), + enabled: () => + Boolean(hruidVal) && + Boolean(languageVal) && + Boolean(versionVal) && + Boolean(classIdVal) && + Boolean(assignmentNumberVal) && + Boolean(submissionNumber), }); } export function useCreateSubmissionMutation(): UseMutationReturnType< SubmissionResponse, Error, - { cid: string; an: number; gn: number; data: SubmissionDTO }, + { data: SubmissionDTO }, unknown > { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ cid, an, gn, data }) => new SubmissionController(cid, an, gn).createSubmission(data), + mutationFn: async ({ data }) => + new SubmissionController(data.learningObjectIdentifier.hruid).createSubmission(data), onSuccess: async (response) => { if (!response.submission.group) { await invalidateAllSubmissionKeys(queryClient); @@ -149,7 +182,14 @@ export function useCreateSubmissionMutation(): UseMutationReturnType< const an = typeof assignment === "number" ? assignment : assignment.id; const gn = response.submission.group.groupNumber; - await invalidateAllSubmissionKeys(queryClient, cid, an, gn); + const { hruid, language, version } = response.submission.learningObjectIdentifier; + await invalidateAllSubmissionKeys(queryClient, hruid, language, version, cid, an, gn); + + await queryClient.invalidateQueries({ queryKey: [LEARNING_PATH_KEY, "get"] }); + + await queryClient.invalidateQueries({ + queryKey: [LEARNING_OBJECT_KEY, "metadata", hruid, language, version], + }); } }, }); @@ -164,7 +204,7 @@ export function useDeleteSubmissionMutation(): UseMutationReturnType< const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ cid, an, gn, sn }) => new SubmissionController(cid, an, gn).deleteSubmission(sn), + mutationFn: async ({ cid, sn }) => new SubmissionController(cid).deleteSubmission(sn), onSuccess: async (response) => { if (!response.submission.group) { await invalidateAllSubmissionKeys(queryClient); @@ -176,7 +216,9 @@ export function useDeleteSubmissionMutation(): UseMutationReturnType< const an = typeof assignment === "number" ? assignment : assignment.id; const gn = response.submission.group.groupNumber; - await invalidateAllSubmissionKeys(queryClient, cid, an, gn); + const { hruid, language, version } = response.submission.learningObjectIdentifier; + + await invalidateAllSubmissionKeys(queryClient, hruid, language, version, cid, an, gn); } }, }); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 701ec6a9..db7238ea 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -14,7 +14,7 @@ import LearningPathPage from "@/views/learning-paths/LearningPathPage.vue"; import LearningPathSearchPage from "@/views/learning-paths/LearningPathSearchPage.vue"; import UserHomePage from "@/views/homepage/UserHomePage.vue"; import SingleTheme from "@/views/SingleTheme.vue"; -import LearningObjectView from "@/views/learning-paths/LearningObjectView.vue"; +import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue"; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), diff --git a/frontend/src/utils/array-utils.ts b/frontend/src/utils/array-utils.ts new file mode 100644 index 00000000..9a4ce828 --- /dev/null +++ b/frontend/src/utils/array-utils.ts @@ -0,0 +1,5 @@ +export function copyArrayWith(index: number, newValue: T, array: T[]): T[] { + const copy = [...array]; + copy[index] = newValue; + return copy; +} diff --git a/frontend/src/utils/deep-equals.ts b/frontend/src/utils/deep-equals.ts new file mode 100644 index 00000000..4434f911 --- /dev/null +++ b/frontend/src/utils/deep-equals.ts @@ -0,0 +1,29 @@ +export function deepEquals(a: T, b: T): boolean { + if (a === b) { + return true; + } + + if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) { + return false; + } + + if (Array.isArray(a) !== Array.isArray(b)) { + return false; + } + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) { + return false; + } + return a.every((val, i) => deepEquals(val, b[i])); + } + + const keysA = Object.keys(a) as (keyof T)[]; + const keysB = Object.keys(b) as (keyof T)[]; + + if (keysA.length !== keysB.length) { + return false; + } + + return keysA.every((key) => deepEquals(a[key], b[key])); +} diff --git a/frontend/src/views/learning-paths/LearningObjectView.vue b/frontend/src/views/learning-paths/LearningObjectView.vue deleted file mode 100644 index 25fd5672..00000000 --- a/frontend/src/views/learning-paths/LearningObjectView.vue +++ /dev/null @@ -1,51 +0,0 @@ - - - - - diff --git a/frontend/src/views/learning-paths/LearningPathGroupSelector.vue b/frontend/src/views/learning-paths/LearningPathGroupSelector.vue new file mode 100644 index 00000000..f6ae4027 --- /dev/null +++ b/frontend/src/views/learning-paths/LearningPathGroupSelector.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/frontend/src/views/learning-paths/LearningPathPage.vue b/frontend/src/views/learning-paths/LearningPathPage.vue index 2a86d08d..453ac66a 100644 --- a/frontend/src/views/learning-paths/LearningPathPage.vue +++ b/frontend/src/views/learning-paths/LearningPathPage.vue @@ -3,8 +3,8 @@ import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; import { computed, type ComputedRef, ref } from "vue"; import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts"; - import { useRoute } from "vue-router"; - import LearningObjectView from "@/views/learning-paths/LearningObjectView.vue"; + import { useRoute, useRouter } from "vue-router"; + import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue"; import { useI18n } from "vue-i18n"; import LearningPathSearchField from "@/components/LearningPathSearchField.vue"; import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; @@ -12,30 +12,38 @@ import UsingQueryResult from "@/components/UsingQueryResult.vue"; import authService from "@/services/auth/auth-service.ts"; import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts"; + import LearningPathGroupSelector from "@/views/learning-paths/LearningPathGroupSelector.vue"; + const router = useRouter(); const route = useRoute(); const { t } = useI18n(); - const props = defineProps<{ hruid: string; language: Language; learningObjectHruid?: string }>(); + const props = defineProps<{ + hruid: string; + language: Language; + learningObjectHruid?: string; + }>(); - interface Personalization { - forStudent?: string; + interface LearningPathPageQuery { forGroup?: string; + assignmentNo?: string; + classId?: string; } - const personalization = computed(() => { - if (route.query.forStudent || route.query.forGroup) { + const query = computed(() => route.query as LearningPathPageQuery); + + const forGroup = computed(() => { + if (query.value.forGroup && query.value.assignmentNo && query.value.classId) { return { - forStudent: route.query.forStudent, - forGroup: route.query.forGroup, - } as Personalization; + forGroup: parseInt(query.value.forGroup), + assignmentNo: parseInt(query.value.assignmentNo), + classId: query.value.classId, + }; } - return { - forStudent: authService.authState.user?.profile?.preferred_username, - } as Personalization; + return undefined; }); - const learningPathQueryResult = useGetLearningPathQuery(props.hruid, props.language, personalization); + const learningPathQueryResult = useGetLearningPathQuery(props.hruid, props.language, forGroup); const learningObjectListQueryResult = useLearningObjectListForPathQuery(learningPathQueryResult.data); @@ -98,6 +106,25 @@ } return "notCompleted"; } + + const forGroupQueryParam = computed({ + get: () => route.query.forGroup, + set: async (value: number | undefined) => { + const query = structuredClone(route.query); + query.forGroup = value; + await router.push({ query }); + }, + }); + + async function assign(): Promise { + await router.push({ + path: "/assignment/create", + query: { + hruid: props.hruid, + language: props.language, + }, + }); + }