diff --git a/backend/src/entities/assignments/submission.entity.ts b/backend/src/entities/assignments/submission.entity.ts index 34e0c1f6..920f0a71 100644 --- a/backend/src/entities/assignments/submission.entity.ts +++ b/backend/src/entities/assignments/submission.entity.ts @@ -15,8 +15,8 @@ export class Submission { }) learningObjectLanguage!: Language; - @PrimaryKey({ type: 'string' }) - learningObjectVersion: string = '1'; + @PrimaryKey({ type: 'numeric' }) + learningObjectVersion: number = 1; @PrimaryKey({ type: 'integer' }) submissionNumber!: number; diff --git a/backend/src/entities/content/learning-object.entity.ts b/backend/src/entities/content/learning-object.entity.ts index 55c4a808..c32efedc 100644 --- a/backend/src/entities/content/learning-object.entity.ts +++ b/backend/src/entities/content/learning-object.entity.ts @@ -47,7 +47,7 @@ export class LearningObject { teacherExclusive: boolean = false; @Property({ type: 'array' }) - skosConcepts!: string[]; + skosConcepts: string[] = []; @Embedded({ entity: () => EducationalGoal, @@ -64,8 +64,8 @@ export class LearningObject { @Property({ type: 'smallint', nullable: true }) difficulty?: number; - @Property({ type: 'integer' }) - estimatedTime!: number; + @Property({ type: 'integer', nullable: true }) + estimatedTime?: number; @Embedded({ entity: () => ReturnValue, diff --git a/backend/src/services/learning-paths/database-learning-path-provider.ts b/backend/src/services/learning-paths/database-learning-path-provider.ts index 36ab58a9..925f9f6b 100644 --- a/backend/src/services/learning-paths/database-learning-path-provider.ts +++ b/backend/src/services/learning-paths/database-learning-path-provider.ts @@ -97,14 +97,26 @@ async function convertNodes( learningobject_hruid: node.learningObjectHruid, version: learningObject.version, transitions: node.transitions - .filter((trans) => !personalizedFor || isTransitionPossible(trans, lastSubmission)) // If we want a personalized learning path, remove all transitions that aren't possible. - .map((trans, i) => convertTransition(trans, i, nodesToLearningObjects)), // Then convert all the transition + .filter((trans) => + !personalizedFor || isTransitionPossible(trans, optionalJsonStringToObject(lastSubmission?.content)) // If we want a personalized learning path, remove all transitions that aren't possible. + ).map((trans, i) => convertTransition(trans, i, nodesToLearningObjects)), // Then convert all the transition }; }) .toArray(); return await Promise.all(nodesPromise); } +/** + * Helper method to convert a json string to an object, or null if it is undefined. + */ +function optionalJsonStringToObject(jsonString?: string): object | null { + if (!jsonString) { + return null; + } else { + return JSON.parse(jsonString); + } +} + /** * Helper function which converts a transition in the database representation to a transition in the representation * the Dwengo API uses. diff --git a/backend/src/services/learning-paths/learning-path-personalization-util.ts b/backend/src/services/learning-paths/learning-path-personalization-util.ts index 533ef15c..648b7abc 100644 --- a/backend/src/services/learning-paths/learning-path-personalization-util.ts +++ b/backend/src/services/learning-paths/learning-path-personalization-util.ts @@ -36,5 +36,6 @@ export function isTransitionPossible(transition: LearningPathTransition, submitt if (submitted === null) { return false; // If the transition is not unconditional and there was no submission, the transition is not possible. } - return JSONPath({ path: transition.condition, json: submitted }).length === 0; + const match = JSONPath({ path: transition.condition, json: {submission: submitted} }) + return match.length === 1; } diff --git a/backend/tests/services/learning-path/database-learning-path-provider.test.ts b/backend/tests/services/learning-path/database-learning-path-provider.test.ts index df49ae3b..81d4a149 100644 --- a/backend/tests/services/learning-path/database-learning-path-provider.test.ts +++ b/backend/tests/services/learning-path/database-learning-path-provider.test.ts @@ -2,7 +2,11 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { LearningObject } from '../../../src/entities/content/learning-object.entity'; import { setupTestApp } from '../../setup-tests'; import { LearningPath } from '../../../src/entities/content/learning-path.entity'; -import { getLearningObjectRepository, getLearningPathRepository } from '../../../src/data/repositories'; +import { + getLearningObjectRepository, + getLearningPathRepository, + getStudentRepository, getSubmissionRepository +} 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 databaseLearningPathProvider from '../../../src/services/learning-paths/database-learning-path-provider'; @@ -10,6 +14,12 @@ import { expectToBeCorrectLearningPath } from '../../test-utils/expectations'; import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository'; import learningObjectService from '../../../src/services/learning-objects/learning-object-service'; import { Language } from '../../../src/entities/content/language'; +import { + ConditionTestLearningPathAndLearningObjects, + createConditionTestLearningPathAndLearningObjects +} from "../../test-assets/learning-paths/test-conditions-example"; +import {Student} from "../../../src/entities/users/student.entity"; +import {LearningObjectNode, LearningPathResponse} from "../../../src/interfaces/learning-content"; async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { const learningObjectRepo = getLearningObjectRepository(); @@ -21,13 +31,85 @@ async function initExampleData(): Promise<{ learningObject: LearningObject; lear return { learningObject, learningPath }; } +async function initPersonalizationTestData(): Promise<{ + learningContent: ConditionTestLearningPathAndLearningObjects, + studentA: Student, + studentB: Student +}> { + const studentRepo = getStudentRepository(); + 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); + + console.log(await getSubmissionRepository().findAll({})); + + const studentA = studentRepo.create({ + username: "student_a", + firstName: "Aron", + lastName: "Student" + }); + await studentRepo.save(studentA); + const submissionA = submissionRepo.create({ + learningObjectHruid: learningContent.branchingObject.hruid, + learningObjectLanguage: learningContent.branchingObject.language, + learningObjectVersion: learningContent.branchingObject.version, + submissionNumber: 0, + submitter: studentA, + submissionTime: new Date(), + content: '[0]' + }); + await submissionRepo.save(submissionA); + + const studentB = studentRepo.create({ + username: "student_b", + firstName: "Bill", + lastName: "Student" + }); + await studentRepo.save(studentB); + const submissionB = submissionRepo.create({ + learningObjectHruid: learningContent.branchingObject.hruid, + learningObjectLanguage: learningContent.branchingObject.language, + learningObjectVersion: learningContent.branchingObject.version, + submissionNumber: 1, + 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 { + let branchingObjectMatches = result.data![0].nodes.filter( + it => it.learningobject_hruid === persTestData.learningContent.branchingObject.hruid + ); + expect(branchingObjectMatches.length).toBe(1); + return branchingObjectMatches[0]; +} + describe('DatabaseLearningPathProvider', () => { let learningObjectRepo: LearningObjectRepository; let example: { learningObject: LearningObject; learningPath: LearningPath }; + let persTestData: { learningContent: ConditionTestLearningPathAndLearningObjects, studentA: Student, studentB: Student } beforeAll(async () => { await setupTestApp(); example = await initExampleData(); + persTestData = await initPersonalizationTestData(); learningObjectRepo = getLearningObjectRepository(); }); @@ -55,6 +137,60 @@ describe('DatabaseLearningPathProvider', () => { expectToBeCorrectLearningPath(result.data![0], example.learningPath, learningObjectsOnPath); }); + it("returns the correct personalized learning path", async () => { + // For student A: + let result = await databaseLearningPathProvider.fetchLearningPaths( + [persTestData.learningContent.learningPath.hruid], + persTestData.learningContent.learningPath.language, + 'the source', + {type: 'student', student: persTestData.studentA} + ); + expect(result.success).toBeTruthy(); + expect(result.data?.length).toBe(1); + + // There should be exactly one branching object + let branchingObject = expectBranchingObjectNode(result, persTestData); + + expect( + branchingObject + .transitions + .filter(it => it.next.hruid === persTestData.learningContent.finalObject.hruid) + .length + ).toBe(0); // studentA picked the first option, therefore, there should be no direct path to the final object. + expect( + branchingObject + .transitions + .filter(it => it.next.hruid === persTestData.learningContent.extraExerciseObject.hruid) + .length + ).toBe(1); // There should however be a path to the extra exercise object. + + // For student B: + result = await databaseLearningPathProvider.fetchLearningPaths( + [persTestData.learningContent.learningPath.hruid], + persTestData.learningContent.learningPath.language, + 'the source', + {type: 'student', student: persTestData.studentB} + ); + expect(result.success).toBeTruthy(); + expect(result.data?.length).toBe(1); + + // There should still be exactly one branching object + branchingObject = expectBranchingObjectNode(result, persTestData); + + // However, now the student picks the other option. + expect( + branchingObject + .transitions + .filter(it => it.next.hruid === persTestData.learningContent.finalObject.hruid) + .length + ).toBe(1); // studentB picked the second option, therefore, there should be a direct path to the final object. + expect( + branchingObject + .transitions + .filter(it => it.next.hruid === persTestData.learningContent.extraExerciseObject.hruid) + .length + ).toBe(0); // There should not be a path anymore to the extra exercise object. + }); it('returns a non-successful response if a non-existing learning path is queried', async () => { const result = await databaseLearningPathProvider.fetchLearningPaths( [example.learningPath.hruid], diff --git a/backend/tests/test-assets/learning-objects/dummy/dummy-learning-object-example.ts b/backend/tests/test-assets/learning-objects/dummy/dummy-learning-object-example.ts index 6dbe54b0..14f62828 100644 --- a/backend/tests/test-assets/learning-objects/dummy/dummy-learning-object-example.ts +++ b/backend/tests/test-assets/learning-objects/dummy/dummy-learning-object-example.ts @@ -3,6 +3,7 @@ import { LearningObject } from '../../../../src/entities/content/learning-object import { Language } from '../../../../src/entities/content/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 @@ -12,13 +13,17 @@ export function dummyLearningObject(hruid: string, language: Language, title: st return { createLearningObject: () => { const learningObject = new LearningObject(); - learningObject.hruid = hruid; + 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: {}, diff --git a/backend/tests/test-assets/learning-objects/test-essay/test-essay-example.ts b/backend/tests/test-assets/learning-objects/test-essay/test-essay-example.ts index b1ff330e..c0c18c6a 100644 --- a/backend/tests/test-assets/learning-objects/test-essay/test-essay-example.ts +++ b/backend/tests/test-assets/learning-objects/test-essay/test-essay-example.ts @@ -14,6 +14,10 @@ const example: LearningObjectExample = { 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; }, diff --git a/backend/tests/test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example.ts b/backend/tests/test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example.ts index cfb0b5a2..c5dcdc94 100644 --- a/backend/tests/test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example.ts +++ b/backend/tests/test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example.ts @@ -14,6 +14,10 @@ const example: LearningObjectExample = { 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; }, diff --git a/backend/tests/test-assets/learning-paths/test-conditions-example.ts b/backend/tests/test-assets/learning-paths/test-conditions-example.ts index b6cf3e9d..f06ff904 100644 --- a/backend/tests/test-assets/learning-paths/test-conditions-example.ts +++ b/backend/tests/test-assets/learning-paths/test-conditions-example.ts @@ -3,66 +3,84 @@ import { Language } from '../../../src/entities/content/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 type ConditionTestLearningPathAndLearningObjects = { + branchingObject: LearningObject, + extraExerciseObject: LearningObject, + finalObject: LearningObject, + learningPath: LearningPath +}; + +export function createConditionTestLearningPathAndLearningObjects(){ + 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, + }; +} const example: LearningPathExample = { createLearningPath: () => { - const learningPath = new LearningPath(); - learningPath.hruid = '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 learningPath; + return createConditionTestLearningPathAndLearningObjects().learningPath; }, }; diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 302015fb..461d2018 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -4,5 +4,6 @@ export default defineConfig({ test: { environment: 'node', globals: true, + testTimeout: 10000, }, });