Skip to content
Draft
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
38 changes: 37 additions & 1 deletion src/lib/officer-learning/hub-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {
saveOfficerLearningLocalSettings,
upsertOfficerLearningUser,
} from "./hub-store";
import { canManageOfficerLearningReport } from "./access";
import {
canManageOfficerLearningReport,
canSyncOfficerLearning,
} from "./access";
import { officerLearningDbBackend } from "@/lib/db/backend";

describe("officer learning hub store", () => {
Expand Down Expand Up @@ -74,13 +77,46 @@ describe("officer learning hub store", () => {
).toBe(false);
expect(await getOfficerLearningUser("union-a", "missing")).toBeNull();
});

it("never lists completions from another union, even with a matching localId", async () => {
await saveOfficerLearningLocalSettings({
unionId: "union-a",
localId: "local-1",
reportingEnabled: true,
updatedById: "pres-1",
updatedAt: new Date().toISOString(),
});
await upsertOfficerLearningUser({
userId: "u-b",
unionId: "union-b",
localId: "local-1",
displayName: "Other union",
hubSyncEnabled: true,
shareWithLocal: true,
modules: {
"module-1": { status: "completed", scrollDepth: 100, quizPassed: true },
},
});

expect(await listSharedCompletionsForLocal("union-a", "local-1")).toEqual(
[],
);
});
});

describe("officer learning access", () => {
it("allows presidents and execs to manage reports", () => {
expect(canManageOfficerLearningReport(["local_president"])).toBe(true);
expect(canManageOfficerLearningReport(["local_exec"])).toBe(true);
expect(canManageOfficerLearningReport(["union_admin"])).toBe(true);
expect(canManageOfficerLearningReport(["local_steward"])).toBe(false);
expect(canManageOfficerLearningReport([])).toBe(false);
});

it("lets any signed-in role sync personal progress", () => {
expect(canSyncOfficerLearning(["local_steward"])).toBe(true);
expect(canSyncOfficerLearning(["local_member"])).toBe(true);
expect(canSyncOfficerLearning([])).toBe(false);
});
});

Expand Down
116 changes: 116 additions & 0 deletions src/lib/officer-learning/hub-sync-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
hydrateProgressFromHub,
maybePushHubProgressAfterPass,
} from "./hub-sync-client";
import {
OFFICER_LEARNING_PROGRESS_KEY,
getAllProgress,
replaceAllProgress,
} from "./progress";

function jsonResponse(body: unknown, ok = true, status = ok ? 200 : 500) {
return {
ok,
status,
json: async () => body,
} as Response;
}

describe("officer learning hub sync client", () => {
let fetchMock: ReturnType<typeof vi.fn>;

beforeEach(() => {
window.localStorage.clear();
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
window.localStorage.clear();
});

it("does not push after a quiz pass unless Hub sync is already enabled", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({
record: {
displayName: "Alex",
hubSyncEnabled: false,
shareWithLocal: false,
modules: {},
},
}),
);

await maybePushHubProgressAfterPass();

expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][1]?.method).toBeUndefined();
});

it("pushes device progress after a pass when Hub sync is on", async () => {
replaceAllProgress({
"module-1": { status: "completed", scrollDepth: 100, quizPassed: true },
});
fetchMock
.mockResolvedValueOnce(
jsonResponse({
record: {
displayName: "Alex",
hubSyncEnabled: true,
shareWithLocal: true,
modules: {},
},
}),
)
.mockResolvedValueOnce(jsonResponse({ ok: true }));

await maybePushHubProgressAfterPass();

expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1][1]).toMatchObject({ method: "PUT" });
const body = JSON.parse(fetchMock.mock.calls[1][1].body as string) as {
hubSyncEnabled: boolean;
modules: Record<string, { quizPassed: boolean }>;
};
expect(body.hubSyncEnabled).toBe(true);
expect(body.modules["module-1"]?.quizPassed).toBe(true);
});

it("merges Hub progress onto the device and ignores failed fetches", async () => {
replaceAllProgress({
"module-1": { status: "in_progress", scrollDepth: 20, quizPassed: false },
});
fetchMock.mockResolvedValueOnce(
jsonResponse({
record: {
displayName: "Alex",
hubSyncEnabled: true,
shareWithLocal: false,
modules: {
"module-1": {
status: "completed",
scrollDepth: 100,
quizPassed: true,
},
},
},
}),
);

const { progress, record } = await hydrateProgressFromHub();
expect(record?.hubSyncEnabled).toBe(true);
expect(progress["module-1"]).toMatchObject({
status: "completed",
quizPassed: true,
scrollDepth: 100,
});
expect(getAllProgress()["module-1"]?.quizPassed).toBe(true);

fetchMock.mockRejectedValueOnce(new Error("offline"));
await expect(maybePushHubProgressAfterPass()).resolves.toBeUndefined();
expect(window.localStorage.getItem(OFFICER_LEARNING_PROGRESS_KEY)).toBeTruthy();
});
});
36 changes: 36 additions & 0 deletions src/lib/officer-learning/modules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import {
OFFICER_LEARNING_MODULES,
getModuleById,
getModuleBySlug,
getNextModuleSlug,
} from "./modules";

describe("officer learning catalog", () => {
it("keeps unique sequential ids and slugs", () => {
const ids = OFFICER_LEARNING_MODULES.map((m) => m.id);
const slugs = OFFICER_LEARNING_MODULES.map((m) => m.slug);
expect(new Set(ids).size).toBe(ids.length);
expect(new Set(slugs).size).toBe(slugs.length);
expect(OFFICER_LEARNING_MODULES.map((m) => m.number)).toEqual([
1, 2, 3, 4, 5, 6,
]);
});

it("resolves modules by slug and id", () => {
expect(getModuleBySlug("democratic-governance")?.id).toBe("module-4");
expect(getModuleById("module-4")?.slug).toBe("democratic-governance");
expect(getModuleBySlug("missing")).toBeUndefined();
});

it("returns the next module slug for quiz navigation", () => {
expect(getNextModuleSlug("contract-enforcement")).toBe(
"progressive-discipline",
);
expect(getNextModuleSlug("financial-health")).toBe(
"building-collective-power",
);
expect(getNextModuleSlug("building-collective-power")).toBeNull();
expect(getNextModuleSlug("not-a-module")).toBeNull();
});
});
41 changes: 41 additions & 0 deletions src/lib/officer-learning/parse-module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,47 @@ describe("parseOfficerLearningModule", () => {
}
});

it("classifies French callout prefixes and quiz explanations", () => {
const markdown = `# Module 4: Gouvernance

## Objectif général
Équiper le président.

## Objectifs d'apprentissage
* **Know**: One
* **Feel**: Two
* **Be Able To**: Three

## Section One
Avertissement: rester calme.

Exercice: essayer ceci.

Réflexion: y penser.

## Quiz d'autoévaluation
### Question 1
Quelle est la première étape?
* A) Écouter
* B) Parler
* C) Voter
* D) Ajourner
**Correct Answer: A**
*Explication*: commencer par écouter.
`;
const parsed = parseOfficerLearningModule("module-4", markdown);
const callouts = parsed.sections
.flatMap((s) => s.blocks)
.filter((b) => b.type === "callout");
expect(callouts.map((b) => (b.type === "callout" ? b.variant : null))).toEqual(
["warning", "practice", "reflection"],
);
expect(callouts[0]?.type === "callout" && callouts[0].text).toBe(
"rester calme.",
);
expect(parsed.quiz[0]?.explanation).toContain("commencer par écouter");
});

it("parses floor checklist task items as checklist blocks", () => {
const markdown = fs.readFileSync(
path.join(process.cwd(), "src/content/officer-learning", "module-1.md"),
Expand Down
96 changes: 96 additions & 0 deletions src/lib/officer-learning/progress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
OFFICER_LEARNING_PROGRESS_EVENT,
OFFICER_LEARNING_PROGRESS_KEY,
getAllProgress,
getModuleProgress,
markModuleOpened,
markQuizPassed,
replaceAllProgress,
resetAllProgress,
statusLabelKey,
updateScrollDepth,
} from "./progress";

describe("officer learning progress", () => {
beforeEach(() => {
window.localStorage.clear();
});

afterEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
});

it("defaults unread modules to not started", () => {
expect(getModuleProgress("module-1")).toEqual({
status: "not_started",
scrollDepth: 0,
quizPassed: false,
});
});

it("marks a module in progress on open unless the quiz already passed", () => {
const opened = markModuleOpened("module-1");
expect(opened.status).toBe("in_progress");
expect(opened.quizPassed).toBe(false);
expect(opened.lastVisitedAt).toEqual(expect.any(String));

markQuizPassed("module-1");
expect(markModuleOpened("module-1").status).toBe("completed");
});

it("clamps scroll depth and never decreases it", () => {
expect(updateScrollDepth("module-2", 150).scrollDepth).toBe(100);
expect(updateScrollDepth("module-2", -4).scrollDepth).toBe(100);
expect(updateScrollDepth("module-2", 40).scrollDepth).toBe(100);

resetAllProgress();
expect(updateScrollDepth("module-2", 33.4).scrollDepth).toBe(33);
expect(updateScrollDepth("module-2", 10).scrollDepth).toBe(33);
});

it("keeps completed status after a passing quiz even on later scroll", () => {
markQuizPassed("module-3");
const next = updateScrollDepth("module-3", 12);
expect(next).toMatchObject({
status: "completed",
quizPassed: true,
scrollDepth: 100,
});
});

it("treats first positive scroll as in progress", () => {
expect(updateScrollDepth("module-4", 0).status).toBe("not_started");
expect(updateScrollDepth("module-4", 1).status).toBe("in_progress");
});

it("notifies listeners when a quiz is passed", () => {
const listener = vi.fn();
window.addEventListener(OFFICER_LEARNING_PROGRESS_EVENT, listener);
markQuizPassed("module-1");
window.removeEventListener(OFFICER_LEARNING_PROGRESS_EVENT, listener);
expect(listener).toHaveBeenCalledTimes(1);
expect(getAllProgress()["module-1"]?.quizPassed).toBe(true);
});

it("survives corrupt JSON and quota failures", () => {
window.localStorage.setItem(OFFICER_LEARNING_PROGRESS_KEY, "{not-json");
expect(getAllProgress()).toEqual({});

vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new DOMException("Quota exceeded", "QuotaExceededError");
});
expect(
replaceAllProgress({
"module-1": { status: "completed", scrollDepth: 100, quizPassed: true },
}),
).toBe(false);
});

it("maps status labels for the dashboard", () => {
expect(statusLabelKey("completed")).toBe("progress.completed");
expect(statusLabelKey("in_progress")).toBe("progress.inProgress");
expect(statusLabelKey("not_started")).toBe("progress.notStarted");
});
});
Loading
Loading