Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: Extract statement construction functions, use async/await, begin extracting tests. #274

Merged
merged 5 commits into from
Oct 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
835 changes: 198 additions & 637 deletions src/AbstractCmi5.ts

Large diffs are not rendered by default.

725 changes: 725 additions & 0 deletions src/Cmi5Statements.ts

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/constants/Cmi5ContextExtension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export class Cmi5ContextExtension {
public static readonly MASTERY_SCORE =
"https://w3id.org/xapi/cmi5/context/extensions/masteryscore";
}
14 changes: 14 additions & 0 deletions src/constants/Cmi5InteractionType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const Cmi5InteractionIRI =
"http://adlnet.gov/expapi/activities/cmi.interaction" as const;

export class Cmi5InteractionType {
public static readonly TRUE_FALSE = "true-false";
public static readonly CHOICE = "choice";
public static readonly FILL_IN = "fill-in";
public static readonly LONG_FILL_IN = "long-fill-in";
public static readonly PERFORMANCE = "performance";
public static readonly NUMERIC = "numeric";
public static readonly SEQUENCING = "sequencing";
public static readonly MATCHING = "matching";
public static readonly LIKERT = "likert";
}
4 changes: 4 additions & 0 deletions src/constants/Cmi5ResultExtension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export class Cmi5ResultExtension {
public static readonly PROGRESS =
"https://w3id.org/xapi/cmi5/result/extensions/progress";
}
3 changes: 3 additions & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
export * from "./Cmi5ContextActivity";
export * from "./Cmi5ContextExtension";
export * from "./Cmi5DefinedVerbs";
export * from "./Cmi5InteractionType";
export * from "./Cmi5ResultExtension";
8 changes: 8 additions & 0 deletions src/interfaces/LaunchContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { LaunchParameters } from "interfaces/LaunchParameters";
import { LaunchData } from "interfaces/LaunchData";

export interface LaunchContext {
initializedDate: Date;
launchParameters: LaunchParameters;
launchData: LaunchData;
}
1 change: 1 addition & 0 deletions src/interfaces/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./AuthTokenResponse";
export * from "./LaunchContext";
export * from "./LaunchData";
export * from "./LaunchParameters";
export * from "./LearnerPreferences";
Expand Down
197 changes: 197 additions & 0 deletions test/__tests__/cmi5-statements.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import {
LaunchContext,
LaunchData,
LaunchParameters,
} from "../../src/interfaces";
import { randomUUID } from "node:crypto";
import { Cmi5DefinedVerbs } from "../../src/constants";
import {
Cmi5CompleteStatement,
Cmi5PassStatement,
} from "../../src/Cmi5Statements";
import { ResultScore } from "@xapi/xapi";

describe("Cmi5 Statements", () => {
const DEFAULT_LAUNCH_PARAMETERS: LaunchParameters = {
activityId: randomUUID(),
actor: { mbox: "test@example.com" },
endpoint: "http://fake-lrs.example.com",
fetch: "http://fake-fetch.lms.example.com",
registration: randomUUID(),
};
const DEFAULT_LAUNCH_DATA: LaunchData = {
contextTemplate: {},
launchMode: "Normal",
moveOn: "CompletedAndPassed",
};
const DEFAULT_LAUNCH_CONTEXT: LaunchContext = {
initializedDate: new Date(),
launchParameters: DEFAULT_LAUNCH_PARAMETERS,
launchData: DEFAULT_LAUNCH_DATA,
};

describe("Cmi5CompleteStatement", () => {
[
{ seconds: 125, expectedDuration: ["PT2M5S", "PT2M5.001S"] },
{ seconds: 31, expectedDuration: ["PT31S", "PT31.001S"] },
].forEach((ex) => {
it(`calculates duration as time since initialized (${ex.seconds}s=${ex.expectedDuration})`, () => {
const ctx: LaunchContext = {
...DEFAULT_LAUNCH_CONTEXT,
initializedDate: new Date(Date.now() - ex.seconds * 1000),
};
const statement = Cmi5CompleteStatement(ctx);
expect(statement.verb).toEqual(Cmi5DefinedVerbs.COMPLETED);
expect(ex.expectedDuration).toContain(statement.result.duration);
});
});

["Browse", "Review", null].forEach(
(launchMode: LaunchData["launchMode"]) => {
it(`throws exception if launchMode is '${launchMode}'`, async () => {
const ctx: LaunchContext = {
...DEFAULT_LAUNCH_CONTEXT,
launchData: {
...DEFAULT_LAUNCH_DATA,
launchMode: launchMode,
},
};
expect(() => Cmi5CompleteStatement(ctx)).toThrow(
expect.objectContaining({
message: "Can only send COMPLETED when launchMode is 'Normal'",
})
);
});
}
);
});

describe("Cmi5PassStatement", () => {
it("returns a statement with PASSED verb", async () => {
const ctx = DEFAULT_LAUNCH_CONTEXT;
const statement = Cmi5PassStatement(ctx);
expect(statement.verb).toEqual(Cmi5DefinedVerbs.PASSED);
});

it("returns a statement with `result.success === true`", async () => {
const ctx = DEFAULT_LAUNCH_CONTEXT;
const resultScore: ResultScore = { scaled: 0.9 };
const statement = Cmi5PassStatement(ctx, resultScore);
expect(statement.result.success).toEqual(true);
});

describe("`statement.result.score`", () => {
describe("when `launchData.masteryScore` is defined", () => {
const ctx = {
...DEFAULT_LAUNCH_CONTEXT,
launchData: {
...DEFAULT_LAUNCH_DATA,
masteryScore: 0.5,
},
};

describe("when `resultScore` is greater than `masteryScore`", () => {
it("returns `statement.result.success === true`", async () => {
expect(ctx.launchData.masteryScore).toBeGreaterThan(0);
const resultScore: ResultScore = { scaled: 0.9 };
expect(resultScore.scaled).toBeGreaterThan(
ctx.launchData.masteryScore
);
const statement = Cmi5PassStatement(ctx, resultScore);
expect(statement.result.success).toEqual(true);
});

it("returns a `ResultScore` when given a `ResultScore`", async () => {
expect(ctx.launchData.masteryScore).toBeGreaterThan(0);
const resultScore: ResultScore = { scaled: 0.9 };
expect(resultScore.scaled).toBeGreaterThan(
ctx.launchData.masteryScore
);
const statement = Cmi5PassStatement(ctx, resultScore);
expect(statement.result.score).toEqual(resultScore);
});

it("returns a `ResultScore` when given a number", async () => {
expect(ctx.launchData.masteryScore).toBeGreaterThan(0);
const scaledScore = 0.75;
expect(scaledScore).toBeGreaterThan(ctx.launchData.masteryScore);
const statement = Cmi5PassStatement(ctx, scaledScore);
expect(statement.result.score).toEqual({ scaled: scaledScore });
});

it("returns a statement when `resultScore === masteryScore`", async () => {
expect(ctx.launchData.masteryScore).toBeGreaterThan(0);
const scaledScore = ctx.launchData.masteryScore;
expect(scaledScore).toBeGreaterThan(0);
const statement = Cmi5PassStatement(ctx, scaledScore);
expect(statement.result.score).toEqual({ scaled: scaledScore });
});

[
{ seconds: 93, expectedDuration: ["PT1M33S", "PT1M33.001S"] },
{ seconds: 7593, expectedDuration: ["PT2H6M33S", "PT2H6M33.001S"] },
].forEach((ex) => {
it(`sends duration as time since initialized (${ex.seconds}=${ex.expectedDuration})`, async () => {
const ctx: LaunchContext = {
...DEFAULT_LAUNCH_CONTEXT,
initializedDate: new Date(Date.now() - ex.seconds * 1000),
launchData: {
...DEFAULT_LAUNCH_DATA,
masteryScore: 0.5,
},
};
expect(ctx.launchData.masteryScore).toBeGreaterThan(0);
const scaledScore = 0.9;
expect(scaledScore).toBeGreaterThan(0);
const statement = Cmi5PassStatement(ctx, scaledScore);
expect(ex.expectedDuration).toContain(statement.result.duration);
});
});
});

describe("when `resultScore` is less than `masteryScore`", () => {
it("throws an error that learner has not met mastery score", async () => {
expect(ctx.launchData.masteryScore).toBeGreaterThan(0);
const resultScore = 0.4;
expect(resultScore).toBeLessThan(ctx.launchData.masteryScore);
expect(() => Cmi5PassStatement(ctx, resultScore)).toThrow(
expect.objectContaining({
message: "Learner has not met Mastery Score",
})
);
});
});

describe("when `resultScore` is not provided", () => {
it("throws an error that learner has not met mastery score", async () => {
expect(ctx.launchData.masteryScore).toBeGreaterThan(0);
expect(() => Cmi5PassStatement(ctx)).toThrow(
expect.objectContaining({
message: "Learner has not met Mastery Score",
})
);
});
});
});
});

["Browse", "Review", null].forEach(
(launchMode: LaunchData["launchMode"]) => {
it(`throws exception if launchMode is '${launchMode}'`, async () => {
const ctx: LaunchContext = {
...DEFAULT_LAUNCH_CONTEXT,
launchData: {
...DEFAULT_LAUNCH_DATA,
launchMode: launchMode,
},
};
expect(() => Cmi5PassStatement(ctx)).toThrow(
expect.objectContaining({
message: "Can only send PASSED when launchMode is 'Normal'",
})
);
});
}
);
});
});
Loading