Merge branch 'dev' into feat/class-functionality

This commit is contained in:
laurejablonski 2025-04-19 12:04:32 +02:00
commit aaaacd181a
102 changed files with 2153 additions and 1467 deletions

View file

@ -3,13 +3,10 @@ import { themes } from '../data/themes.js';
import { FALLBACK_LANG } from '../config.js'; import { FALLBACK_LANG } from '../config.js';
import learningPathService from '../services/learning-paths/learning-path-service.js'; import learningPathService from '../services/learning-paths/learning-path-service.js';
import { Language } from '@dwengo-1/common/util/language'; 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 { BadRequestException } from '../exceptions/bad-request-exception.js';
import { NotFoundException } from '../exceptions/not-found-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. * Fetch learning paths based on query parameters.
@ -20,20 +17,20 @@ export async function getLearningPaths(req: Request, res: Response): Promise<voi
const searchQuery = req.query.search as string; const searchQuery = req.query.search as string;
const language = (req.query.language as string) || FALLBACK_LANG; const language = (req.query.language as string) || FALLBACK_LANG;
const forStudent = req.query.forStudent as string;
const forGroupNo = req.query.forGroup as string; const forGroupNo = req.query.forGroup as string;
const assignmentNo = req.query.assignmentNo as string; const assignmentNo = req.query.assignmentNo as string;
const classId = req.query.classId as string; const classId = req.query.classId as string;
let personalizationTarget: PersonalizationTarget | undefined; let forGroup: Group | undefined;
if (forStudent) { if (forGroupNo) {
personalizationTarget = await personalizedForStudent(forStudent);
} else if (forGroupNo) {
if (!assignmentNo || !classId) { if (!assignmentNo || !classId) {
throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.'); throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.');
} }
personalizationTarget = await personalizedForGroup(classId, parseInt(assignmentNo), parseInt(forGroupNo)); const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, parseInt(assignmentNo));
if (assignment) {
forGroup = (await getGroupRepository().findByAssignmentAndGroupNumber(assignment, parseInt(forGroupNo))) ?? undefined;
}
} }
let hruidList; let hruidList;
@ -48,18 +45,13 @@ export async function getLearningPaths(req: Request, res: Response): Promise<voi
throw new NotFoundException(`Theme "${themeKey}" not found.`); throw new NotFoundException(`Theme "${themeKey}" not found.`);
} }
} else if (searchQuery) { } else if (searchQuery) {
const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, personalizationTarget); const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, forGroup);
res.json(searchResults); res.json(searchResults);
return; return;
} else { } else {
hruidList = themes.flatMap((theme) => theme.hruids); hruidList = themes.flatMap((theme) => theme.hruids);
} }
const learningPaths = await learningPathService.fetchLearningPaths( const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language as Language, `HRUIDs: ${hruidList.join(', ')}`, forGroup);
hruidList,
language as Language,
`HRUIDs: ${hruidList.join(', ')}`,
personalizationTarget
);
res.json(learningPaths.data); res.json(learningPaths.data);
} }

View file

@ -17,15 +17,18 @@ export async function getSubmissionsHandler(req: Request, res: Response): Promis
const lang = languageMap[req.query.language as string] || Language.Dutch; const lang = languageMap[req.query.language as string] || Language.Dutch;
const version = parseInt(req.query.version as string) ?? 1; 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, loHruid,
lang, lang,
version, version,
req.query.classId as string, 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<void> { export async function getSubmissionHandler(req: Request, res: Response): Promise<void> {

View file

@ -61,32 +61,30 @@ export class SubmissionRepository extends DwengoEntityRepository<Submission> {
/** /**
* Looks up all submissions for the given learning object which were submitted as part of the given assignment. * 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( public async findAllSubmissionsForLearningObjectAndAssignment(loId: LearningObjectIdentifier, assignment: Assignment): Promise<Submission[]> {
loId: LearningObjectIdentifier,
assignment: Assignment,
forStudentUsername?: string
): Promise<Submission[]> {
const onBehalfOf = forStudentUsername
? {
assignment,
members: {
$some: {
username: forStudentUsername,
},
},
}
: {
assignment,
};
return this.findAll({ return this.findAll({
where: { where: {
learningObjectHruid: loId.hruid, learningObjectHruid: loId.hruid,
learningObjectLanguage: loId.language, learningObjectLanguage: loId.language,
learningObjectVersion: loId.version, 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<Submission[]> {
return this.findAll({
where: {
learningObjectHruid: loId.hruid,
learningObjectLanguage: loId.language,
learningObjectVersion: loId.version,
onBehalfOf: group,
}, },
}); });
} }

View file

@ -1,6 +1,10 @@
import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
import { LearningPath } from '../../entities/content/learning-path.entity.js'; import { LearningPath } from '../../entities/content/learning-path.entity.js';
import { Language } from '@dwengo-1/common/util/language'; 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<LearningPath> { export class LearningPathRepository extends DwengoEntityRepository<LearningPath> {
public async findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> { public async findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> {
@ -23,4 +27,27 @@ export class LearningPathRepository extends DwengoEntityRepository<LearningPath>
populate: ['nodes', 'nodes.transitions'], populate: ['nodes', 'nodes.transitions'],
}); });
} }
public createNode(nodeData: RequiredEntityData<LearningPathNode>): LearningPathNode {
return this.em.create(LearningPathNode, nodeData);
}
public createTransition(transitionData: RequiredEntityData<LearningPathTransition>): LearningPathTransition {
return this.em.create(LearningPathTransition, transitionData);
}
public async saveLearningPathNodesAndTransitions(
path: LearningPath,
nodes: LearningPathNode[],
transitions: LearningPathTransition[],
options?: { preventOverwrite?: boolean }
): Promise<void> {
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)));
}
} }

View file

@ -6,6 +6,9 @@ import { Language } from '@dwengo-1/common/util/language';
@Entity({ repository: () => SubmissionRepository }) @Entity({ repository: () => SubmissionRepository })
export class Submission { export class Submission {
@PrimaryKey({ type: 'integer', autoincrement: true })
submissionNumber?: number;
@PrimaryKey({ type: 'string' }) @PrimaryKey({ type: 'string' })
learningObjectHruid!: string; learningObjectHruid!: string;
@ -15,12 +18,9 @@ export class Submission {
}) })
learningObjectLanguage!: Language; learningObjectLanguage!: Language;
@PrimaryKey({ type: 'numeric' }) @PrimaryKey({ type: 'numeric', autoincrement: false })
learningObjectVersion = 1; learningObjectVersion = 1;
@PrimaryKey({ type: 'integer', autoincrement: true })
submissionNumber?: number;
@ManyToOne({ @ManyToOne({
entity: () => Group, entity: () => Group,
}) })

View file

@ -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 { Attachment } from './attachment.entity.js';
import { Teacher } from '../users/teacher.entity.js'; import { Teacher } from '../users/teacher.entity.js';
import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js'; import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js';
@ -42,7 +42,7 @@ export class LearningObject {
@Property({ type: 'array' }) @Property({ type: 'array' })
keywords: string[] = []; keywords: string[] = [];
@Property({ type: 'array', nullable: true }) @Property({ type: new ArrayType((i) => Number(i)), nullable: true })
targetAges?: number[] = []; targetAges?: number[] = [];
@Property({ type: 'bool' }) @Property({ type: 'bool' })

View file

@ -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 { LearningPath } from './learning-path.entity.js';
import { LearningPathTransition } from './learning-path-transition.entity.js'; import { LearningPathTransition } from './learning-path-transition.entity.js';
import { Language } from '@dwengo-1/common/util/language'; import { Language } from '@dwengo-1/common/util/language';
@Entity() @Entity()
export class LearningPathNode { export class LearningPathNode {
@PrimaryKey({ type: 'integer', autoincrement: true })
nodeNumber?: number;
@ManyToOne({ entity: () => LearningPath, primary: true }) @ManyToOne({ entity: () => LearningPath, primary: true })
learningPath!: Rel<LearningPath>; learningPath!: Rel<LearningPath>;
@PrimaryKey({ type: 'integer', autoincrement: true })
nodeNumber!: number;
@Property({ type: 'string' }) @Property({ type: 'string' })
learningObjectHruid!: string; learningObjectHruid!: string;
@ -27,7 +27,7 @@ export class LearningPathNode {
startNode!: boolean; startNode!: boolean;
@OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node' }) @OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node' })
transitions: LearningPathTransition[] = []; transitions!: Collection<LearningPathTransition>;
@Property({ length: 3 }) @Property({ length: 3 })
createdAt: Date = new Date(); createdAt: Date = new Date();

View file

@ -3,12 +3,12 @@ import { LearningPathNode } from './learning-path-node.entity.js';
@Entity() @Entity()
export class LearningPathTransition { export class LearningPathTransition {
@ManyToOne({ entity: () => LearningPathNode, primary: true })
node!: Rel<LearningPathNode>;
@PrimaryKey({ type: 'numeric' }) @PrimaryKey({ type: 'numeric' })
transitionNumber!: number; transitionNumber!: number;
@ManyToOne({ entity: () => LearningPathNode, primary: true })
node!: Rel<LearningPathNode>;
@Property({ type: 'string' }) @Property({ type: 'string' })
condition!: string; condition!: string;

View file

@ -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 { Teacher } from '../users/teacher.entity.js';
import { LearningPathRepository } from '../../data/content/learning-path-repository.js'; import { LearningPathRepository } from '../../data/content/learning-path-repository.js';
import { LearningPathNode } from './learning-path-node.entity.js'; import { LearningPathNode } from './learning-path-node.entity.js';
@ -25,5 +25,5 @@ export class LearningPath {
image: Buffer | null = null; image: Buffer | null = null;
@OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' }) @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' })
nodes: LearningPathNode[] = []; nodes: Collection<LearningPathNode> = new Collection<LearningPathNode>(this);
} }

View file

@ -1,5 +1,5 @@
import { Submission } from '../entities/assignments/submission.entity.js'; import { Submission } from '../entities/assignments/submission.entity.js';
import { mapToGroupDTO } from './group.js'; import { mapToGroupDTOId } from './group.js';
import { mapToStudentDTO } from './student.js'; import { mapToStudentDTO } from './student.js';
import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission';
import { getSubmissionRepository } from '../data/repositories.js'; import { getSubmissionRepository } from '../data/repositories.js';
@ -13,11 +13,10 @@ export function mapToSubmissionDTO(submission: Submission): SubmissionDTO {
language: submission.learningObjectLanguage, language: submission.learningObjectLanguage,
version: submission.learningObjectVersion, version: submission.learningObjectVersion,
}, },
submissionNumber: submission.submissionNumber, submissionNumber: submission.submissionNumber,
submitter: mapToStudentDTO(submission.submitter), submitter: mapToStudentDTO(submission.submitter),
time: submission.submissionTime, time: submission.submissionTime,
group: mapToGroupDTO(submission.onBehalfOf), group: mapToGroupDTOId(submission.onBehalfOf),
content: submission.content, content: submission.content,
}; };
} }

View file

@ -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})`); logger.warn(`An error occurred while handling a request: ${err} (-> HTTP ${err.status})`);
res.status(err.status).json(err); res.status(err.status).json(err);
} else { } 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); res.status(500).json(err);
} }
} }

View file

@ -5,7 +5,7 @@ const router = express.Router({ mergeParams: true });
// Root endpoint used to search objects // Root endpoint used to search objects
router.get('/', getSubmissionsHandler); router.get('/', getSubmissionsHandler);
router.post('/:id', createSubmissionHandler); router.post('/', createSubmissionHandler);
// Information about an submission with id 'id' // Information about an submission with id 'id'
router.get('/:id', getSubmissionHandler); router.get('/:id', getSubmissionHandler);

View file

@ -8,12 +8,13 @@ import {
LearningPathResponse, LearningPathResponse,
} from '@dwengo-1/common/interfaces/learning-content'; } from '@dwengo-1/common/interfaces/learning-content';
import { getLogger } from '../logging/initalize.js'; import { getLogger } from '../logging/initalize.js';
import { v4 } from 'uuid';
function filterData(data: LearningObjectMetadata, htmlUrl: string): FilteredLearningObject { function filterData(data: LearningObjectMetadata, htmlUrl: string): FilteredLearningObject {
return { return {
key: data.hruid, // Hruid learningObject (not path) key: data.hruid, // Hruid learningObject (not path)
_id: data._id, _id: data._id,
uuid: data.uuid, uuid: data.uuid || v4(),
version: data.version, version: data.version,
title: data.title, title: data.title,
htmlUrl, // Url to fetch html content htmlUrl, // Url to fetch html content

View file

@ -32,7 +32,7 @@ function convertLearningObject(learningObject: LearningObject | null): FilteredL
educationalGoals: learningObject.educationalGoals, educationalGoals: learningObject.educationalGoals,
returnValue: { returnValue: {
callback_url: learningObject.returnValue.callbackUrl, 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, skosConcepts: learningObject.skosConcepts,
targetAges: learningObject.targetAges || [], targetAges: learningObject.targetAges || [],

View file

@ -11,6 +11,7 @@ import {
LearningPathIdentifier, LearningPathIdentifier,
LearningPathResponse, LearningPathResponse,
} from '@dwengo-1/common/interfaces/learning-content'; } from '@dwengo-1/common/interfaces/learning-content';
import { v4 } from 'uuid';
const logger: Logger = getLogger(); const logger: Logger = getLogger();
@ -23,7 +24,7 @@ function filterData(data: LearningObjectMetadata): FilteredLearningObject {
return { return {
key: data.hruid, // Hruid learningObject (not path) key: data.hruid, // Hruid learningObject (not path)
_id: data._id, _id: data._id,
uuid: data.uuid, uuid: data.uuid ?? v4(),
version: data.version, version: data.version,
title: data.title, title: data.title,
htmlUrl: `/learningObject/${data.hruid}/html?language=${data.language}&version=${data.version}`, // Url to fetch html content htmlUrl: `/learningObject/${data.hruid}/html?language=${data.language}&version=${data.version}`, // Url to fetch html content

View file

@ -38,7 +38,7 @@ class GiftProcessor extends StringProcessor {
let html = "<div class='learning-object-gift'>\n"; let html = "<div class='learning-object-gift'>\n";
let i = 1; let i = 1;
for (const question of quizQuestions) { for (const question of quizQuestions) {
html += ` <div class='gift-question' id='gift-q${i}'>\n`; html += ` <div class='gift-question gift-question-type-${question.type}' id='gift-q${i}'>\n`;
html += ' ' + this.renderQuestion(question, i).replaceAll(/\n(.+)/g, '\n $1'); // Replace for indentation. html += ' ' + this.renderQuestion(question, i).replaceAll(/\n(.+)/g, '\n $1'); // Replace for indentation.
html += ` </div>\n`; html += ` </div>\n`;
i++; i++;

View file

@ -14,7 +14,7 @@ export class MultipleChoiceQuestionRenderer extends GIFTQuestionRenderer<Multipl
for (const choice of question.choices) { for (const choice of question.choices) {
renderedHtml += `<div class="gift-choice-div">\n`; renderedHtml += `<div class="gift-choice-div">\n`;
renderedHtml += ` <input type='radio' id='gift-q${questionNumber}-choice-${i}' name='gift-q${questionNumber}-choices' value="${i}"/>\n`; renderedHtml += ` <input type='radio' id='gift-q${questionNumber}-choice-${i}' name='gift-q${questionNumber}-choices' value="${i}"/>\n`;
renderedHtml += ` <label for='gift-q${questionNumber}-choice-${i}'>${choice.text}</label>\n`; renderedHtml += ` <label for='gift-q${questionNumber}-choice-${i}'>${choice.text.text}</label>\n`;
renderedHtml += `</div>\n`; renderedHtml += `</div>\n`;
i++; i++;
} }

View file

@ -4,7 +4,7 @@ import { getLearningPathRepository } from '../../data/repositories.js';
import learningObjectService from '../learning-objects/learning-object-service.js'; import learningObjectService from '../learning-objects/learning-object-service.js';
import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js';
import { LearningPathTransition } from '../../entities/content/learning-path-transition.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 { import {
FilteredLearningObject, FilteredLearningObject,
LearningObjectNode, LearningObjectNode,
@ -13,13 +13,16 @@ import {
Transition, Transition,
} from '@dwengo-1/common/interfaces/learning-content'; } from '@dwengo-1/common/interfaces/learning-content';
import { Language } from '@dwengo-1/common/util/language'; 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 * Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its
* corresponding learning object. * corresponding learning object.
* @param nodes The nodes to find the learning object for. * @param nodes The nodes to find the learning object for.
*/ */
async function getLearningObjectsForNodes(nodes: LearningPathNode[]): Promise<Map<LearningPathNode, FilteredLearningObject>> { async function getLearningObjectsForNodes(nodes: Collection<LearningPathNode>): Promise<Map<LearningPathNode, FilteredLearningObject>> {
// Fetching the corresponding learning object for each of the nodes and creating a map that maps each node to // Fetching the corresponding learning object for each of the nodes and creating a map that maps each node to
// Its corresponding learning object. // Its corresponding learning object.
const nullableNodesToLearningObjects = new Map<LearningPathNode, FilteredLearningObject | null>( const nullableNodesToLearningObjects = new Map<LearningPathNode, FilteredLearningObject | null>(
@ -44,7 +47,7 @@ async function getLearningObjectsForNodes(nodes: LearningPathNode[]): Promise<Ma
/** /**
* Convert the given learning path entity to an object which conforms to the learning path content. * Convert the given learning path entity to an object which conforms to the learning path content.
*/ */
async function convertLearningPath(learningPath: LearningPathEntity, order: number, personalizedFor?: PersonalizationTarget): Promise<LearningPath> { async function convertLearningPath(learningPath: LearningPathEntity, order: number, personalizedFor?: Group): Promise<LearningPath> {
// Fetch the corresponding learning object for each node since some parts of the expected response contains parts // 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. // With information which is not available in the LearningPathNodes themselves.
const nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> = await getLearningObjectsForNodes(learningPath.nodes); const nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> = await getLearningObjectsForNodes(learningPath.nodes);
@ -89,10 +92,10 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb
async function convertNode( async function convertNode(
node: LearningPathNode, node: LearningPathNode,
learningObject: FilteredLearningObject, learningObject: FilteredLearningObject,
personalizedFor: PersonalizationTarget | undefined, personalizedFor: Group | undefined,
nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>
): Promise<LearningObjectNode> { ): Promise<LearningObjectNode> {
const lastSubmission = personalizedFor ? await getLastSubmissionForCustomizationTarget(node, personalizedFor) : null; const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(node, personalizedFor) : null;
const transitions = node.transitions const transitions = node.transitions
.filter( .filter(
(trans) => (trans) =>
@ -108,6 +111,7 @@ async function convertNode(
updatedAt: node.updatedAt.toISOString(), updatedAt: node.updatedAt.toISOString(),
learningobject_hruid: node.learningObjectHruid, learningobject_hruid: node.learningObjectHruid,
version: learningObject.version, version: learningObject.version,
done: personalizedFor ? lastSubmission !== null : undefined,
transitions, transitions,
}; };
} }
@ -121,7 +125,7 @@ async function convertNode(
*/ */
async function convertNodes( async function convertNodes(
nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>, nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>,
personalizedFor?: PersonalizationTarget personalizedFor?: Group
): Promise<LearningObjectNode[]> { ): Promise<LearningObjectNode[]> {
const nodesPromise = Array.from(nodesToLearningObjects.entries()).map(async (entry) => const nodesPromise = Array.from(nodesToLearningObjects.entries()).map(async (entry) =>
convertNode(entry[0], entry[1], personalizedFor, nodesToLearningObjects) 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. _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. default: false, // We don't work with default transitions but retain this for backwards compatibility.
next: { 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, hruid: transition.next.learningObjectHruid,
language: nextNode.language, language: nextNode.language,
version: nextNode.version, version: nextNode.version,
@ -177,12 +181,7 @@ const databaseLearningPathProvider: LearningPathProvider = {
/** /**
* Fetch the learning paths with the given hruids from the database. * Fetch the learning paths with the given hruids from the database.
*/ */
async fetchLearningPaths( async fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: Group): Promise<LearningPathResponse> {
hruids: string[],
language: Language,
source: string,
personalizedFor?: PersonalizationTarget
): Promise<LearningPathResponse> {
const learningPathRepo = getLearningPathRepository(); const learningPathRepo = getLearningPathRepository();
const learningPaths = (await Promise.all(hruids.map(async (hruid) => learningPathRepo.findByHruidAndLanguage(hruid, language)))).filter( 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. * Search learning paths in the database using the given search string.
*/ */
async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]> { async searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]> {
const learningPathRepo = getLearningPathRepository(); const learningPathRepo = getLearningPathRepository();
const searchResults = await learningPathRepo.findByQueryStringAndLanguage(query, language); const searchResults = await learningPathRepo.findByQueryStringAndLanguage(query, language);

View file

@ -1,76 +1,22 @@
import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; 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 { Group } from '../../entities/assignments/group.entity.js';
import { Submission } from '../../entities/assignments/submission.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 { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js';
import { JSONPath } from 'jsonpath-plus'; 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. * Returns the last submission for the learning object associated with the given node and for the group
* @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.
*/ */
export async function personalizedForStudent(username: string): Promise<PersonalizationTarget | undefined> { export async function getLastSubmissionForGroup(node: LearningPathNode, pathFor: Group): Promise<Submission | null> {
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<PersonalizationTarget | undefined> {
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<Submission | null> {
const submissionRepo = getSubmissionRepository(); const submissionRepo = getSubmissionRepository();
const learningObjectId: LearningObjectIdentifier = { const learningObjectId: LearningObjectIdentifier = {
hruid: node.learningObjectHruid, hruid: node.learningObjectHruid,
language: node.language, language: node.language,
version: node.version, version: node.version,
}; };
if (pathFor.type === 'group') { return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor);
return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor.group);
}
return await submissionRepo.findMostRecentSubmissionForStudent(learningObjectId, pathFor.student);
} }
/** /**

View file

@ -1,6 +1,6 @@
import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; 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 { 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. * 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. * Fetch the learning paths with the given hruids from the data source.
*/ */
fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: PersonalizationTarget): Promise<LearningPathResponse>; fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: Group): Promise<LearningPathResponse>;
/** /**
* Search learning paths in the data source using the given search string. * Search learning paths in the data source using the given search string.
*/ */
searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]>; searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]>;
} }

View file

@ -1,13 +1,78 @@
import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider.js'; import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider.js';
import databaseLearningPathProvider from './database-learning-path-provider.js'; import databaseLearningPathProvider from './database-learning-path-provider.js';
import { envVars, getEnvVar } from '../../util/envVars.js'; import { envVars, getEnvVar } from '../../util/envVars.js';
import { PersonalizationTarget } from './learning-path-personalization-util.js'; import { LearningObjectNode, LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content';
import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content';
import { Language } from '@dwengo-1/common/util/language'; 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 userContentPrefix = getEnvVar(envVars.UserContentPrefix);
const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider]; 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<LearningPathTransition>(fromNode, transitions);
});
path.nodes = new Collection<LearningPathNode>(path, nodes);
return path;
}
/** /**
* Service providing access to data about learning paths from the appropriate data source (database or Dwengo-api) * 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 source
* @param personalizedFor If this is set, a learning path personalized for the given group or student will be returned. * @param personalizedFor If this is set, a learning path personalized for the given group or student will be returned.
*/ */
async fetchLearningPaths( async fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: Group): Promise<LearningPathResponse> {
hruids: string[],
language: Language,
source: string,
personalizedFor?: PersonalizationTarget
): Promise<LearningPathResponse> {
const userContentHruids = hruids.filter((hruid) => hruid.startsWith(userContentPrefix)); const userContentHruids = hruids.filter((hruid) => hruid.startsWith(userContentPrefix));
const nonUserContentHruids = 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. * Search learning paths in the data source using the given search string.
*/ */
async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]> { async searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]> {
const providerResponses = await Promise.all( const providerResponses = await Promise.all(
allProviders.map(async (provider) => provider.searchLearningPaths(query, language, personalizedFor)) allProviders.map(async (provider) => provider.searchLearningPaths(query, language, personalizedFor))
); );
return providerResponses.flat(); 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<void> {
const repo = getLearningPathRepository();
const path = mapToLearningPath(dto, admins);
await repo.save(path, { preventOverwrite: true });
},
}; };
export default learningPathService; export default learningPathService;

View file

@ -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 { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js';
import { NotFoundException } from '../exceptions/not-found-exception.js'; import { NotFoundException } from '../exceptions/not-found-exception.js';
import { mapToSubmission, mapToSubmissionDTO } from '../interfaces/submission.js'; import { mapToSubmission, mapToSubmissionDTO } from '../interfaces/submission.js';
@ -37,6 +37,7 @@ export async function createSubmission(submissionDTO: SubmissionDTO): Promise<Su
const submissionRepository = getSubmissionRepository(); const submissionRepository = getSubmissionRepository();
const submission = mapToSubmission(submissionDTO, submitter, group); const submission = mapToSubmission(submissionDTO, submitter, group);
await submissionRepository.save(submission); await submissionRepository.save(submission);
return mapToSubmissionDTO(submission); return mapToSubmissionDTO(submission);
@ -60,12 +61,18 @@ export async function getSubmissionsForLearningObjectAndAssignment(
version: number, version: number,
classId: string, classId: string,
assignmentId: number, assignmentId: number,
studentUsername?: string groupId?: number
): Promise<SubmissionDTO[]> { ): Promise<SubmissionDTO[]> {
const loId = new LearningObjectIdentifier(learningObjectHruid, language, version); const loId = new LearningObjectIdentifier(learningObjectHruid, language, version);
const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, assignmentId); 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)); return submissions.map((s) => mapToSubmissionDTO(s));
} }

View file

@ -27,7 +27,7 @@ export class SqliteAutoincrementSubscriber implements EventSubscriber {
for (const prop of Object.values(args.meta.properties)) { for (const prop of Object.values(args.meta.properties)) {
const property = prop as EntityProperty<T>; const property = prop as EntityProperty<T>;
if (property.primary && property.autoincrement && !(args.entity as Record<string, unknown>)[property.name]) { if (property.primary && property.autoincrement && (args.entity as Record<string, unknown>)[property.name] === undefined) {
// Obtain and increment sequence number of this entity. // Obtain and increment sequence number of this entity.
const propertyKey = args.meta.class.name + '.' + property.name; const propertyKey = args.meta.class.name + '.' + property.name;
const nextSeqNumber = this.sequenceNumbersForEntityType.get(propertyKey) || 0; const nextSeqNumber = this.sequenceNumbersForEntityType.get(propertyKey) || 0;

View file

@ -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;
}

View file

@ -35,7 +35,7 @@ describe('AssignmentRepository', () => {
const result = await assignmentRepository.findAllByResponsibleTeacher('testleerkracht1'); const result = await assignmentRepository.findAllByResponsibleTeacher('testleerkracht1');
const resultIds = result.map((it) => it.id).sort((a, b) => (a ?? 0) - (b ?? 0)); const resultIds = result.map((it) => it.id).sort((a, b) => (a ?? 0) - (b ?? 0));
expect(resultIds).toEqual([1, 3, 4]); expect(resultIds).toEqual([1, 1, 3, 4]);
}); });
it('should not find removed assignment', async () => { it('should not find removed assignment', async () => {

View file

@ -91,9 +91,9 @@ describe('SubmissionRepository', () => {
expect(result[2].submissionNumber).toBe(3); 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 () => { it('should find only the submissions for a certain learning object and assignment made for the given group', async () => {
const result = await submissionRepository.findAllSubmissionsForLearningObjectAndAssignment(loId, assignment!, 'Tool'); const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 2);
// (student Tool is in group #2) const result = await submissionRepository.findAllSubmissionsForLearningObjectAndGroup(loId, group!);
expect(result).toHaveLength(1); expect(result).toHaveLength(1);

View file

@ -2,66 +2,57 @@ import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests.js'; import { setupTestApp } from '../../setup-tests.js';
import { getAttachmentRepository, getLearningObjectRepository } from '../../../src/data/repositories.js'; import { getAttachmentRepository, getLearningObjectRepository } from '../../../src/data/repositories.js';
import { AttachmentRepository } from '../../../src/data/content/attachment-repository.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 { LearningObject } from '../../../src/entities/content/learning-object.entity.js';
import { Attachment } from '../../../src/entities/content/attachment.entity.js'; import { Attachment } from '../../../src/entities/content/attachment.entity.js';
import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier.js'; import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier.js';
import { testLearningObjectPnNotebooks } from '../../test_assets/content/learning-objects.testdata';
const NEWER_TEST_SUFFIX = 'nEweR'; import { v4 as uuidV4 } from 'uuid';
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,
};
}
describe('AttachmentRepository', () => { describe('AttachmentRepository', () => {
let attachmentRepo: AttachmentRepository; let attachmentRepo: AttachmentRepository;
let exampleLearningObjects: { older: LearningObject; newer: LearningObject }; let newLearningObject: LearningObject;
let attachmentsOlderLearningObject: Attachment[]; let attachmentsOlderLearningObject: Attachment[];
beforeAll(async () => { beforeAll(async () => {
await setupTestApp(); await setupTestApp();
attachmentsOlderLearningObject = testLearningObjectPnNotebooks.attachments as Attachment[];
attachmentRepo = getAttachmentRepository(); attachmentRepo = getAttachmentRepository();
exampleLearningObjects = await createTestLearningObjects(getLearningObjectRepository()); const learningObjectRepo = getLearningObjectRepository();
});
it('can add attachments to learning objects without throwing an error', async () => { const newLearningObjectData = structuredClone(testLearningObjectPnNotebooks);
attachmentsOlderLearningObject = Object.values(example.createAttachment).map((fn) => fn(exampleLearningObjects.older)); 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; let attachmentOnlyNewer: Attachment;
it('allows us to add attachments with the same name to a different learning object without throwing an error', async () => { 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 = structuredClone(attachmentsOlderLearningObject[0]);
attachmentOnlyNewer.content.write(NEWER_TEST_SUFFIX); attachmentOnlyNewer.learningObject = newLearningObject;
attachmentOnlyNewer.content = Buffer.from('New attachment content');
await attachmentRepo.save(attachmentOnlyNewer); await attachmentRepo.save(attachmentRepo.create(attachmentOnlyNewer));
}); });
let olderLearningObjectId: LearningObjectIdentifier; let olderLearningObjectId: LearningObjectIdentifier;
it('returns the correct attachment when queried by learningObjectId and attachment name', async () => { it('returns the correct attachment when queried by learningObjectId and attachment name', async () => {
olderLearningObjectId = { olderLearningObjectId = {
hruid: exampleLearningObjects.older.hruid, hruid: testLearningObjectPnNotebooks.hruid,
language: exampleLearningObjects.older.language, language: testLearningObjectPnNotebooks.language,
version: exampleLearningObjects.older.version, version: testLearningObjectPnNotebooks.version,
}; };
const result = await attachmentRepo.findByLearningObjectIdAndName(olderLearningObjectId, attachmentsOlderLearningObject[0].name); 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 () => { 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 () => { 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( const result = await attachmentRepo.findByMostRecentVersionOfLearningObjectAndName(
exampleLearningObjects.older.hruid, testLearningObjectPnNotebooks.hruid,
exampleLearningObjects.older.language, testLearningObjectPnNotebooks.language,
attachmentOnlyNewer.name attachmentOnlyNewer.name
); );
expect(result).toBe(attachmentOnlyNewer); expect(result).not.toBeNull();
expect(result!.name).toEqual(attachmentOnlyNewer.name);
expect(result!.content).toEqual(attachmentOnlyNewer.content);
}); });
}); });

View file

@ -1,28 +1,21 @@
import { beforeAll, describe, expect, it } from 'vitest'; import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests.js'; 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 { AttachmentRepository } from '../../../src/data/content/attachment-repository.js';
import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js'; import { testLearningObject02 } from '../../test_assets/content/learning-objects.testdata';
import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier.js';
import { Language } from '@dwengo-1/common/util/language';
describe('AttachmentRepository', () => { describe('AttachmentRepository', () => {
let attachmentRepository: AttachmentRepository; let attachmentRepository: AttachmentRepository;
let learningObjectRepository: LearningObjectRepository;
beforeAll(async () => { beforeAll(async () => {
await setupTestApp(); await setupTestApp();
attachmentRepository = getAttachmentRepository(); attachmentRepository = getAttachmentRepository();
learningObjectRepository = getLearningObjectRepository();
}); });
it('should return the requested attachment', async () => { 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( const attachment = await attachmentRepository.findByMostRecentVersionOfLearningObjectAndName(
learningObject!.hruid, testLearningObject02.hruid,
Language.English, testLearningObject02.language,
'attachment01' 'attachment01'
); );

View file

@ -2,48 +2,33 @@ import { beforeAll, describe, it, expect } from 'vitest';
import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js'; import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js';
import { setupTestApp } from '../../setup-tests.js'; import { setupTestApp } from '../../setup-tests.js';
import { getLearningObjectRepository } from '../../../src/data/repositories.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 { LearningObject } from '../../../src/entities/content/learning-object.entity.js';
import { expectToBeCorrectEntity } from '../../test-utils/expectations.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', () => { describe('LearningObjectRepository', () => {
let learningObjectRepository: LearningObjectRepository; let learningObjectRepository: LearningObjectRepository;
let exampleLearningObject: LearningObject;
beforeAll(async () => { beforeAll(async () => {
await setupTestApp(); await setupTestApp();
learningObjectRepository = getLearningObjectRepository(); learningObjectRepository = getLearningObjectRepository();
}); });
it('should be able to add a learning object to it without an error', async () => { it('should return a learning object when queried by id', async () => {
exampleLearningObject = example.createLearningObject();
await learningObjectRepository.insert(exampleLearningObject);
});
it('should return the learning object when queried by id', async () => {
const result = await learningObjectRepository.findByIdentifier({ const result = await learningObjectRepository.findByIdentifier({
hruid: exampleLearningObject.hruid, hruid: testLearningObject01.hruid,
language: exampleLearningObject.language, language: testLearningObject02.language,
version: exampleLearningObject.version, version: testLearningObject03.version,
}); });
expect(result).toBeInstanceOf(LearningObject); expect(result).toBeInstanceOf(LearningObject);
expectToBeCorrectEntity( expectToBeCorrectEntity(result!, testLearningObject01);
{
name: 'actual',
entity: result!,
},
{
name: 'expected',
entity: exampleLearningObject,
}
);
}); });
it('should return null when non-existing version is queried', async () => { it('should return null when non-existing version is queried', async () => {
const result = await learningObjectRepository.findByIdentifier({ const result = await learningObjectRepository.findByIdentifier({
hruid: exampleLearningObject.hruid, hruid: testLearningObject01.hruid,
language: exampleLearningObject.language, language: testLearningObject01.language,
version: 100, version: 100,
}); });
expect(result).toBe(null); expect(result).toBe(null);
@ -52,9 +37,12 @@ describe('LearningObjectRepository', () => {
let newerExample: LearningObject; let newerExample: LearningObject;
it('should allow a learning object with the same id except a different version to be added', async () => { it('should allow a learning object with the same id except a different version to be added', async () => {
newerExample = example.createLearningObject(); const testLearningObject01Newer = structuredClone(testLearningObject01);
newerExample.version = 10; testLearningObject01Newer.version = 10;
newerExample.title += ' (nieuw)'; testLearningObject01Newer.title += ' (nieuw)';
testLearningObject01Newer.uuid = v4();
testLearningObject01Newer.content = Buffer.from('This is the new content.');
newerExample = learningObjectRepository.create(testLearningObject01Newer);
await learningObjectRepository.save(newerExample); await learningObjectRepository.save(newerExample);
}); });
@ -66,7 +54,7 @@ describe('LearningObjectRepository', () => {
}); });
it('should return null when queried by non-existing hruid or language', async () => { 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); expect(result).toBe(null);
}); });
}); });

View file

@ -4,6 +4,7 @@ import { getLearningObjectRepository } from '../../../src/data/repositories';
import { setupTestApp } from '../../setup-tests'; import { setupTestApp } from '../../setup-tests';
import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier';
import { Language } from '@dwengo-1/common/util/language'; import { Language } from '@dwengo-1/common/util/language';
import { testLearningObject01 } from '../../test_assets/content/learning-objects.testdata';
describe('LearningObjectRepository', () => { describe('LearningObjectRepository', () => {
let learningObjectRepository: LearningObjectRepository; let learningObjectRepository: LearningObjectRepository;
@ -13,8 +14,8 @@ describe('LearningObjectRepository', () => {
learningObjectRepository = getLearningObjectRepository(); learningObjectRepository = getLearningObjectRepository();
}); });
const id01 = new LearningObjectIdentifier('id01', Language.English, 1); const id01 = new LearningObjectIdentifier(testLearningObject01.hruid, testLearningObject01.language, testLearningObject01.version);
const id02 = new LearningObjectIdentifier('test_id', Language.English, 1); const id02 = new LearningObjectIdentifier('non_existing_id', Language.English, 1);
it('should return the learning object that matches identifier 1', async () => { it('should return the learning object that matches identifier 1', async () => {
const learningObject = await learningObjectRepository.findByIdentifier(id01); const learningObject = await learningObjectRepository.findByIdentifier(id01);

View file

@ -2,41 +2,27 @@ import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests.js'; import { setupTestApp } from '../../setup-tests.js';
import { getLearningPathRepository } from '../../../src/data/repositories.js'; import { getLearningPathRepository } from '../../../src/data/repositories.js';
import { LearningPathRepository } from '../../../src/data/content/learning-path-repository.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 { 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'; import { Language } from '@dwengo-1/common/util/language';
import { testLearningPath01 } from '../../test_assets/content/learning-paths.testdata';
function expectToHaveFoundPrecisely(expected: LearningPath, result: LearningPath[]): void { import { mapToLearningPath } from '../../../src/services/learning-paths/learning-path-service';
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);
}
describe('LearningPathRepository', () => { describe('LearningPathRepository', () => {
let learningPathRepo: LearningPathRepository; let learningPathRepo: LearningPathRepository;
let examplePath: LearningPath;
beforeAll(async () => { beforeAll(async () => {
await setupTestApp(); await setupTestApp();
learningPathRepo = getLearningPathRepository(); learningPathRepo = getLearningPathRepository();
examplePath = mapToLearningPath(testLearningPath01, []);
}); });
let examplePath: LearningPath; 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);
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);
expect(result).toBeInstanceOf(LearningPath); 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 () => { 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 () => { 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); expectToHaveFoundPrecisely(examplePath, result);
}); });

View file

@ -3,6 +3,7 @@ import { getLearningPathRepository } from '../../../src/data/repositories';
import { LearningPathRepository } from '../../../src/data/content/learning-path-repository'; import { LearningPathRepository } from '../../../src/data/content/learning-path-repository';
import { setupTestApp } from '../../setup-tests'; import { setupTestApp } from '../../setup-tests';
import { Language } from '@dwengo-1/common/util/language'; import { Language } from '@dwengo-1/common/util/language';
import { testLearningPath01 } from '../../test_assets/content/learning-paths.testdata';
describe('LearningPathRepository', () => { describe('LearningPathRepository', () => {
let learningPathRepository: LearningPathRepository; let learningPathRepository: LearningPathRepository;
@ -19,10 +20,10 @@ describe('LearningPathRepository', () => {
}); });
it('should return requested learning path', async () => { 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).toBeTruthy();
expect(learningPath?.title).toBe('repertoire Tool'); expect(learningPath?.title).toBe(testLearningPath01.title);
expect(learningPath?.description).toBe('all about Tool'); expect(learningPath?.description).toBe(testLearningPath01.description);
}); });
}); });

View file

@ -1,37 +1,32 @@
import { beforeAll, describe, expect, it } from 'vitest'; import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests'; 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 { LearningObject } from '../../../src/entities/content/learning-object.entity';
import databaseLearningObjectProvider from '../../../src/services/learning-objects/database-learning-object-provider'; import databaseLearningObjectProvider from '../../../src/services/learning-objects/database-learning-object-provider';
import { expectToBeCorrectFilteredLearningObject } from '../../test-utils/expectations'; import { expectToBeCorrectFilteredLearningObject } from '../../test-utils/expectations';
import { Language } from '@dwengo-1/common/util/language'; import { Language } from '@dwengo-1/common/util/language';
import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; import { FilteredLearningObject, LearningObjectNode, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
import learningPathExample from '../../test-assets/learning-paths/pn-werking-example'; import { testPartiallyDatabaseAndPartiallyDwengoApiLearningPath } from '../../test_assets/content/learning-paths.testdata';
import { LearningPath } from '../../../src/entities/content/learning-path.entity'; import { testLearningObjectPnNotebooks } from '../../test_assets/content/learning-objects.testdata';
import { FilteredLearningObject } from '@dwengo-1/common/interfaces/learning-content'; import { LearningPath } from '@dwengo-1/common/dist/interfaces/learning-content';
import { RequiredEntityData } from '@mikro-orm/core';
async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { import { getHtmlRenderingForTestLearningObject } from '../../test-utils/get-html-rendering';
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 };
}
const EXPECTED_TITLE_FROM_DWENGO_LEARNING_OBJECT = 'Notebook opslaan'; const EXPECTED_TITLE_FROM_DWENGO_LEARNING_OBJECT = 'Notebook opslaan';
describe('DatabaseLearningObjectProvider', () => { describe('DatabaseLearningObjectProvider', () => {
let exampleLearningObject: LearningObject; let exampleLearningObject: RequiredEntityData<LearningObject>;
let exampleLearningPath: LearningPath; let exampleLearningPath: LearningPath;
let exampleLearningPathId: LearningPathIdentifier;
beforeAll(async () => { beforeAll(async () => {
await setupTestApp(); await setupTestApp();
const exampleData = await initExampleData(); exampleLearningObject = testLearningObjectPnNotebooks;
exampleLearningObject = exampleData.learningObject; exampleLearningPath = testPartiallyDatabaseAndPartiallyDwengoApiLearningPath;
exampleLearningPath = exampleData.learningPath;
exampleLearningPathId = {
hruid: exampleLearningPath.hruid,
language: exampleLearningPath.language as Language,
};
}); });
describe('getLearningObjectById', () => { describe('getLearningObjectById', () => {
it('should return the learning object when it is queried by its id', async () => { 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 () => { it('should return the correct rendering of the learning object', async () => {
const result = await databaseLearningObjectProvider.getLearningObjectHTML(exampleLearningObject); const result = await databaseLearningObjectProvider.getLearningObjectHTML(exampleLearningObject);
// Set newlines so your tests are platform-independent. // 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 () => { it('should return null for a non-existing learning object', async () => {
const result = await databaseLearningObjectProvider.getLearningObjectHTML({ const result = await databaseLearningObjectProvider.getLearningObjectHTML({
@ -73,8 +68,8 @@ describe('DatabaseLearningObjectProvider', () => {
}); });
describe('getLearningObjectIdsFromPath', () => { describe('getLearningObjectIdsFromPath', () => {
it('should return all learning object IDs from a path', async () => { it('should return all learning object IDs from a path', async () => {
const result = await databaseLearningObjectProvider.getLearningObjectIdsFromPath(exampleLearningPath); const result = await databaseLearningObjectProvider.getLearningObjectIdsFromPath(exampleLearningPathId);
expect(new Set(result)).toEqual(new Set(exampleLearningPath.nodes.map((it) => it.learningObjectHruid))); 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 () => { it('should throw an error if queried with a path identifier for which there is no learning path', async () => {
await expect( await expect(
@ -89,9 +84,11 @@ describe('DatabaseLearningObjectProvider', () => {
}); });
describe('getLearningObjectsFromPath', () => { describe('getLearningObjectsFromPath', () => {
it('should correctly return all learning objects which are on the path, even those who are not in the database', async () => { 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(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); expect(result.map((it) => it.title)).toContainEqual(EXPECTED_TITLE_FROM_DWENGO_LEARNING_OBJECT);
}); });

View file

@ -1,14 +1,14 @@
import { beforeAll, describe, expect, it } from 'vitest'; import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests'; import { setupTestApp } from '../../setup-tests';
import { LearningObject } from '../../../src/entities/content/learning-object.entity'; 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 learningObjectService from '../../../src/services/learning-objects/learning-object-service';
import { envVars, getEnvVar } from '../../../src/util/envVars'; import { envVars, getEnvVar } from '../../../src/util/envVars';
import { LearningPath } from '../../../src/entities/content/learning-path.entity'; import { LearningObjectIdentifierDTO, LearningPath as LearningPathDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
import learningPathExample from '../../test-assets/learning-paths/pn-werking-example';
import { LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
import { Language } from '@dwengo-1/common/util/language'; 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 EXPECTED_DWENGO_LEARNING_OBJECT_TITLE = 'Werken met notebooks';
const DWENGO_TEST_LEARNING_OBJECT_ID: LearningObjectIdentifierDTO = { 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']); 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', () => { describe('LearningObjectService', () => {
let exampleLearningObject: LearningObject; let exampleLearningObject: RequiredEntityData<LearningObject>;
let exampleLearningPath: LearningPath; let exampleLearningPath: LearningPathDTO;
let exampleLearningPathId: LearningPathIdentifier;
beforeAll(async () => { beforeAll(async () => {
await setupTestApp(); await setupTestApp();
const exampleData = await initExampleData(); exampleLearningObject = testLearningObjectPnNotebooks;
exampleLearningObject = exampleData.learningObject; exampleLearningPath = testPartiallyDatabaseAndPartiallyDwengoApiLearningPath;
exampleLearningPath = exampleData.learningPath;
exampleLearningPathId = {
hruid: exampleLearningPath.hruid,
language: exampleLearningPath.language as Language,
};
}); });
describe('getLearningObjectById', () => { describe('getLearningObjectById', () => {
@ -69,7 +64,7 @@ describe('LearningObjectService', () => {
const result = await learningObjectService.getLearningObjectHTML(exampleLearningObject); const result = await learningObjectService.getLearningObjectHTML(exampleLearningObject);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
// Set newlines so your tests are platform-independent. // 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( it(
'returns the same HTML as the Dwengo API when queried with the identifier of a learning object that does ' + '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', () => { describe('getLearningObjectsFromPath', () => {
it('returns all learning objects when a learning path in the database is queried', async () => { it('returns all learning objects when a learning path in the database is queried', async () => {
const result = await learningObjectService.getLearningObjectsFromPath(exampleLearningPath); const result = await learningObjectService.getLearningObjectsFromPath(exampleLearningPathId);
expect(result.map((it) => it.key)).toEqual(exampleLearningPath.nodes.map((it) => it.learningObjectHruid)); 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 () => { 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); const result = await learningObjectService.getLearningObjectsFromPath(DWENGO_TEST_LEARNING_PATH_ID);
@ -115,8 +110,8 @@ describe('LearningObjectService', () => {
describe('getLearningObjectIdsFromPath', () => { describe('getLearningObjectIdsFromPath', () => {
it('returns all learning objects when a learning path in the database is queried', async () => { it('returns all learning objects when a learning path in the database is queried', async () => {
const result = await learningObjectService.getLearningObjectIdsFromPath(exampleLearningPath); const result = await learningObjectService.getLearningObjectIdsFromPath(exampleLearningPathId);
expect(result).toEqual(exampleLearningPath.nodes.map((it) => it.learningObjectHruid)); 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 () => { 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); const result = await learningObjectService.getLearningObjectIdsFromPath(DWENGO_TEST_LEARNING_PATH_ID);

View file

@ -1,26 +1,35 @@
import { describe, expect, it } from 'vitest'; import { beforeAll, 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 processingService from '../../../../src/services/learning-objects/processing/processing-service'; 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', () => { describe('ProcessingService', () => {
beforeAll(async () => {
await setupTestApp();
});
it('renders a markdown learning object correctly', async () => { it('renders a markdown learning object correctly', async () => {
const markdownLearningObject = mdExample.createLearningObject(); const markdownLearningObject = getLearningObjectRepository().create(testLearningObjectPnNotebooks);
const result = await processingService.render(markdownLearningObject); const result = await processingService.render(markdownLearningObject);
// Set newlines so your tests are platform-independent. // 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 () => { it('renders a multiple choice question correctly', async () => {
const multipleChoiceLearningObject = multipleChoiceExample.createLearningObject(); const testLearningObject = getLearningObjectRepository().create(testLearningObjectMultipleChoice);
const result = await processingService.render(multipleChoiceLearningObject); const result = await processingService.render(testLearningObject);
expect(result).toEqual(multipleChoiceExample.getHTMLRendering().replace(/\r\n/g, '\n')); expect(result).toEqual(getHtmlRenderingForTestLearningObject(testLearningObjectMultipleChoice).replace(/\r\n/g, '\n'));
}); });
it('renders an essay question correctly', async () => { it('renders an essay question correctly', async () => {
const essayLearningObject = essayExample.createLearningObject(); const essayLearningObject = getLearningObjectRepository().create(testLearningObjectEssayQuestion);
const result = await processingService.render(essayLearningObject); 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'));
}); });
}); });

View file

@ -2,227 +2,112 @@ import { beforeAll, describe, expect, it } from 'vitest';
import { LearningObject } from '../../../src/entities/content/learning-object.entity.js'; import { LearningObject } from '../../../src/entities/content/learning-object.entity.js';
import { setupTestApp } from '../../setup-tests.js'; import { setupTestApp } from '../../setup-tests.js';
import { LearningPath } from '../../../src/entities/content/learning-path.entity.js'; import { LearningPath } from '../../../src/entities/content/learning-path.entity.js';
import { import { getSubmissionRepository } from '../../../src/data/repositories.js';
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 databaseLearningPathProvider from '../../../src/services/learning-paths/database-learning-path-provider.js'; import databaseLearningPathProvider from '../../../src/services/learning-paths/database-learning-path-provider.js';
import { expectToBeCorrectLearningPath } from '../../test-utils/expectations.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 { 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 { 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'; function expectBranchingObjectNode(result: LearningPathResponse): LearningObjectNode {
const STUDENT_B_USERNAME = 'student_b'; const branchingObjectMatches = result.data![0].nodes.filter((it) => it.learningobject_hruid === testLearningObjectMultipleChoice.hruid);
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
);
expect(branchingObjectMatches.length).toBe(1); expect(branchingObjectMatches.length).toBe(1);
return branchingObjectMatches[0]; return branchingObjectMatches[0];
} }
describe('DatabaseLearningPathProvider', () => { describe('DatabaseLearningPathProvider', () => {
let example: { learningObject: LearningObject; learningPath: LearningPath }; let testLearningPath: LearningPath;
let persTestData: { learningContent: ConditionTestLearningPathAndLearningObjects; studentA: Student; studentB: Student }; let branchingLearningObject: RequiredEntityData<LearningObject>;
let extraExerciseLearningObject: RequiredEntityData<LearningObject>;
let finalLearningObject: RequiredEntityData<LearningObject>;
let groupA: Group;
let groupB: Group;
beforeAll(async () => { beforeAll(async () => {
await setupTestApp(); await setupTestApp();
example = await initExampleData(); testLearningPath = mapToLearningPath(testLearningPathWithConditions, []);
persTestData = await initPersonalizationTestData(); 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', () => { describe('fetchLearningPaths', () => {
it('returns the learning path correctly', async () => { it('returns the learning path correctly', async () => {
const result = await databaseLearningPathProvider.fetchLearningPaths( const result = await databaseLearningPathProvider.fetchLearningPaths([testLearningPath.hruid], testLearningPath.language, 'the source');
[example.learningPath.hruid],
example.learningPath.language,
'the source'
);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.data?.length).toBe(1); expect(result.data?.length).toBe(1);
const learningObjectsOnPath = ( expectToBeCorrectLearningPath(result.data![0], testLearningPathWithConditions);
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);
}); });
it('returns the correct personalized learning path', async () => { it('returns the correct personalized learning path', async () => {
// For student A: // For student A:
let result = await databaseLearningPathProvider.fetchLearningPaths( let result = await databaseLearningPathProvider.fetchLearningPaths(
[persTestData.learningContent.learningPath.hruid], [testLearningPath.hruid],
persTestData.learningContent.learningPath.language, testLearningPath.language,
'the source', 'the source',
{ type: 'student', student: persTestData.studentA } groupA
); );
expect(result.success).toBeTruthy(); expect(result.success).toBeTruthy();
expect(result.data?.length).toBe(1); expect(result.data?.length).toBe(1);
// There should be exactly one branching object // 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 === 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 === persTestData.learningContent.extraExerciseObject.hruid).length).toBe( expect(branchingObject.transitions.filter((it) => it.next.hruid === extraExerciseLearningObject.hruid).length).toBe(1); // There should however be a path to the extra exercise object.
1
); // There should however be a path to the extra exercise object.
// For student B: // For student B:
result = await databaseLearningPathProvider.fetchLearningPaths( result = await databaseLearningPathProvider.fetchLearningPaths([testLearningPath.hruid], testLearningPath.language, 'the source', groupB);
[persTestData.learningContent.learningPath.hruid],
persTestData.learningContent.learningPath.language,
'the source',
{ type: 'student', student: persTestData.studentB }
);
expect(result.success).toBeTruthy(); expect(result.success).toBeTruthy();
expect(result.data?.length).toBe(1); expect(result.data?.length).toBe(1);
// There should still be exactly one branching object // There should still be exactly one branching object
branchingObject = expectBranchingObjectNode(result, persTestData); branchingObject = expectBranchingObjectNode(result);
// However, now the student picks the other option. // 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 === 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 === persTestData.learningContent.extraExerciseObject.hruid).length).toBe( 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.
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 () => { it('returns a non-successful response if a non-existing learning path is queried', async () => {
const result = await databaseLearningPathProvider.fetchLearningPaths( const result = await databaseLearningPathProvider.fetchLearningPaths(
[example.learningPath.hruid], [testLearningPath.hruid],
Language.Abkhazian, // Wrong language Language.Abkhazian, // Wrong language
'the source' 'the source'
); );
@ -233,27 +118,24 @@ describe('DatabaseLearningPathProvider', () => {
describe('searchLearningPaths', () => { describe('searchLearningPaths', () => {
it('returns the correct learning path when queried with a substring of its title', async () => { it('returns the correct learning path when queried with a substring of its title', async () => {
const result = await databaseLearningPathProvider.searchLearningPaths( const result = await databaseLearningPathProvider.searchLearningPaths(testLearningPath.title.substring(2, 6), testLearningPath.language);
example.learningPath.title.substring(2, 6),
example.learningPath.language
);
expect(result.length).toBe(1); expect(result.length).toBe(1);
expect(result[0].title).toBe(example.learningPath.title); expect(result[0].title).toBe(testLearningPath.title);
expect(result[0].description).toBe(example.learningPath.description); expect(result[0].description).toBe(testLearningPath.description);
}); });
it('returns the correct learning path when queried with a substring of the description', async () => { it('returns the correct learning path when queried with a substring of the description', async () => {
const result = await databaseLearningPathProvider.searchLearningPaths( const result = await databaseLearningPathProvider.searchLearningPaths(
example.learningPath.description.substring(5, 12), testLearningPath.description.substring(5, 12),
example.learningPath.language testLearningPath.language
); );
expect(result.length).toBe(1); expect(result.length).toBe(1);
expect(result[0].title).toBe(example.learningPath.title); expect(result[0].title).toBe(testLearningPath.title);
expect(result[0].description).toBe(example.learningPath.description); 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 () => { 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( const result = await databaseLearningPathProvider.searchLearningPaths(
'substring which does not occur in the title or the description of a learning object', '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); expect(result.length).toBe(0);
}); });

View file

@ -1,22 +1,9 @@
import { beforeAll, describe, expect, it } from 'vitest'; import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests'; 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 learningPathService from '../../../src/services/learning-paths/learning-path-service';
import { Language } from '@dwengo-1/common/util/language'; import { Language } from '@dwengo-1/common/util/language';
import { testPartiallyDatabaseAndPartiallyDwengoApiLearningPath } from '../../test_assets/content/learning-paths.testdata';
async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { import { LearningPath as LearningPathDTO } from '@dwengo-1/common/interfaces/learning-content';
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 };
}
const TEST_DWENGO_LEARNING_PATH_HRUID = 'pn_werking'; const TEST_DWENGO_LEARNING_PATH_HRUID = 'pn_werking';
const TEST_DWENGO_LEARNING_PATH_TITLE = 'Werken met notebooks'; 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<p9<U8SDP8UP9'; const TEST_SEARCH_QUERY_EXPECTING_NO_MATCHES = 'su$m8f9usf89ud<p9<U8SDP8UP9';
describe('LearningPathService', () => { describe('LearningPathService', () => {
let example: { learningObject: LearningObject; learningPath: LearningPath }; let testLearningPath: LearningPathDTO;
beforeAll(async () => { beforeAll(async () => {
await setupTestApp(); await setupTestApp();
example = await initExampleData(); testLearningPath = testPartiallyDatabaseAndPartiallyDwengoApiLearningPath;
}); });
describe('fetchLearningPaths', () => { describe('fetchLearningPaths', () => {
it('should return learning paths both from the database and from the Dwengo API', async () => { it('should return learning paths both from the database and from the Dwengo API', async () => {
const result = await learningPathService.fetchLearningPaths( const result = await learningPathService.fetchLearningPaths(
[example.learningPath.hruid, TEST_DWENGO_LEARNING_PATH_HRUID], [testLearningPath.hruid, TEST_DWENGO_LEARNING_PATH_HRUID],
example.learningPath.language, testLearningPath.language as Language,
'the source' 'the source'
); );
expect(result.success).toBeTruthy(); 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 === 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 === 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 () => { 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.success).toBeTruthy();
expect(result.data?.length).toBe(1); expect(result.data?.length).toBe(1);
// Should include all the nodes, even those pointing to foreign learning objects. // 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( 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', () => { describe('searchLearningPath', () => {
it('should include both the learning paths from the Dwengo API and those from the database in its response', async () => { 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. // 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 // 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. // But should not only find that one.
expect(result.length).not.toBeLessThan(2); expect(result.length).not.toBeLessThan(2);
@ -71,7 +65,7 @@ describe('LearningPathService', () => {
expect(result.length).not.toBe(0); expect(result.length).not.toBe(0);
// But not the example learning path. // 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 () => { 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); const result = await learningPathService.searchLearningPaths(TEST_SEARCH_QUERY_EXPECTING_NO_MATCHES, Language.Dutch);

View file

@ -14,6 +14,7 @@ import { makeTestQuestions } from './test_assets/questions/questions.testdata.js
import { makeTestAnswers } from './test_assets/questions/answers.testdata.js'; import { makeTestAnswers } from './test_assets/questions/answers.testdata.js';
import { makeTestSubmissions } from './test_assets/assignments/submission.testdata.js'; import { makeTestSubmissions } from './test_assets/assignments/submission.testdata.js';
import { Collection } from '@mikro-orm/core'; import { Collection } from '@mikro-orm/core';
import { Group } from '../src/entities/assignments/group.entity';
export async function setupTestApp(): Promise<void> { export async function setupTestApp(): Promise<void> {
dotenv.config({ path: '.env.test' }); dotenv.config({ path: '.env.test' });
@ -29,8 +30,8 @@ export async function setupTestApp(): Promise<void> {
const assignments = makeTestAssignemnts(em, classes); const assignments = makeTestAssignemnts(em, classes);
const groups = makeTestGroups(em, students, assignments); const groups = makeTestGroups(em, students, assignments);
assignments[0].groups = new Collection(groups.slice(0, 3)); assignments[0].groups = new Collection<Group>(groups.slice(0, 3));
assignments[1].groups = new Collection(groups.slice(3, 4)); assignments[1].groups = new Collection<Group>(groups.slice(3, 4));
const teacherInvitations = makeTestTeacherInvitations(em, teachers, classes); const teacherInvitations = makeTestTeacherInvitations(em, teachers, classes);
const classJoinRequests = makeTestClassJoinRequests(em, students, classes); const classJoinRequests = makeTestClassJoinRequests(em, students, classes);

View file

@ -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;
}

View file

@ -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(),
};
}

View file

@ -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<string, (owner: LearningObject) => Attachment>;
getHTMLRendering: () => string;
}

View file

@ -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;

View file

@ -1,2 +0,0 @@
::MC basic::
How are you? {}

View file

@ -1,7 +0,0 @@
<div class="learning-object-gift">
<div id="gift-q1" class="gift-question">
<h2 id="gift-q1-title" class="gift-title">MC basic</h2>
<p id="gift-q1-stem" class="gift-stem">How are you?</p>
<textarea id="gift-q1-answer" class="gift-essay-answer"></textarea>
</div>
</div>

View file

@ -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;

View file

@ -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;

View file

@ -1,3 +0,0 @@
interface LearningPathExample {
createLearningPath: () => LearningPath;
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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,
};
}

View file

@ -1,8 +1,8 @@
import { AssertionError } from 'node:assert'; import { AssertionError } from 'node:assert';
import { LearningObject } from '../../src/entities/content/learning-object.entity'; 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 { expect } from 'vitest';
import { FilteredLearningObject, LearningPath } from '@dwengo-1/common/interfaces/learning-content'; 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. // Ignored properties because they belang for example to the class, not to the entity itself.
const IGNORE_PROPERTIES = ['parent']; const IGNORE_PROPERTIES = ['parent'];
@ -11,65 +11,56 @@ const IGNORE_PROPERTIES = ['parent'];
* Checks if the actual entity from the database conforms to the entity that was added previously. * 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 actual The actual entity retrieved from the database
* @param expected The (previously added) entity we would expect to retrieve * @param expected The (previously added) entity we would expect to retrieve
* @param propertyPrefix Prefix to append to property in error messages.
*/ */
export function expectToBeCorrectEntity<T extends object>(actual: { entity: T; name?: string }, expected: { entity: T; name?: string }): void { export function expectToBeCorrectEntity<T extends object>(actual: T, expected: T, propertyPrefix = ''): void {
if (!actual.name) { for (const property in expected) {
actual.name = 'actual'; if (Object.prototype.hasOwnProperty.call(expected, property)) {
} const prefixedProperty = propertyPrefix + property;
if (!expected.name) {
expected.name = 'expected';
}
for (const property in expected.entity) {
if ( if (
property in IGNORE_PROPERTIES && 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. 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.entity[property] !== 'function' // Functions obviously are not persisted via the database typeof expected[property] !== 'function' // Functions obviously are not persisted via the database
) { ) {
if (!Object.prototype.hasOwnProperty.call(actual.entity, property)) { if (!Object.prototype.hasOwnProperty.call(actual, property)) {
throw new AssertionError({ throw new AssertionError({
message: `${expected.name} has defined property ${property}, but ${actual.name} is missing it.`, message: `Expected property ${prefixedProperty}, but it is missing.`,
}); });
} }
if (typeof expected.entity[property] === 'boolean') { if (typeof expected[property] === 'boolean') {
// Sometimes, booleans get represented by numbers 0 and 1 in the objects actual from the database. // 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])) { if (Boolean(expected[property]) !== Boolean(actual[property])) {
throw new AssertionError({ throw new AssertionError({
message: `${property} was ${expected.entity[property]} in ${expected.name}, message: `Expected ${prefixedProperty} to be ${expected[property]},
but ${actual.entity[property]} (${Boolean(expected.entity[property])}) in ${actual.name}`, but was ${actual[property]} (${Boolean(expected[property])}).`,
}); });
} }
} else if (typeof expected.entity[property] !== typeof actual.entity[property]) { } else if (typeof expected[property] !== typeof actual[property]) {
throw new AssertionError({ throw new AssertionError({
message: `${property} has type ${typeof expected.entity[property]} in ${expected.name}, but type ${typeof 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.entity[property] === 'object') { } else if (typeof expected[property] === 'object') {
expectToBeCorrectEntity( expectToBeCorrectEntity(actual[property] as object, expected[property] as object, property);
{
name: actual.name + '.' + property,
entity: actual.entity[property] as object,
},
{
name: expected.name + '.' + property,
entity: expected.entity[property] as object,
}
);
} else { } else {
if (expected.entity[property] !== actual.entity[property]) { if (expected[property] !== actual[property]) {
throw new AssertionError({ throw new AssertionError({
message: `${property} was ${expected.entity[property]} in ${expected.name}, but ${actual.entity[property]} in ${actual.name}`, message: `${prefixedProperty} was expected to be ${expected[property]}, ` + `but was ${actual[property]}.`,
}); });
} }
} }
} }
} }
} }
}
/** /**
* Checks that filtered is the correct representation of original as FilteredLearningObject. * Checks that filtered is the correct representation of original as FilteredLearningObject.
* @param filtered the representation 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<LearningObject>): void {
expect(filtered.uuid).toEqual(original.uuid); expect(filtered.uuid).toEqual(original.uuid);
expect(filtered.version).toEqual(original.version); expect(filtered.version).toEqual(original.version);
expect(filtered.language).toEqual(original.language); 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. * is a correct representation of the given learning path entity.
* *
* @param learningPath The learning path returned by the retriever, service or endpoint * @param learningPath The learning path returned by the retriever, service or endpoint
* @param expectedEntity The expected entity * @param expected The learning path that should have been returned.
* @param learningObjectsOnPath The learning objects on LearningPath. Necessary since some information in
* the learning path returned from the API endpoint
*/ */
export function expectToBeCorrectLearningPath( export function expectToBeCorrectLearningPath(learningPath: LearningPath, expected: LearningPath): void {
learningPath: LearningPath, expect(learningPath.hruid).toEqual(expected.hruid);
expectedEntity: LearningPathEntity, expect(learningPath.language).toEqual(expected.language);
learningObjectsOnPath: FilteredLearningObject[] expect(learningPath.description).toEqual(expected.description);
): void { expect(learningPath.title).toEqual(expected.title);
expect(learningPath.hruid).toEqual(expectedEntity.hruid);
expect(learningPath.language).toEqual(expectedEntity.language);
expect(learningPath.description).toEqual(expectedEntity.description);
expect(learningPath.title).toEqual(expectedEntity.title);
const keywords = new Set(learningObjectsOnPath.flatMap((it) => it.keywords || [])); expect(new Set(learningPath.keywords.split(' '))).toEqual(new Set(learningPath.keywords.split(' ')));
expect(new Set(learningPath.keywords.split(' '))).toEqual(keywords);
const targetAges = new Set(learningObjectsOnPath.flatMap((it) => it.targetAges || [])); expect(new Set(learningPath.target_ages)).toEqual(new Set(expected.target_ages));
expect(new Set(learningPath.target_ages)).toEqual(targetAges); expect(learningPath.min_age).toEqual(Math.min(...expected.target_ages));
expect(learningPath.min_age).toEqual(Math.min(...targetAges)); expect(learningPath.max_age).toEqual(Math.max(...expected.target_ages));
expect(learningPath.max_age).toEqual(Math.max(...targetAges));
expect(learningPath.num_nodes).toEqual(expectedEntity.nodes.length); expect(learningPath.num_nodes).toEqual(expected.nodes.length);
expect(learningPath.image || null).toEqual(expectedEntity.image); expect(learningPath.image ?? null).toEqual(expected.image ?? null);
const expectedLearningPathNodes = new Map( for (const node of expected.nodes) {
expectedEntity.nodes.map((node) => [ const correspondingNode = learningPath.nodes.find(
{ learningObjectHruid: node.learningObjectHruid, language: node.language, version: node.version }, (it) => node.learningobject_hruid === it.learningobject_hruid && node.language === it.language && node.version === it.version
{ startNode: node.startNode, transitions: node.transitions },
])
); );
expect(correspondingNode).toBeTruthy();
expect(Boolean(correspondingNode!.start_node)).toEqual(Boolean(node.start_node));
for (const node of learningPath.nodes) { for (const transition of node.transitions) {
const nodeKey = { const correspondingTransition = correspondingNode!.transitions.find(
learningObjectHruid: node.learningobject_hruid, (it) =>
language: node.language, it.next.hruid === transition.next.hruid &&
version: node.version, it.next.language === transition.next.language &&
}; it.next.version === transition.next.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))
); );
expect(new Set(node.transitions.map((it) => it.next.language))).toEqual(new Set(expectedNode.transitions.map((it) => it.next.language))); expect(correspondingTransition).toBeTruthy();
expect(new Set(node.transitions.map((it) => it.next.version))).toEqual(new Set(expectedNode.transitions.map((it) => it.next.version)));
} }
} }
}
/**
* Expect that the given result is a singleton list with exactly the given element.
*/
export function expectToHaveFoundPrecisely<T extends object>(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<T>(result: T[]): void {
expect(result).toHaveProperty('length');
expect(result.length).toBe(0);
}

View file

@ -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<LearningObject>): 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();
}

View file

@ -1,10 +1,14 @@
import fs from 'fs'; import fs from 'fs';
import path from 'node:path'; 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. * Load the asset at the given path.
* @param relPath Path of the asset relative to the test-assets folder. * @param relPath Path of the asset relative to the test-assets folder.
*/ */
export function loadTestAsset(relPath: string): Buffer { export function loadTestAsset(relPath: string): Buffer {
return fs.readFileSync(path.resolve(__dirname, `../test-assets/${relPath}`)); return fs.readFileSync(path.resolve(dirName, `../test_assets/${relPath}`));
} }

View file

@ -2,9 +2,11 @@ import { EntityManager } from '@mikro-orm/core';
import { Assignment } from '../../../src/entities/assignments/assignment.entity'; import { Assignment } from '../../../src/entities/assignments/assignment.entity';
import { Class } from '../../../src/entities/classes/class.entity'; import { Class } from '../../../src/entities/classes/class.entity';
import { Language } from '@dwengo-1/common/util/language'; 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[] { export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assignment[] {
const assignment01 = em.create(Assignment, { assignment01 = em.create(Assignment, {
within: classes[0], within: classes[0],
id: 1, id: 1,
title: 'dire straits', title: 'dire straits',
@ -14,7 +16,7 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign
groups: [], groups: [],
}); });
const assignment02 = em.create(Assignment, { assignment02 = em.create(Assignment, {
within: classes[1], within: classes[1],
id: 2, id: 2,
title: 'tool', title: 'tool',
@ -24,7 +26,7 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign
groups: [], groups: [],
}); });
const assignment03 = em.create(Assignment, { assignment03 = em.create(Assignment, {
within: classes[0], within: classes[0],
id: 3, id: 3,
title: 'delete', title: 'delete',
@ -34,7 +36,7 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign
groups: [], groups: [],
}); });
const assignment04 = em.create(Assignment, { assignment04 = em.create(Assignment, {
within: classes[0], within: classes[0],
id: 4, id: 4,
title: 'another assignment', title: 'another assignment',
@ -44,5 +46,41 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign
groups: [], groups: [],
}); });
return [assignment01, assignment02, assignment03, assignment04]; conditionalPathAssignment = em.create(Assignment, {
within: getClassWithTestleerlingAndTestleerkracht(),
id: 1,
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;
} }

View file

@ -2,13 +2,15 @@ import { EntityManager } from '@mikro-orm/core';
import { Group } from '../../../src/entities/assignments/group.entity'; import { Group } from '../../../src/entities/assignments/group.entity';
import { Assignment } from '../../../src/entities/assignments/assignment.entity'; import { Assignment } from '../../../src/entities/assignments/assignment.entity';
import { Student } from '../../../src/entities/users/student.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[] { export function makeTestGroups(em: EntityManager, students: Student[], assignments: Assignment[]): Group[] {
/* /*
* Group #1 for Assignment #1 in class 'id01' * Group #1 for Assignment #1 in class 'id01'
* => Assigned to do learning path 'id02' * => Assigned to do learning path 'id02'
*/ */
const group01 = em.create(Group, { group01 = em.create(Group, {
assignment: assignments[0], assignment: assignments[0],
groupNumber: 1, groupNumber: 1,
members: students.slice(0, 2), members: students.slice(0, 2),
@ -18,7 +20,7 @@ export function makeTestGroups(em: EntityManager, students: Student[], assignmen
* Group #2 for Assignment #1 in class 'id01' * Group #2 for Assignment #1 in class 'id01'
* => Assigned to do learning path 'id02' * => Assigned to do learning path 'id02'
*/ */
const group02 = em.create(Group, { group02 = em.create(Group, {
assignment: assignments[0], assignment: assignments[0],
groupNumber: 2, groupNumber: 2,
members: students.slice(2, 4), members: students.slice(2, 4),
@ -28,7 +30,7 @@ export function makeTestGroups(em: EntityManager, students: Student[], assignmen
* Group #3 for Assignment #1 in class 'id01' * Group #3 for Assignment #1 in class 'id01'
* => Assigned to do learning path 'id02' * => Assigned to do learning path 'id02'
*/ */
const group03 = em.create(Group, { group03 = em.create(Group, {
assignment: assignments[0], assignment: assignments[0],
groupNumber: 3, groupNumber: 3,
members: students.slice(4, 6), members: students.slice(4, 6),
@ -38,7 +40,7 @@ export function makeTestGroups(em: EntityManager, students: Student[], assignmen
* Group #4 for Assignment #2 in class 'id02' * Group #4 for Assignment #2 in class 'id02'
* => Assigned to do learning path 'id01' * => Assigned to do learning path 'id01'
*/ */
const group04 = em.create(Group, { group04 = em.create(Group, {
assignment: assignments[1], assignment: assignments[1],
groupNumber: 4, groupNumber: 4,
members: students.slice(3, 4), 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' * Group #5 for Assignment #4 in class 'id01'
* => Assigned to do learning path 'id01' * => Assigned to do learning path 'id01'
*/ */
const group05 = em.create(Group, { group05 = em.create(Group, {
assignment: assignments[3], assignment: assignments[3],
groupNumber: 1, groupNumber: 1,
members: students.slice(0, 2), 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;
} }

View file

@ -2,12 +2,14 @@ import { EntityManager } from '@mikro-orm/core';
import { Class } from '../../../src/entities/classes/class.entity'; import { Class } from '../../../src/entities/classes/class.entity';
import { Student } from '../../../src/entities/users/student.entity'; import { Student } from '../../../src/entities/users/student.entity';
import { Teacher } from '../../../src/entities/users/teacher.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[] { export function makeTestClasses(em: EntityManager, students: Student[], teachers: Teacher[]): Class[] {
const studentsClass01 = students.slice(0, 8); const studentsClass01 = students.slice(0, 8);
const teacherClass01: Teacher[] = teachers.slice(4, 5); const teacherClass01: Teacher[] = teachers.slice(4, 5);
const class01 = em.create(Class, { class01 = em.create(Class, {
classId: '8764b861-90a6-42e5-9732-c0d9eb2f55f9', classId: '8764b861-90a6-42e5-9732-c0d9eb2f55f9',
displayName: 'class01', displayName: 'class01',
teachers: teacherClass01, 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 studentsClass02: Student[] = students.slice(0, 2).concat(students.slice(3, 4));
const teacherClass02: Teacher[] = teachers.slice(1, 2); const teacherClass02: Teacher[] = teachers.slice(1, 2);
const class02 = em.create(Class, { class02 = em.create(Class, {
classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89',
displayName: 'class02', displayName: 'class02',
teachers: teacherClass02, teachers: teacherClass02,
@ -27,7 +29,7 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers
const studentsClass03: Student[] = students.slice(1, 4); const studentsClass03: Student[] = students.slice(1, 4);
const teacherClass03: Teacher[] = teachers.slice(2, 3); const teacherClass03: Teacher[] = teachers.slice(2, 3);
const class03 = em.create(Class, { class03 = em.create(Class, {
classId: '80dcc3e0-1811-4091-9361-42c0eee91cfa', classId: '80dcc3e0-1811-4091-9361-42c0eee91cfa',
displayName: 'class03', displayName: 'class03',
teachers: teacherClass03, teachers: teacherClass03,
@ -37,12 +39,45 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers
const studentsClass04: Student[] = students.slice(0, 2); const studentsClass04: Student[] = students.slice(0, 2);
const teacherClass04: Teacher[] = teachers.slice(2, 3); const teacherClass04: Teacher[] = teachers.slice(2, 3);
const class04 = em.create(Class, { class04 = em.create(Class, {
classId: '33d03536-83b8-4880-9982-9bbf2f908ddf', classId: '33d03536-83b8-4880-9982-9bbf2f908ddf',
displayName: 'class04', displayName: 'class04',
teachers: teacherClass04, teachers: teacherClass04,
students: studentsClass04, 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;
} }

View file

@ -0,0 +1,2 @@
::Reflection::
Reflect on this learning path. What have you learned today? {}

View file

@ -0,0 +1,7 @@
<div class="learning-object-gift">
<div id="gift-q1" class="gift-question gift-question-type-Essay">
<h2 id="gift-q1-title" class="gift-title">Reflection</h2>
<p id="gift-q1-stem" class="gift-stem">Reflect on this learning path. What have you learned today?</p>
<textarea id="gift-q1-answer" class="gift-essay-answer"></textarea>
</div>
</div>

View file

@ -1,5 +1,5 @@
::MC basic:: ::Self-evaluation::
Are you following along well with the class? { Are you following along well? {
~No, it's very difficult to follow along. ~No, it's very difficult to follow along.
=Yes, no problem! =Yes, no problem!
} }

View file

@ -1,14 +1,14 @@
<div class="learning-object-gift"> <div class="learning-object-gift">
<div id="gift-q1" class="gift-question"> <div id="gift-q1" class="gift-question gift-question-type-MC">
<h2 id="gift-q1-title" class="gift-title">MC basic</h2> <h2 id="gift-q1-title" class="gift-title">Self-evaluation</h2>
<p id="gift-q1-stem" class="gift-stem">Are you following along well with the class?</p> <p id="gift-q1-stem" class="gift-stem">Are you following along well?</p>
<div class="gift-choice-div"> <div class="gift-choice-div">
<input value="0" name="gift-q1-choices" id="gift-q1-choice-0" type="radio"> <input value="0" name="gift-q1-choices" id="gift-q1-choice-0" type="radio">
<label for="gift-q1-choice-0">[object Object]</label> <label for="gift-q1-choice-0">No, it's very difficult to follow along.</label>
</div> </div>
<div class="gift-choice-div"> <div class="gift-choice-div">
<input value="1" name="gift-q1-choices" id="gift-q1-choice-1" type="radio"> <input value="1" name="gift-q1-choices" id="gift-q1-choice-1" type="radio">
<label for="gift-q1-choice-1">[object Object]</label> <label for="gift-q1-choice-1">Yes, no problem!</label>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,16 +1,49 @@
import { EntityManager } from '@mikro-orm/core'; import { EntityManager, RequiredEntityData } from '@mikro-orm/core';
import { LearningObject } from '../../../src/entities/content/learning-object.entity'; import { LearningObject } from '../../../src/entities/content/learning-object.entity';
import { Language } from '@dwengo-1/common/util/language'; import { Language } from '@dwengo-1/common/util/language';
import { DwengoContentType } from '../../../src/services/learning-objects/processing/content-type'; import { DwengoContentType } from '../../../src/services/learning-objects/processing/content-type';
import { ReturnValue } from '../../../src/entities/content/return-value.entity'; 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[] { export function makeTestLearningObjects(em: EntityManager): LearningObject[] {
const returnValue: ReturnValue = new ReturnValue(); const returnValue: ReturnValue = new ReturnValue();
returnValue.callbackSchema = ''; returnValue.callbackSchema = '';
returnValue.callbackUrl = ''; returnValue.callbackUrl = '';
const learningObject01 = em.create(LearningObject, { const learningObject01 = em.create(LearningObject, testLearningObject01);
hruid: 'id01', 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 learningObjectMultipleChoice = em.create(LearningObject, testLearningObjectMultipleChoice);
const learningObjectEssayQuestion = em.create(LearningObject, testLearningObjectEssayQuestion);
const learningObjectPnNotebooks = em.create(LearningObject, testLearningObjectPnNotebooks);
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<LearningObject> = {
hruid: `${getEnvVar(envVars.UserContentPrefix)}id01`,
language: Language.English, language: Language.English,
version: 1, version: 1,
admins: [], admins: [],
@ -18,21 +51,23 @@ export function makeTestLearningObjects(em: EntityManager): LearningObject[] {
description: 'debute', description: 'debute',
contentType: DwengoContentType.TEXT_MARKDOWN, contentType: DwengoContentType.TEXT_MARKDOWN,
keywords: [], keywords: [],
uuid: v4(),
targetAges: [16, 17, 18],
teacherExclusive: false, teacherExclusive: false,
skosConcepts: [], skosConcepts: [],
educationalGoals: [], educationalGoals: [],
copyright: '', copyright: '',
license: '', license: '',
estimatedTime: 45, estimatedTime: 45,
returnValue: returnValue, returnValue: createReturnValue(),
available: true, available: true,
contentLocation: '', contentLocation: '',
attachments: [], 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"), 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 learningObject02 = em.create(LearningObject, { export const testLearningObject02: RequiredEntityData<LearningObject> = {
hruid: 'id02', hruid: `${getEnvVar(envVars.UserContentPrefix)}id02`,
language: Language.English, language: Language.English,
version: 1, version: 1,
admins: [], admins: [],
@ -46,17 +81,17 @@ export function makeTestLearningObjects(em: EntityManager): LearningObject[] {
copyright: '', copyright: '',
license: '', license: '',
estimatedTime: 80, estimatedTime: 80,
returnValue: returnValue, returnValue: createReturnValue(),
available: true, available: true,
contentLocation: '', contentLocation: '',
attachments: [], attachments: [],
content: Buffer.from( 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" "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 learningObject03 = em.create(LearningObject, { export const testLearningObject03: RequiredEntityData<LearningObject> = {
hruid: 'id03', hruid: `${getEnvVar(envVars.UserContentPrefix)}id03`,
language: Language.English, language: Language.English,
version: 1, version: 1,
admins: [], admins: [],
@ -70,7 +105,7 @@ export function makeTestLearningObjects(em: EntityManager): LearningObject[] {
copyright: '', copyright: '',
license: '', license: '',
estimatedTime: 55, estimatedTime: 55,
returnValue: returnValue, returnValue: createReturnValue(),
available: true, available: true,
contentLocation: '', contentLocation: '',
attachments: [], attachments: [],
@ -80,10 +115,10 @@ export function makeTestLearningObjects(em: EntityManager): LearningObject[] {
come back and see me later next patient please \ come back and see me later next patient please \
send in another victim of industrial disease' send in another victim of industrial disease'
), ),
}); };
const learningObject04 = em.create(LearningObject, { export const testLearningObject04: RequiredEntityData<LearningObject> = {
hruid: 'id04', hruid: `${getEnvVar(envVars.UserContentPrefix)}id04`,
language: Language.English, language: Language.English,
version: 1, version: 1,
admins: [], admins: [],
@ -97,7 +132,7 @@ export function makeTestLearningObjects(em: EntityManager): LearningObject[] {
copyright: '', copyright: '',
license: '', license: '',
estimatedTime: 55, estimatedTime: 55,
returnValue: returnValue, returnValue: createReturnValue(),
available: true, available: true,
contentLocation: '', contentLocation: '',
attachments: [], attachments: [],
@ -107,10 +142,10 @@ export function makeTestLearningObjects(em: EntityManager): LearningObject[] {
I had the one-arm bandit fever \ I had the one-arm bandit fever \
There was an arrow through my heart and my soul' There was an arrow through my heart and my soul'
), ),
}); };
const learningObject05 = em.create(LearningObject, { export const testLearningObject05: RequiredEntityData<LearningObject> = {
hruid: 'id05', hruid: `${getEnvVar(envVars.UserContentPrefix)}id05`,
language: Language.English, language: Language.English,
version: 1, version: 1,
admins: [], admins: [],
@ -124,12 +159,103 @@ export function makeTestLearningObjects(em: EntityManager): LearningObject[] {
copyright: '', copyright: '',
license: '', license: '',
estimatedTime: 55, estimatedTime: 55,
returnValue: returnValue, returnValue: createReturnValue(),
available: true, available: true,
contentLocation: '', contentLocation: '',
attachments: [], attachments: [],
content: Buffer.from('calling Elvis, is anybody home, calling elvis, I am here all alone'), content: Buffer.from('calling Elvis, is anybody home, calling elvis, I am here all alone'),
}); };
return [learningObject01, learningObject02, learningObject03, learningObject04, learningObject05]; export const testLearningObjectMultipleChoice: RequiredEntityData<LearningObject> = {
} 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<LearningObject> = {
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<LearningObject> = {
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: '[]',
},
};

View file

@ -1,100 +1,236 @@
import { EntityManager } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/core';
import { LearningPath } from '../../../src/entities/content/learning-path.entity'; import { LearningPath } from '../../../src/entities/content/learning-path.entity';
import { Language } from '@dwengo-1/common/util/language'; import { Language } from '@dwengo-1/common/util/language';
import { LearningPathTransition } from '../../../src/entities/content/learning-path-transition.entity'; import { mapToLearningPath } from '../../../src/services/learning-paths/learning-path-service';
import { LearningPathNode } from '../../../src/entities/content/learning-path-node.entity'; 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[] { export function makeTestLearningPaths(_em: EntityManager): LearningPath[] {
const learningPathNode01: LearningPathNode = new LearningPathNode(); const learningPath01 = mapToLearningPath(testLearningPath01, []);
const learningPathNode02: LearningPathNode = new LearningPathNode(); const learningPath02 = mapToLearningPath(testLearningPath02, []);
const learningPathNode03: LearningPathNode = new LearningPathNode();
const learningPathNode04: LearningPathNode = new LearningPathNode();
const learningPathNode05: LearningPathNode = new LearningPathNode();
const transitions01: LearningPathTransition = new LearningPathTransition(); const partiallyDatabasePartiallyDwengoApiLearningPath = mapToLearningPath(testPartiallyDatabaseAndPartiallyDwengoApiLearningPath, []);
const transitions02: LearningPathTransition = new LearningPathTransition(); const learningPathWithConditions = mapToLearningPath(testLearningPathWithConditions, []);
const transitions03: LearningPathTransition = new LearningPathTransition();
const transitions04: LearningPathTransition = new LearningPathTransition();
const transitions05: LearningPathTransition = new LearningPathTransition();
transitions01.condition = 'true'; return [learningPath01, learningPath02, partiallyDatabasePartiallyDwengoApiLearningPath, learningPathWithConditions];
transitions01.next = learningPathNode02; }
transitions02.condition = 'true'; const nowString = new Date().toString();
transitions02.next = learningPathNode02;
transitions03.condition = 'true'; export const testLearningPath01: LearningPathDTO = {
transitions03.next = learningPathNode04; keywords: 'test',
target_ages: [16, 17, 18],
transitions04.condition = 'true'; hruid: `${getEnvVar(envVars.UserContentPrefix)}id01`,
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, language: Language.English,
admins: [],
title: 'repertoire Tool', title: 'repertoire Tool',
description: 'all about Tool', description: 'all about Tool',
image: null, nodes: [
nodes: nodes01, {
}); 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: [],
},
],
};
const nodes02: LearningPathNode[] = [ export const testLearningPath02: LearningPathDTO = {
// LearningPathNode03, keywords: 'test',
// LearningPathNode04, target_ages: [16, 17, 18],
// LearningPathNode05, hruid: `${getEnvVar(envVars.UserContentPrefix)}id02`,
];
const learningPath02 = em.create(LearningPath, {
hruid: 'id02',
language: Language.English, language: Language.English,
admins: [],
title: 'repertoire Dire Straits', title: 'repertoire Dire Straits',
description: 'all about Dire Straits', description: 'all about Dire Straits',
image: null, nodes: [
nodes: nodes02, {
}); 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: [],
},
],
};
return [learningPath01, learningPath02]; 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: [],
},
],
};

View file

@ -15,7 +15,14 @@ export const TEST_STUDENTS = [
{ username: 'testleerling1', firstName: 'Gerald', lastName: 'Schmittinger' }, { username: 'testleerling1', firstName: 'Gerald', lastName: 'Schmittinger' },
]; ];
let testStudents: Student[];
// 🏗️ Functie die ORM entities maakt uit de data array // 🏗️ Functie die ORM entities maakt uit de data array
export function makeTestStudents(em: EntityManager): Student[] { 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');
} }

View file

@ -2,37 +2,63 @@ import { Teacher } from '../../../src/entities/users/teacher.entity';
import { EntityManager } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/core';
export function makeTestTeachers(em: EntityManager): Teacher[] { export function makeTestTeachers(em: EntityManager): Teacher[] {
const teacher01 = em.create(Teacher, { teacher01 = em.create(Teacher, {
username: 'FooFighters', username: 'FooFighters',
firstName: 'Dave', firstName: 'Dave',
lastName: 'Grohl', lastName: 'Grohl',
}); });
const teacher02 = em.create(Teacher, { teacher02 = em.create(Teacher, {
username: 'LimpBizkit', username: 'LimpBizkit',
firstName: 'Fred', firstName: 'Fred',
lastName: 'Durst', lastName: 'Durst',
}); });
const teacher03 = em.create(Teacher, { teacher03 = em.create(Teacher, {
username: 'Staind', username: 'Staind',
firstName: 'Aaron', firstName: 'Aaron',
lastName: 'Lewis', lastName: 'Lewis',
}); });
// Should not be used, gets deleted in a unit test // Should not be used, gets deleted in a unit test
const teacher04 = em.create(Teacher, { teacher04 = em.create(Teacher, {
username: 'ZesdeMetaal', username: 'ZesdeMetaal',
firstName: 'Wannes', firstName: 'Wannes',
lastName: 'Cappelle', lastName: 'Cappelle',
}); });
// Makes sure when logged in as testleerkracht1, there exists a corresponding user // Makes sure when logged in as testleerkracht1, there exists a corresponding user
const teacher05 = em.create(Teacher, { testleerkracht1 = em.create(Teacher, {
username: 'testleerkracht1', username: 'testleerkracht1',
firstName: 'Bob', firstName: 'Kris',
lastName: 'Dylan', 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;
} }

View file

@ -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 { makeTestTeachers } from '../tests/test_assets/users/teachers.testdata.js';
import { getLogger, Logger } from '../src/logging/initalize.js'; import { getLogger, Logger } from '../src/logging/initalize.js';
import { Collection } from '@mikro-orm/core'; 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(); const logger: Logger = getLogger();

View file

@ -1,14 +1,15 @@
import { Language } from '../util/language'; import { Language } from '../util/language';
export interface Transition { export interface Transition {
default: boolean; default?: boolean;
_id: string; _id?: string;
next: { next: {
_id: string; _id?: string;
hruid: string; hruid: string;
version: number; version: number;
language: string; language: string;
}; };
condition?: string;
} }
export interface LearningObjectIdentifierDTO { export interface LearningObjectIdentifierDTO {
@ -18,7 +19,7 @@ export interface LearningObjectIdentifierDTO {
} }
export interface LearningObjectNode { export interface LearningObjectNode {
_id: string; _id?: string;
learningobject_hruid: string; learningobject_hruid: string;
version: number; version: number;
language: Language; language: Language;
@ -30,20 +31,20 @@ export interface LearningObjectNode {
} }
export interface LearningPath { export interface LearningPath {
_id: string; _id?: string;
language: string; language: string;
hruid: string; hruid: string;
title: string; title: string;
description: string; description: string;
image?: string; // Image might be missing, so it's optional image?: string; // Image might be missing, so it's optional
num_nodes: number; num_nodes?: number;
num_nodes_left: number; num_nodes_left?: number;
nodes: LearningObjectNode[]; nodes: LearningObjectNode[];
keywords: string; keywords: string;
target_ages: number[]; target_ages: number[];
min_age: number; min_age?: number;
max_age: number; max_age?: number;
__order: number; __order?: number;
} }
export interface LearningPathIdentifier { export interface LearningPathIdentifier {
@ -62,8 +63,8 @@ export interface ReturnValue {
} }
export interface LearningObjectMetadata { export interface LearningObjectMetadata {
_id: string; _id?: string;
uuid: string; uuid?: string;
hruid: string; hruid: string;
version: number; version: number;
language: Language; language: Language;
@ -84,7 +85,7 @@ export interface LearningObjectMetadata {
export interface FilteredLearningObject { export interface FilteredLearningObject {
key: string; key: string;
_id: string; _id?: string;
uuid: string; uuid: string;
version: number; version: number;
title: string; title: string;

View file

@ -71,7 +71,7 @@ export default [
'init-declarations': 'off', 'init-declarations': 'off',
'@typescript-eslint/init-declarations': 'off', '@typescript-eslint/init-declarations': 'off',
'max-params': 'off', 'max-params': 'off',
'@typescript-eslint/max-params': ['error', { max: 6 }], '@typescript-eslint/max-params': 'off',
'@typescript-eslint/member-ordering': 'error', '@typescript-eslint/member-ordering': 'error',
'@typescript-eslint/method-signature-style': 'off', // Don't care about TypeScript strict mode. '@typescript-eslint/method-signature-style': 'off', // Don't care about TypeScript strict mode.
'@typescript-eslint/naming-convention': [ '@typescript-eslint/naming-convention': [
@ -87,6 +87,7 @@ export default [
modifiers: ['const'], modifiers: ['const'],
format: ['camelCase', 'UPPER_CASE'], format: ['camelCase', 'UPPER_CASE'],
trailingUnderscore: 'allow', trailingUnderscore: 'allow',
leadingUnderscore: 'allow',
}, },
{ {
// Enforce that private members are prefixed with an underscore // Enforce that private members are prefixed with an underscore

View file

@ -10,12 +10,14 @@
"preview": "vite preview", "preview": "vite preview",
"type-check": "vue-tsc --build", "type-check": "vue-tsc --build",
"format": "prettier --write src/", "format": "prettier --write src/",
"test:e2e": "playwright test",
"format-check": "prettier --check src/", "format-check": "prettier --check src/",
"lint": "eslint . --fix", "lint": "eslint . --fix",
"test:unit": "vitest --run", "pretest:unit": "tsx ../docs/api/generate.ts && npm run build",
"test:e2e": "playwright test" "test:unit": "vitest --run"
}, },
"dependencies": { "dependencies": {
"@dwengo-1/common": "^0.1.1",
"@tanstack/react-query": "^5.69.0", "@tanstack/react-query": "^5.69.0",
"@tanstack/vue-query": "^5.69.0", "@tanstack/vue-query": "^5.69.0",
"axios": "^1.8.2", "axios": "^1.8.2",

View file

@ -8,20 +8,21 @@ export class LearningPathController extends BaseController {
constructor() { constructor() {
super("learningPath"); super("learningPath");
} }
async search(query: string): Promise<LearningPath[]> { async search(query: string, language: string): Promise<LearningPath[]> {
const dtos = await this.get<LearningPathDTO[]>("/", { search: query }); const dtos = await this.get<LearningPathDTO[]>("/", { search: query, language });
return dtos.map((dto) => LearningPath.fromDTO(dto)); return dtos.map((dto) => LearningPath.fromDTO(dto));
} }
async getBy( async getBy(
hruid: string, hruid: string,
language: Language, language: Language,
options?: { forGroup?: string; forStudent?: string }, forGroup?: { forGroup: number; assignmentNo: number; classId: string },
): Promise<LearningPath> { ): Promise<LearningPath> {
const dtos = await this.get<LearningPathDTO[]>("/", { const dtos = await this.get<LearningPathDTO[]>("/", {
hruid, hruid,
language, language,
forGroup: options?.forGroup, forGroup: forGroup?.forGroup,
forStudent: options?.forStudent, assignmentNo: forGroup?.assignmentNo,
classId: forGroup?.classId,
}); });
return LearningPath.fromDTO(single(dtos)); return LearningPath.fromDTO(single(dtos));
} }

View file

@ -1,5 +1,6 @@
import { BaseController } from "./base-controller"; import { BaseController } from "./base-controller";
import type { SubmissionDTO, SubmissionDTOId } from "@dwengo-1/common/interfaces/submission"; import type { SubmissionDTO, SubmissionDTOId } from "@dwengo-1/common/interfaces/submission";
import type { Language } from "@dwengo-1/common/util/language";
export interface SubmissionsResponse { export interface SubmissionsResponse {
submissions: SubmissionDTO[] | SubmissionDTOId[]; submissions: SubmissionDTO[] | SubmissionDTOId[];
@ -10,16 +11,36 @@ export interface SubmissionResponse {
} }
export class SubmissionController extends BaseController { export class SubmissionController extends BaseController {
constructor(classid: string, assignmentNumber: number, groupNumber: number) { constructor(hruid: string) {
super(`class/${classid}/assignments/${assignmentNumber}/groups/${groupNumber}/submissions`); super(`learningObject/${hruid}/submissions`);
} }
async getAll(full = true): Promise<SubmissionsResponse> { async getAll(
return this.get<SubmissionsResponse>(`/`, { full }); language: Language,
version: number,
classId: string,
assignmentId: number,
groupId?: number,
full = true,
): Promise<SubmissionsResponse> {
return this.get<SubmissionsResponse>(`/`, { language, version, classId, assignmentId, groupId, full });
} }
async getByNumber(submissionNumber: number): Promise<SubmissionResponse> { async getByNumber(
return this.get<SubmissionResponse>(`/${submissionNumber}`); language: Language,
version: number,
classId: string,
assignmentId: number,
groupId: number,
submissionNumber: number,
): Promise<SubmissionResponse> {
return this.get<SubmissionResponse>(`/${submissionNumber}`, {
language,
version,
classId,
assignmentId,
groupId,
});
} }
async createSubmission(data: SubmissionDTO): Promise<SubmissionResponse> { async createSubmission(data: SubmissionDTO): Promise<SubmissionResponse> {

View file

@ -1,13 +1,13 @@
{ {
"welcome": "Willkommen", "welcome": "Willkommen",
"student": "schüler", "student": "Schüler",
"teacher": "lehrer", "teacher": "Lehrer",
"assignments": "Aufgaben", "assignments": "Aufgaben",
"classes": "Klasses", "classes": "Klassen",
"discussions": "Diskussionen", "discussions": "Diskussionen",
"login": "einloggen", "login": "einloggen",
"logout": "ausloggen", "logout": "ausloggen",
"cancel": "kündigen", "cancel": "abbrechen",
"logoutVerification": "Sind Sie sicher, dass Sie sich abmelden wollen?", "logoutVerification": "Sind Sie sicher, dass Sie sich abmelden wollen?",
"homeTitle": "Unsere Stärken", "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.", "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", "submitCode": "senden",
"members": "Mitglieder", "members": "Mitglieder",
"themes": "Themen", "themes": "Themen",
"choose-theme": "Wähle ein thema", "choose-theme": "Wählen Sie ein Thema",
"choose-age": "Alter auswählen", "choose-age": "Alter auswählen",
"theme-options": { "theme-options": {
"all": "Alle themen", "all": "Alle Themen",
"culture": "Kultur", "culture": "Kultur",
"electricity-and-mechanics": "Elektrizität und Mechanik", "electricity-and-mechanics": "Elektrizität und Mechanik",
"nature-and-climate": "Natur und Klima", "nature-and-climate": "Natur und Klima",
@ -37,11 +37,11 @@
"algorithms": "Algorithmisches Denken" "algorithms": "Algorithmisches Denken"
}, },
"age-options": { "age-options": {
"all": "Alle altersgruppen", "all": "Alle Altersgruppen",
"primary-school": "Grundschule", "primary-school": "Grundschule",
"lower-secondary": "12-14 jahre alt", "lower-secondary": "12-14 Jahre alt",
"upper-secondary": "14-16 jahre alt", "upper-secondary": "14-16 Jahre alt",
"high-school": "16-18 jahre alt", "high-school": "16-18 Jahre alt",
"older": "18 und älter" "older": "18 und älter"
}, },
"read-more": "Mehr lesen", "read-more": "Mehr lesen",
@ -73,8 +73,19 @@
"accept": "akzeptieren", "accept": "akzeptieren",
"deny": "ablehnen", "deny": "ablehnen",
"sent": "sent", "sent": "sent",
"failed": "gescheitert", "failed": "fehlgeschlagen",
"wrong": "etwas ist schief gelaufen", "wrong": "etwas ist schief gelaufen",
"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",
"created": "erstellt", "created": "erstellt",
"remove": "entfernen", "remove": "entfernen",
"students": "Studenten", "students": "Studenten",

View file

@ -75,6 +75,17 @@
"sent": "sent", "sent": "sent",
"failed": "failed", "failed": "failed",
"wrong": "something went wrong", "wrong": "something went wrong",
"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",
"created": "created", "created": "created",
"remove": "remove", "remove": "remove",
"students": "students", "students": "students",

View file

@ -76,6 +76,17 @@
"failed": "échoué", "failed": "échoué",
"wrong": "quelque chose n'a pas fonctionné", "wrong": "quelque chose n'a pas fonctionné",
"created": "créé", "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",
"remove": "supprimer", "remove": "supprimer",
"students": "étudiants", "students": "étudiants",
"classJoinRequests": "demandes d'adhésion", "classJoinRequests": "demandes d'adhésion",

View file

@ -76,6 +76,17 @@
"failed": "mislukt", "failed": "mislukt",
"wrong": "er ging iets verkeerd", "wrong": "er ging iets verkeerd",
"created": "gecreëerd", "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",
"remove": "verwijder", "remove": "verwijder",
"students": "studenten", "students": "studenten",
"classJoinRequests": "deelname verzoeken", "classJoinRequests": "deelname verzoeken",

View file

@ -10,7 +10,7 @@ import {
useQueryClient, useQueryClient,
type UseQueryReturnType, type UseQueryReturnType,
} from "@tanstack/vue-query"; } from "@tanstack/vue-query";
import { computed, type MaybeRefOrGetter, toValue } from "vue"; import { computed, toValue, type MaybeRefOrGetter } from "vue";
import { invalidateAllSubmissionKeys } from "./submissions"; import { invalidateAllSubmissionKeys } from "./submissions";
type GroupsQueryKey = ["groups", string, number, boolean]; type GroupsQueryKey = ["groups", string, number, boolean];
@ -160,7 +160,7 @@ export function useDeleteGroupMutation(): UseMutationReturnType<
const gn = response.group.groupNumber; const gn = response.group.groupNumber;
await invalidateAllGroupKeys(queryClient, cid, an, gn); await invalidateAllGroupKeys(queryClient, cid, an, gn);
await invalidateAllSubmissionKeys(queryClient, cid, an, gn); await invalidateAllSubmissionKeys(queryClient, undefined, undefined, undefined, cid, an, gn);
}, },
}); });
} }

View file

@ -5,7 +5,7 @@ import { getLearningObjectController } from "@/controllers/controllers.ts";
import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts"; import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts";
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.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(); const learningObjectController = getLearningObjectController();
export function useLearningObjectMetadataQuery( export function useLearningObjectMetadataQuery(

View file

@ -4,19 +4,19 @@ import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query";
import { getLearningPathController } from "@/controllers/controllers"; import { getLearningPathController } from "@/controllers/controllers";
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; 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(); const learningPathController = getLearningPathController();
export function useGetLearningPathQuery( export function useGetLearningPathQuery(
hruid: MaybeRefOrGetter<string>, hruid: MaybeRefOrGetter<string>,
language: MaybeRefOrGetter<Language>, language: MaybeRefOrGetter<Language>,
options?: MaybeRefOrGetter<{ forGroup?: string; forStudent?: string }>, forGroup?: MaybeRefOrGetter<{ forGroup: number; assignmentNo: number; classId: string } | undefined>,
): UseQueryReturnType<LearningPath, Error> { ): UseQueryReturnType<LearningPath, Error> {
return useQuery({ return useQuery({
queryKey: [LEARNING_PATH_KEY, "get", hruid, language, options], queryKey: [LEARNING_PATH_KEY, "get", hruid, language, forGroup],
queryFn: async () => { queryFn: async () => {
const [hruidVal, languageVal, optionsVal] = [toValue(hruid), toValue(language), toValue(options)]; const [hruidVal, languageVal, forGroupVal] = [toValue(hruid), toValue(language), toValue(forGroup)];
return learningPathController.getBy(hruidVal, languageVal, optionsVal); return learningPathController.getBy(hruidVal, languageVal, forGroupVal);
}, },
enabled: () => Boolean(toValue(hruid)) && Boolean(toValue(language)), enabled: () => Boolean(toValue(hruid)) && Boolean(toValue(language)),
}); });
@ -34,12 +34,14 @@ export function useGetAllLearningPathsByThemeQuery(
export function useSearchLearningPathQuery( export function useSearchLearningPathQuery(
query: MaybeRefOrGetter<string | undefined>, query: MaybeRefOrGetter<string | undefined>,
language: MaybeRefOrGetter<string | undefined>,
): UseQueryReturnType<LearningPath[], Error> { ): UseQueryReturnType<LearningPath[], Error> {
return useQuery({ return useQuery({
queryKey: [LEARNING_PATH_KEY, "search", query], queryKey: [LEARNING_PATH_KEY, "search", query, language],
queryFn: async () => { queryFn: async () => {
const queryVal = toValue(query)!; const queryVal = toValue(query)!;
return learningPathController.search(queryVal); const languageVal = toValue(language)!;
return learningPathController.search(queryVal, languageVal);
}, },
enabled: () => Boolean(toValue(query)), enabled: () => Boolean(toValue(query)),
}); });

View file

@ -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 type { SubmissionDTO } from "@dwengo-1/common/interfaces/submission";
import { import {
QueryClient, QueryClient,
useMutation, useMutation,
type UseMutationReturnType,
useQuery, useQuery,
useQueryClient, useQueryClient,
type UseMutationReturnType,
type UseQueryReturnType, type UseQueryReturnType,
} from "@tanstack/vue-query"; } 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( type SubmissionQueryKey = ["submission", string, Language | undefined, number, string, number, number, number];
classid: string,
assignmentNumber: number,
groupNumber: number,
full: boolean,
): SubmissionsQueryKey {
return ["submissions", classid, assignmentNumber, groupNumber, full];
}
type SubmissionQueryKey = ["submission", string, number, number, number];
function submissionQueryKey( function submissionQueryKey(
hruid: string,
language: Language,
version: number,
classid: string, classid: string,
assignmentNumber: number, assignmentNumber: number,
groupNumber: number, groupNumber: number,
submissionNumber: number, submissionNumber: number,
): SubmissionQueryKey { ): SubmissionQueryKey {
return ["submission", classid, assignmentNumber, groupNumber, submissionNumber]; return ["submission", hruid, language, version, classid, assignmentNumber, groupNumber, submissionNumber];
} }
export async function invalidateAllSubmissionKeys( export async function invalidateAllSubmissionKeys(
queryClient: QueryClient, queryClient: QueryClient,
hruid?: string,
language?: Language,
version?: number,
classid?: string, classid?: string,
assignmentNumber?: number, assignmentNumber?: number,
groupNumber?: number, groupNumber?: number,
@ -43,101 +43,134 @@ export async function invalidateAllSubmissionKeys(
await Promise.all( await Promise.all(
keys.map(async (key) => { keys.map(async (key) => {
const queryKey = [key, classid, assignmentNumber, groupNumber, submissionNumber].filter( const queryKey = [
(arg) => arg !== undefined, key,
); hruid,
language,
version,
classid,
assignmentNumber,
groupNumber,
submissionNumber,
].filter((arg) => arg !== undefined);
return queryClient.invalidateQueries({ queryKey: queryKey }); return queryClient.invalidateQueries({ queryKey: queryKey });
}), }),
); );
await queryClient.invalidateQueries({ 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({ 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({ 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( function checkEnabled(properties: MaybeRefOrGetter<unknown>[]): boolean {
classid: string | undefined, return properties.every((prop) => Boolean(toValue(prop)));
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<string | undefined>,
assignmentNumber: MaybeRefOrGetter<number | undefined>,
groupNumber: MaybeRefOrGetter<number | undefined>,
submissionNumber: MaybeRefOrGetter<number | undefined>,
full: MaybeRefOrGetter<boolean>,
): Values {
return {
cid: toValue(classid),
an: toValue(assignmentNumber),
gn: toValue(groupNumber),
sn: toValue(submissionNumber),
f: toValue(full),
};
} }
export function useSubmissionsQuery( export function useSubmissionsQuery(
hruid: MaybeRefOrGetter<string | undefined>,
language: MaybeRefOrGetter<Language | undefined>,
version: MaybeRefOrGetter<number | undefined>,
classid: MaybeRefOrGetter<string | undefined>, classid: MaybeRefOrGetter<string | undefined>,
assignmentNumber: MaybeRefOrGetter<number | undefined>, assignmentNumber: MaybeRefOrGetter<number | undefined>,
groupNumber: MaybeRefOrGetter<number | undefined>, groupNumber: MaybeRefOrGetter<number | undefined>,
full: MaybeRefOrGetter<boolean> = true, full: MaybeRefOrGetter<boolean> = true,
): UseQueryReturnType<SubmissionsResponse, Error> { ): UseQueryReturnType<SubmissionDTO[], Error> {
const { cid, an, gn, sn, f } = toValues(classid, assignmentNumber, groupNumber, 1, full);
return useQuery({ return useQuery({
queryKey: computed(() => submissionsQueryKey(cid!, an!, gn!, f)), queryKey: ["submissions", hruid, language, version, classid, assignmentNumber, groupNumber, full],
queryFn: async () => new SubmissionController(cid!, an!, gn!).getAll(f), queryFn: async () => {
enabled: () => checkEnabled(cid, an, gn, sn), 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( export function useSubmissionQuery(
hruid: MaybeRefOrGetter<string | undefined>,
language: MaybeRefOrGetter<Language | undefined>,
version: MaybeRefOrGetter<number | undefined>,
classid: MaybeRefOrGetter<string | undefined>, classid: MaybeRefOrGetter<string | undefined>,
assignmentNumber: MaybeRefOrGetter<number | undefined>, assignmentNumber: MaybeRefOrGetter<number | undefined>,
groupNumber: MaybeRefOrGetter<number | undefined>, groupNumber: MaybeRefOrGetter<number | undefined>,
submissionNumber: MaybeRefOrGetter<number | undefined>,
): UseQueryReturnType<SubmissionResponse, Error> { ): UseQueryReturnType<SubmissionResponse, Error> {
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({ return useQuery({
queryKey: computed(() => submissionQueryKey(cid!, an!, gn!, sn!)), queryKey: computed(() =>
queryFn: async () => new SubmissionController(cid!, an!, gn!).getByNumber(sn!), submissionQueryKey(
enabled: () => checkEnabled(cid, an, gn, sn), 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< export function useCreateSubmissionMutation(): UseMutationReturnType<
SubmissionResponse, SubmissionResponse,
Error, Error,
{ cid: string; an: number; gn: number; data: SubmissionDTO }, { data: SubmissionDTO },
unknown unknown
> { > {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ 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) => { onSuccess: async (response) => {
if (!response.submission.group) { if (!response.submission.group) {
await invalidateAllSubmissionKeys(queryClient); await invalidateAllSubmissionKeys(queryClient);
@ -149,7 +182,14 @@ export function useCreateSubmissionMutation(): UseMutationReturnType<
const an = typeof assignment === "number" ? assignment : assignment.id; const an = typeof assignment === "number" ? assignment : assignment.id;
const gn = response.submission.group.groupNumber; 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(); const queryClient = useQueryClient();
return useMutation({ 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) => { onSuccess: async (response) => {
if (!response.submission.group) { if (!response.submission.group) {
await invalidateAllSubmissionKeys(queryClient); await invalidateAllSubmissionKeys(queryClient);
@ -176,7 +216,9 @@ export function useDeleteSubmissionMutation(): UseMutationReturnType<
const an = typeof assignment === "number" ? assignment : assignment.id; const an = typeof assignment === "number" ? assignment : assignment.id;
const gn = response.submission.group.groupNumber; 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);
} }
}, },
}); });

View file

@ -13,7 +13,7 @@ import LearningPathPage from "@/views/learning-paths/LearningPathPage.vue";
import LearningPathSearchPage from "@/views/learning-paths/LearningPathSearchPage.vue"; import LearningPathSearchPage from "@/views/learning-paths/LearningPathSearchPage.vue";
import UserHomePage from "@/views/homepage/UserHomePage.vue"; import UserHomePage from "@/views/homepage/UserHomePage.vue";
import SingleTheme from "@/views/SingleTheme.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({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),

View file

@ -0,0 +1,5 @@
export function copyArrayWith<T>(index: number, newValue: T, array: T[]): T[] {
const copy = [...array];
copy[index] = newValue;
return copy;
}

View file

@ -0,0 +1,29 @@
export function deepEquals<T>(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]));
}

View file

@ -1,7 +1,14 @@
<script setup lang="ts"></script> <script setup lang="ts">
import { useRoute } from "vue-router";
const route = useRoute();
</script>
<template> <template>
<main></main> <main>
Hier zou de pagina staan om een assignment aan te maken voor de leerpad met hruid {{ route.query.hruid }} en
language {{ route.query.language }}. (Overschrijf dit)
</main>
</template> </template>
<style scoped></style> <style scoped></style>

View file

@ -1,51 +0,0 @@
<script setup lang="ts">
import { Language } from "@/data-objects/language.ts";
import type { UseQueryReturnType } from "@tanstack/vue-query";
import { useLearningObjectHTMLQuery } from "@/queries/learning-objects.ts";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
const props = defineProps<{ hruid: string; language: Language; version: number }>();
const learningObjectHtmlQueryResult: UseQueryReturnType<Document, Error> = useLearningObjectHTMLQuery(
() => props.hruid,
() => props.language,
() => props.version,
);
</script>
<template>
<using-query-result
:query-result="learningObjectHtmlQueryResult as UseQueryReturnType<Document, Error>"
v-slot="learningPathHtml: { data: Document }"
>
<div
class="learning-object-container"
v-html="learningPathHtml.data.body.innerHTML"
></div>
</using-query-result>
</template>
<style scoped>
.learning-object-container {
padding: 20px;
}
:deep(hr) {
margin-top: 10px;
margin-bottom: 10px;
}
:deep(li) {
margin-left: 30px;
margin-top: 5px;
margin-bottom: 5px;
}
:deep(img) {
max-width: 80%;
}
:deep(h2),
:deep(h3),
:deep(h4),
:deep(h5),
:deep(h6) {
margin-top: 10px;
}
</style>

View file

@ -0,0 +1,55 @@
<script setup lang="ts">
import UsingQueryResult from "@/components/UsingQueryResult.vue";
import { useGroupsQuery } from "@/queries/groups.ts";
import type { GroupsResponse } from "@/controllers/groups.ts";
import { useI18n } from "vue-i18n";
import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
const { t } = useI18n();
const props = defineProps<{
classId: string;
assignmentNumber: number;
}>();
const model = defineModel<number | undefined>({ default: undefined });
const groupsQuery = useGroupsQuery(props.classId, props.assignmentNumber, true);
interface GroupSelectorOption {
groupNumber: number | undefined;
label: string;
}
function groupOptions(groups: GroupDTO[]): GroupSelectorOption[] {
return [...groups]
.sort((a, b) => a.groupNumber - b.groupNumber)
.map((group, index) => ({
groupNumber: group.groupNumber,
label: `${index + 1}`,
}));
}
</script>
<template>
<using-query-result
:query-result="groupsQuery"
v-slot="{ data }: { data: GroupsResponse }"
>
<v-select
:label="t('viewAsGroup')"
:items="groupOptions(data.groups)"
v-model="model"
item-title="label"
class="group-selector-cb"
variant="outlined"
clearable
></v-select>
</using-query-result>
</template>
<style scoped>
.group-selector-cb {
margin-top: 10px;
}
</style>

View file

@ -3,8 +3,8 @@
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
import { computed, type ComputedRef, ref } from "vue"; import { computed, type ComputedRef, ref } from "vue";
import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts"; import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts";
import { useRoute } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import LearningObjectView from "@/views/learning-paths/LearningObjectView.vue"; import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import LearningPathSearchField from "@/components/LearningPathSearchField.vue"; import LearningPathSearchField from "@/components/LearningPathSearchField.vue";
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
@ -12,30 +12,38 @@
import UsingQueryResult from "@/components/UsingQueryResult.vue"; import UsingQueryResult from "@/components/UsingQueryResult.vue";
import authService from "@/services/auth/auth-service.ts"; import authService from "@/services/auth/auth-service.ts";
import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.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 route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const props = defineProps<{ hruid: string; language: Language; learningObjectHruid?: string }>(); const props = defineProps<{
hruid: string;
language: Language;
learningObjectHruid?: string;
}>();
interface Personalization { interface LearningPathPageQuery {
forStudent?: string;
forGroup?: string; forGroup?: string;
assignmentNo?: string;
classId?: string;
} }
const personalization = computed(() => { const query = computed(() => route.query as LearningPathPageQuery);
if (route.query.forStudent || route.query.forGroup) {
const forGroup = computed(() => {
if (query.value.forGroup && query.value.assignmentNo && query.value.classId) {
return { return {
forStudent: route.query.forStudent, forGroup: parseInt(query.value.forGroup),
forGroup: route.query.forGroup, assignmentNo: parseInt(query.value.assignmentNo),
} as Personalization; classId: query.value.classId,
};
} }
return { return undefined;
forStudent: authService.authState.user?.profile?.preferred_username,
} as Personalization;
}); });
const learningPathQueryResult = useGetLearningPathQuery(props.hruid, props.language, personalization); const learningPathQueryResult = useGetLearningPathQuery(props.hruid, props.language, forGroup);
const learningObjectListQueryResult = useLearningObjectListForPathQuery(learningPathQueryResult.data); const learningObjectListQueryResult = useLearningObjectListForPathQuery(learningPathQueryResult.data);
@ -98,6 +106,25 @@
} }
return "notCompleted"; return "notCompleted";
} }
const forGroupQueryParam = computed<number | undefined>({
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<void> {
await router.push({
path: "/assignment/create",
query: {
hruid: props.hruid,
language: props.language,
},
});
}
</script> </script>
<template> <template>
@ -109,6 +136,7 @@
v-model="navigationDrawerShown" v-model="navigationDrawerShown"
:width="350" :width="350"
> >
<div class="d-flex flex-column h-100">
<v-list-item> <v-list-item>
<template v-slot:title> <template v-slot:title>
<div class="learning-path-title">{{ learningPath.data.title }}</div> <div class="learning-path-title">{{ learningPath.data.title }}</div>
@ -142,6 +170,17 @@
</p> </p>
</template> </template>
</v-list-item> </v-list-item>
<v-list-item
v-if="query.classId && query.assignmentNo && authService.authState.activeRole === 'teacher'"
>
<template v-slot:default>
<learning-path-group-selector
:class-id="query.classId"
:assignment-number="parseInt(query.assignmentNo)"
v-model="forGroupQueryParam"
/>
</template>
</v-list-item>
<v-divider></v-divider> <v-divider></v-divider>
<div v-if="props.learningObjectHruid"> <div v-if="props.learningObjectHruid">
<using-query-result <using-query-result
@ -168,6 +207,17 @@
</template> </template>
</using-query-result> </using-query-result>
</div> </div>
<v-spacer></v-spacer>
<v-list-item v-if="authService.authState.activeRole === 'teacher'">
<template v-slot:default>
<v-btn
class="button-in-nav"
@click="assign()"
>{{ t("assignLearningPath") }}</v-btn
>
</template>
</v-list-item>
</div>
</v-navigation-drawer> </v-navigation-drawer>
<div class="control-bar-above-content"> <div class="control-bar-above-content">
<v-btn <v-btn
@ -180,12 +230,15 @@
<learning-path-search-field></learning-path-search-field> <learning-path-search-field></learning-path-search-field>
</div> </div>
</div> </div>
<div class="learning-object-view-container">
<learning-object-view <learning-object-view
:hruid="currentNode.learningobjectHruid" :hruid="currentNode.learningobjectHruid"
:language="currentNode.language" :language="currentNode.language"
:version="currentNode.version" :version="currentNode.version"
:group="forGroup"
v-if="currentNode" v-if="currentNode"
></learning-object-view> ></learning-object-view>
</div>
<div class="navigation-buttons-container"> <div class="navigation-buttons-container">
<v-btn <v-btn
prepend-icon="mdi-chevron-left" prepend-icon="mdi-chevron-left"
@ -221,9 +274,18 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
.learning-object-view-container {
padding-left: 20px;
padding-right: 20px;
padding-bottom: 20px;
}
.navigation-buttons-container { .navigation-buttons-container {
padding: 20px; padding: 20px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
.button-in-nav {
margin-top: 10px;
margin-bottom: 10px;
}
</style> </style>

View file

@ -9,11 +9,11 @@
import LearningPathsGrid from "@/components/LearningPathsGrid.vue"; import LearningPathsGrid from "@/components/LearningPathsGrid.vue";
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t, locale } = useI18n();
const query = computed(() => route.query.query as string | undefined); const query = computed(() => route.query.query as string | undefined);
const searchQueryResults = useSearchLearningPathQuery(query); const searchQueryResults = useSearchLearningPathQuery(query, locale);
</script> </script>
<template> <template>

View file

@ -0,0 +1,18 @@
export const essayQuestionAdapter: GiftAdapter = {
questionType: "Essay",
installListener(
questionElement: Element,
answerUpdateCallback: (newAnswer: string | number | object) => void,
): void {
const textArea = questionElement.querySelector("textarea")!;
textArea.addEventListener("input", () => {
answerUpdateCallback(textArea.value);
});
},
setAnswer(questionElement: Element, answer: string | number | object): void {
const textArea = questionElement.querySelector("textarea")!;
textArea.value = String(answer);
},
};

View file

@ -0,0 +1,8 @@
interface GiftAdapter {
questionType: string;
installListener(
questionElement: Element,
answerUpdateCallback: (newAnswer: string | number | object) => void,
): void;
setAnswer(questionElement: Element, answer: string | number | object): void;
}

View file

@ -0,0 +1,8 @@
import { multipleChoiceQuestionAdapter } from "@/views/learning-paths/gift-adapters/multiple-choice-question-adapter.ts";
import { essayQuestionAdapter } from "@/views/learning-paths/gift-adapters/essay-question-adapter.ts";
export const giftAdapters = [multipleChoiceQuestionAdapter, essayQuestionAdapter];
export function getGiftAdapterForType(questionType: string): GiftAdapter | undefined {
return giftAdapters.find((it) => it.questionType === questionType);
}

View file

@ -0,0 +1,27 @@
export const multipleChoiceQuestionAdapter: GiftAdapter = {
questionType: "MC",
installListener(
questionElement: Element,
answerUpdateCallback: (newAnswer: string | number | object) => void,
): void {
questionElement.querySelectorAll("input[type=radio]").forEach((element) => {
const input = element as HTMLInputElement;
input.addEventListener("change", () => {
answerUpdateCallback(parseInt(input.value));
});
// Optional: initialize value if already selected
if (input.checked) {
answerUpdateCallback(parseInt(input.value));
}
});
},
setAnswer(questionElement: Element, answer: string | number | object): void {
questionElement.querySelectorAll("input[type=radio]").forEach((element) => {
const input = element as HTMLInputElement;
input.checked = String(answer) === String(input.value);
});
},
};

View file

@ -0,0 +1,73 @@
<script setup lang="ts">
import { Language } from "@/data-objects/language.ts";
import type { UseQueryReturnType } from "@tanstack/vue-query";
import { useLearningObjectHTMLQuery } from "@/queries/learning-objects.ts";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
import { computed, ref } from "vue";
import authService from "@/services/auth/auth-service.ts";
import type { SubmissionData } from "@/views/learning-paths/learning-object/submission-data";
import LearningObjectContentView from "@/views/learning-paths/learning-object/content/LearningObjectContentView.vue";
import LearningObjectSubmissionsView from "@/views/learning-paths/learning-object/submissions/LearningObjectSubmissionsView.vue";
const _isStudent = computed(() => authService.authState.activeRole === "student");
const props = defineProps<{
hruid: string;
language: Language;
version: number;
group?: { forGroup: number; assignmentNo: number; classId: string };
}>();
const learningObjectHtmlQueryResult: UseQueryReturnType<Document, Error> = useLearningObjectHTMLQuery(
() => props.hruid,
() => props.language,
() => props.version,
);
const currentSubmission = ref<SubmissionData>([]);
</script>
<template>
<using-query-result
:query-result="learningObjectHtmlQueryResult as UseQueryReturnType<Document, Error>"
v-slot="learningPathHtml: { data: Document }"
>
<learning-object-content-view
:learning-object-content="learningPathHtml.data"
v-model:submission-data="currentSubmission"
/>
<div class="content-submissions-spacer" />
<learning-object-submissions-view
v-if="props.group"
:group="props.group"
:hruid="props.hruid"
:language="props.language"
:version="props.version"
v-model:submission-data="currentSubmission"
/>
</using-query-result>
</template>
<style scoped>
:deep(hr) {
margin-top: 10px;
margin-bottom: 10px;
}
:deep(li) {
margin-left: 30px;
margin-top: 5px;
margin-bottom: 5px;
}
:deep(img) {
max-width: 80%;
}
:deep(h2),
:deep(h3),
:deep(h4),
:deep(h5),
:deep(h6) {
margin-top: 10px;
}
.content-submissions-spacer {
height: 20px;
}
</style>

View file

@ -0,0 +1,90 @@
<script setup lang="ts">
import type { SubmissionData } from "@/views/learning-paths/learning-object/submission-data";
import { getGiftAdapterForType } from "@/views/learning-paths/gift-adapters/gift-adapters.ts";
import { computed, nextTick, onMounted, watch } from "vue";
import { copyArrayWith } from "@/utils/array-utils.ts";
const props = defineProps<{
learningObjectContent: Document;
submissionData?: SubmissionData;
}>();
const emit = defineEmits<(e: "update:submissionData", value: SubmissionData) => void>();
const submissionData = computed<SubmissionData | undefined>({
get: () => props.submissionData,
set: (v?: SubmissionData): void => {
if (v) emit("update:submissionData", v);
},
});
function forEachQuestion(
doAction: (questionIndex: number, questionName: string, questionType: string, questionElement: Element) => void,
): void {
const questions = document.querySelectorAll(".gift-question");
questions.forEach((question) => {
const name = question.id.match(/gift-q(\d+)/)?.[1];
const questionType = question.className
.split(" ")
.find((it) => it.startsWith("gift-question-type"))
?.match(/gift-question-type-([^ ]*)/)?.[1];
if (!name || isNaN(parseInt(name)) || !questionType) return;
const index = parseInt(name) - 1;
doAction(index, name, questionType, question);
});
}
function attachQuestionListeners(): void {
forEachQuestion((index, _name, type, element) => {
getGiftAdapterForType(type)?.installListener(element, (newAnswer) => {
submissionData.value = copyArrayWith(index, newAnswer, submissionData.value ?? []);
});
});
}
function setAnswers(answers: SubmissionData): void {
forEachQuestion((index, _name, type, element) => {
const answer = answers[index];
if (answer !== null && answer !== undefined) {
getGiftAdapterForType(type)?.setAnswer(element, answer);
} else if (answer === undefined) {
answers[index] = null;
}
});
submissionData.value = answers;
}
onMounted(async () =>
nextTick(() => {
attachQuestionListeners();
setAnswers(props.submissionData ?? []);
}),
);
watch(
() => props.learningObjectContent,
async () => {
await nextTick();
attachQuestionListeners();
},
);
watch(
() => props.submissionData,
async () => {
await nextTick();
setAnswers(props.submissionData ?? []);
},
);
</script>
<template>
<div
class="learning-object-container"
v-html="learningObjectContent.body.innerHTML"
></div>
</template>
<style scoped></style>

View file

@ -0,0 +1 @@
export type SubmissionData = (string | number | object | null)[];

View file

@ -0,0 +1,61 @@
<script setup lang="ts">
import type { SubmissionDTO } from "@dwengo-1/common/interfaces/submission";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = defineProps<{
allSubmissions: SubmissionDTO[];
}>();
const emit = defineEmits<(e: "submission-selected", submission: SubmissionDTO) => void>();
const headers = computed(() => [
{ title: "#", value: "submissionNo", width: "50px" },
{ title: t("submittedBy"), value: "submittedBy" },
{ title: t("timestamp"), value: "timestamp" },
{ title: "", key: "action", width: "70px", sortable: false },
]);
const data = computed(() =>
[...props.allSubmissions]
.sort((a, b) => (a.submissionNumber ?? 0) - (b.submissionNumber ?? 0))
.map((submission, index) => ({
submissionNo: index + 1,
submittedBy: `${submission.submitter.firstName} ${submission.submitter.lastName}`,
timestamp: submission.time ? new Date(submission.time).toLocaleString() : "-",
dto: submission,
})),
);
function selectSubmission(submission: SubmissionDTO): void {
emit("submission-selected", submission);
}
</script>
<template>
<v-card>
<v-card-title>{{ t("groupSubmissions") }}</v-card-title>
<v-card-text>
<v-data-table
:headers="headers"
:items="data"
density="compact"
hide-default-footer
:no-data-text="t('noSubmissionsYet')"
>
<template v-slot:[`item.action`]="{ item }">
<v-btn
density="compact"
variant="plain"
@click="selectSubmission(item.dto)"
>
{{ t("loadSubmission") }}
</v-btn>
</template>
</v-data-table>
</v-card-text>
</v-card>
</template>
<style scoped></style>

View file

@ -0,0 +1,97 @@
<script setup lang="ts">
import type { SubmissionData } from "@/views/learning-paths/learning-object/submission-data";
import type { SubmissionDTO } from "@dwengo-1/common/interfaces/submission";
import { Language } from "@/data-objects/language.ts";
import { useSubmissionsQuery } from "@/queries/submissions.ts";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
import SubmitButton from "@/views/learning-paths/learning-object/submissions/SubmitButton.vue";
import { computed, watch } from "vue";
import LearningObjectSubmissionsTable from "@/views/learning-paths/learning-object/submissions/LearningObjectSubmissionsTable.vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = defineProps<{
submissionData?: SubmissionData;
hruid: string;
language: Language;
version: number;
group: { forGroup: number; assignmentNo: number; classId: string };
}>();
const emit = defineEmits<(e: "update:submissionData", value: SubmissionData) => void>();
const submissionQuery = useSubmissionsQuery(
() => props.hruid,
() => props.language,
() => props.version,
() => props.group.classId,
() => props.group.assignmentNo,
() => props.group.forGroup,
() => true,
);
function emitSubmissionData(submissionData: SubmissionData): void {
emit("update:submissionData", submissionData);
}
function emitSubmission(submission: SubmissionDTO): void {
emitSubmissionData(JSON.parse(submission.content));
}
watch(submissionQuery.data, () => {
const submissions = submissionQuery.data.value;
if (submissions && submissions.length > 0) {
emitSubmission(submissions[submissions.length - 1]);
} else {
emitSubmissionData([]);
}
});
const lastSubmission = computed<SubmissionData>(() => {
const submissions = submissionQuery.data.value;
if (!submissions || submissions.length === 0) {
return undefined;
}
return JSON.parse(submissions[submissions.length - 1].content);
});
const showSubmissionTable = computed(() => props.submissionData !== undefined && props.submissionData.length > 0);
const showIsDoneMessage = computed(() => lastSubmission.value !== undefined && lastSubmission.value.length === 0);
</script>
<template>
<using-query-result
:query-result="submissionQuery"
v-slot="submissions: { data: SubmissionDTO[] }"
>
<submit-button
:hruid="props.hruid"
:language="props.language"
:version="props.version"
:group="props.group"
:submission-data="props.submissionData"
:submissions="submissions.data"
/>
<div class="submit-submissions-spacer"></div>
<v-alert
icon="mdi-check"
:text="t('taskCompleted')"
type="success"
variant="tonal"
density="compact"
v-if="showIsDoneMessage"
></v-alert>
<learning-object-submissions-table
v-if="submissionQuery.data && showSubmissionTable"
:all-submissions="submissions.data"
@submission-selected="emitSubmission"
/>
</using-query-result>
</template>
<style scoped>
.submit-submissions-spacer {
height: 20px;
}
</style>

Some files were not shown because too many files have changed in this diff Show more